package bridge import ( "encoding/base64" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) type discardEvents struct{} func (discardEvents) OnEvent(string) {} func TestBridgeRequiresHumanSessionAndSafeOrigin(t *testing.T) { token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) cookie := `[{"name":"__Host-rastrillo_session","value":"` + token + `"}]` for _, tc := range []struct{ origin, cookies string }{ {"https://calls.example.com", `[]`}, {"https://calls.example.com", `[{"name":"Authorization","value":"Bearer mt_fake"}]`}, {"https://calls.example.com", `[{"name":"__Host-rastrillo_session","value":"bad"}]`}, {"http://calls.example.com", cookie}, {"https://calls.example.com/path", cookie}, } { if c, err := NewClient(tc.origin, "room", tc.cookies, discardEvents{}); err == nil { c.Close() t.Fatal("accepted invalid native session") } } c, err := NewClient("https://calls.example.com", "room", cookie, discardEvents{}) if err != nil { t.Fatal(err) } c.Close() } // loopbackSession is a session cookie as the handoff returns it for an // http://127.0.0.1 instance, which is the one place a test server can // be and the bridge will still send it. func loopbackSession() (string, string) { token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) return token, `[{"name":"rastrillo_session","value":"` + token + `"}]` } func TestCheckSessionCarriesTheCookieAndAnswersAValue(t *testing.T) { token, cookies := loopbackSession() status := http.StatusOK server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != token { t.Error("session not sent") } if status == http.StatusSeeOther { http.Redirect(w, r, "/signin", status) return } w.WriteHeader(status) })) defer server.Close() for s, want := range map[int]string{http.StatusOK: "member", http.StatusNotFound: "participant", http.StatusSeeOther: SignedOut} { status = s got, err := CheckSession(server.URL, cookies) if err != nil || got != want { t.Errorf("status %d gave %q, %v", s, got, err) } } status = http.StatusBadGateway if got, err := CheckSession(server.URL, cookies); err == nil || got == SignedOut { t.Fatal("a bad gateway signed the app out", got, err) } } func TestCheckSessionCallsAStoredCopyItCannotSendSignedOut(t *testing.T) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) defer server.Close() for _, cookies := range []string{``, `[]`, `not json`, `[{"name":"rastrillo_session","value":"short"}]`} { if got, err := CheckSession(server.URL, cookies); err != nil || got != SignedOut { t.Errorf("%q gave %q, %v", cookies, got, err) } } if calls.Load() != 0 { t.Fatal("sent a session it should have refused", calls.Load()) } } func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) { _, cookies := loopbackSession() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/j/live": http.Redirect(w, r, "/m/abc123/join", http.StatusSeeOther) case "/j/gone": http.Redirect(w, r, "/signin", http.StatusSeeOther) case "/m/xyz99/info": w.Write([]byte(`{"title":"x"}`)) default: http.NotFound(w, r) } })) defer server.Close() for address, want := range map[string]string{ server.URL: `{"room":""}`, server.URL + "/j/live": `{"room":"abc123"}`, server.URL + "/j/gone": `{"room":"","signedOut":true}`, server.URL + "/m/xyz99": `{"room":"xyz99"}`, // Signed in, as somebody this meeting is not for (2026-10-02). server.URL + "/m/other1": `{"room":"","noAccess":true}`, } { got, err := OpenLink(server.URL, cookies, address) if err != nil || got != want { t.Errorf("%q gave %s, %v", address, got, err) } } if _, err := OpenLink(server.URL, cookies, server.URL+"/j/replaced"); err == nil { t.Fatal("a replaced link opened") } } // TestReportsCarryTheHellosTokenAfterLeave is spec ยง15.26 through the // bridge: the token from the stream's hello goes on both report requests // with the session, the answers come back as values the host can act on, // and both still work once the call has been left. func TestReportsCarryTheHellosTokenAfterLeave(t *testing.T) { const report = "0123456789abcdef0123456789abcdef" session, cookies := loopbackSession() var quality, problem atomic.Int32 hello := make(chan struct{}) var once atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != session { t.Error("session not sent") } switch r.URL.Path { case "/m/room/stream": w.Header().Set("Content-Type", "text/event-stream") w.Write([]byte("data: {\"type\":\"hello\",\"peer\":\"self\",\"report\":\"" + report + "\"}\n\n")) w.(http.Flusher).Flush() if once.CompareAndSwap(false, true) { close(hello) } <-r.Context().Done() case "/m/room/quality": if r.Header.Get("X-Calls-Report") != report { t.Error("quality without the token") } if quality.Add(1) == 1 { w.WriteHeader(http.StatusAccepted) } else { w.WriteHeader(http.StatusNoContent) } case "/m/room/problem": if r.Header.Get("X-Calls-Report") != report { t.Error("problem without the token") } if problem.Add(1) == 1 { w.Write([]byte(`{"ref":"K7Q2M9PD"}`)) } else { w.WriteHeader(http.StatusTooManyRequests) } case "/m/room/info": w.Write([]byte(`{"title":"x"}`)) default: // The join, and the leave. w.WriteHeader(http.StatusOK) } })) defer server.Close() c, err := NewClient(server.URL, "room", cookies, discardEvents{}) if err != nil { t.Fatal(err) } if got := c.SendQuality(`{}`); got != "gone" { t.Fatalf("before the hello: %q", got) } if err := c.Start(); err != nil { t.Fatal(err) } <-hello // The hello is applied after it is read; give the core its turn. for i := 0; i < 100 && c.SendQuality(`{"v":1}`) == "gone"; i++ { time.Sleep(20 * time.Millisecond) } c.Leave() if got := c.SendQuality(`{"v":1,"final":true}`); got != "stop" { t.Fatalf("after leave, the kill switch: %q", got) } if got := c.SendProblem(`{"note":"x"}`); got != `{"ref":"K7Q2M9PD"}` { t.Fatalf("problem: %s", got) } if got := c.SendProblem(`{"note":"x"}`); got != `{"outcome":"too-many"}` { t.Fatalf("second problem: %s", got) } }