package callscore import ( "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) func TestCheckSessionReadsTheSettingsPageByStatusAlone(t *testing.T) { for _, c := range []struct { status int want Standing }{ {http.StatusOK, Member}, // requireParticipant's refusal of a guest and requireMember's of // an invited address are the same 404, and both mean the same // thing here: live, and not worth keeping. {http.StatusNotFound, Participant}, } { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/settings" { t.Errorf("asked %s %s", r.Method, r.URL.Path) } w.WriteHeader(c.status) })) got, err := CheckSession(t.Context(), server.URL, server.Client()) if err != nil || got != c.want { t.Errorf("status %d gave %q, %v", c.status, got, err) } server.Close() } } func TestCheckSessionOnlyCallsTheSignInRedirectSignedOut(t *testing.T) { // The whole point of the distinction: a credential is forgotten when // the instance says it is gone, and kept through anything else. for _, c := range []struct { handler http.HandlerFunc signedOut bool }{ {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true}, {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, true}, {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, false}, {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) }, false}, } { server := httptest.NewServer(c.handler) _, err := CheckSession(t.Context(), server.URL, server.Client()) if err == nil { t.Fatal("an unreadable answer was read") } if errors.Is(err, ErrSignedOut) != c.signedOut { t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err) } server.Close() } // And an instance that cannot be reached at all says nothing about the // session. server := httptest.NewServer(http.NotFoundHandler()) origin := server.URL server.Close() if _, err := CheckSession(t.Context(), origin, &http.Client{}); err == nil || errors.Is(err, ErrSignedOut) { t.Fatal("an unreachable instance signed the app out", err) } } func TestCheckSessionNeverFollowsTheRedirect(t *testing.T) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) http.Redirect(w, r, "/signin", http.StatusSeeOther) })) defer server.Close() CheckSession(t.Context(), server.URL, server.Client()) if calls.Load() != 1 { t.Fatal("followed the redirect", calls.Load()) } } func TestOpenLinkAnswersTheInstanceAsNoneWithoutAsking(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 _, address := range []string{server.URL, server.URL + "/"} { got, err := OpenLink(t.Context(), server.URL, address, server.Client()) if err != nil || got != "" { t.Errorf("%q gave %q, %v", address, got, err) } } if calls.Load() != 0 { t.Fatal("asked the server about something it did not need to", calls.Load()) } } // A /m/ address is asked about (2026-10-02): its info, with the session, // once, redirects not followed. Until then it was answered without // asking, and an app signed in as somebody without standing — a member's // app opened from a guest's browser — found out only when the join // failed. func TestOpenLinkAsksWhetherTheSessionMayEnterAMeeting(t *testing.T) { for _, c := range []struct { name string handler http.HandlerFunc want string signedOut bool noAccess bool }{ {"may enter", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"title":"x"}`)) }, "abc123", false, false}, {"session gone", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, "", true, false}, {"not this person's meeting", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, "", false, true}, {"server trouble", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(502) }, "", false, false}, } { t.Run(c.name, func(t *testing.T) { var asked []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { asked = append(asked, r.Method+" "+r.URL.Path) c.handler(w, r) })) defer server.Close() for _, address := range []string{server.URL + "/m/abc123", server.URL + "/m/abc123/join"} { asked = nil got, err := OpenLink(t.Context(), server.URL, address, server.Client()) if len(asked) != 1 || asked[0] != "GET /m/abc123/info" { t.Fatalf("%s asked %v", address, asked) } if c.want != "" && (err != nil || got != c.want) { t.Fatalf("%s gave %q, %v", address, got, err) } if c.want == "" && err == nil { t.Fatalf("%s opened", address) } if errors.Is(err, ErrSignedOut) != c.signedOut || errors.Is(err, ErrNoAccess) != c.noAccess { t.Fatalf("%s: %v", address, err) } } }) } } func TestOpenLinkFollowsAnInvitationWithTheSessionAndReadsTheRoom(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/j/token_1" { t.Errorf("followed %s", r.URL.Path) } http.Redirect(w, r, "/m/xyz789/join", http.StatusSeeOther) })) defer server.Close() got, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token_1", server.Client()) if err != nil || got != "xyz789" { t.Fatal(got, err) } } func TestOpenLinkTellsAGoneSessionFromAGoneLink(t *testing.T) { for _, c := range []struct { handler http.HandlerFunc signedOut bool }{ // followLink with no session: the guest name form, or the sign-in // page for a meeting that requires one. {func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("
")) }, true}, {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true}, // A replaced link is a 404 whoever follows it. {func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, false}, {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://elsewhere.test/m/abc/join", http.StatusSeeOther) }, false}, } { server := httptest.NewServer(c.handler) _, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token", server.Client()) if err == nil { t.Fatal("accepted a link that did not lead to a meeting") } if errors.Is(err, ErrSignedOut) != c.signedOut { t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err) } server.Close() } } func TestOpenLinkRefusesAnotherInstanceBeforeSendingItTheSession(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 _, address := range []string{"https://other.test/j/token", "https://other.test/m/abc", "not a link", server.URL + "/settings"} { if _, err := OpenLink(t.Context(), server.URL, address, server.Client()); err == nil { t.Errorf("accepted %q", address) } } if calls.Load() != 0 { t.Fatal("a refused address still reached a server", calls.Load()) } } func TestSignOutPostsWithTheOriginAndWantsTheRedirect(t *testing.T) { var origin, method, path atomic.Value server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin.Store(r.Header.Get("Origin")) method.Store(r.Method) path.Store(r.URL.Path) http.Redirect(w, r, "/signed-out", http.StatusSeeOther) })) defer server.Close() if err := SignOut(t.Context(), server.URL, server.Client()); err != nil { t.Fatal(err) } if method.Load() != "POST" || path.Load() != "/signout" || origin.Load() != server.URL { t.Fatal("wrong request", method.Load(), path.Load(), origin.Load()) } refused := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "cross-origin form submission refused", http.StatusForbidden) })) defer refused.Close() if err := SignOut(t.Context(), refused.URL, refused.Client()); err == nil || !strings.Contains(err.Error(), "did not confirm") { t.Fatal("a refused sign-out was reported as done", err) } } // In Home mode the server ends the Calls session and then answers 307 on // to {issuer}/signout (connect.Routes.Signout), a hop for a browser's // id.* session that the native client does not follow and has no use for. // The session is gone either way, so both redirects confirm the sign-out. func TestSignOutAcceptsTheHomeChain(t *testing.T) { var followed atomic.Bool issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed.Store(true) })) defer issuer.Close() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, issuer.URL+"/signout", http.StatusTemporaryRedirect) })) defer server.Close() if err := SignOut(t.Context(), server.URL, server.Client()); err != nil { t.Fatal("a Home-mode sign-out was reported as failed:", err) } if followed.Load() { t.Fatal("the native client followed the hop to the issuer") } }