| 1 | package callscore |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "strings" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestStartMeetingSendsBothLocksAndAnswersTheSqid(t *testing.T) { |
| 13 | var origin, contentType, method, path atomic.Value |
| 14 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 15 | origin.Store(r.Header.Get("Origin")) |
| 16 | contentType.Store(r.Header.Get("Content-Type")) |
| 17 | method.Store(r.Method) |
| 18 | path.Store(r.URL.Path) |
| 19 | json.NewEncoder(w).Encode(map[string]string{"sqid": "abc123", "title": "Call, 17 Sep 11:23"}) |
| 20 | })) |
| 21 | defer server.Close() |
| 22 | sqid, err := StartMeeting(t.Context(), server.URL, server.Client()) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if sqid != "abc123" { |
| 27 | t.Fatal("wrong meeting", sqid) |
| 28 | } |
| 29 | if method.Load() != "POST" || path.Load() != "/native/meetings/start" { |
| 30 | t.Fatal("wrong request", method.Load(), path.Load()) |
| 31 | } |
| 32 | // csrf.Protect refuses a POST that carries neither, and it is worth a |
| 33 | // test because the failure is a 403 on a live server and nothing a |
| 34 | // unit test of the handler would ever see. |
| 35 | if origin.Load() != server.URL { |
| 36 | t.Fatal("origin not sent", origin.Load()) |
| 37 | } |
| 38 | if contentType.Load() != "application/json" { |
| 39 | t.Fatal("content type not sent", contentType.Load()) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestStartMeetingRefusesAnAnswerItCannotOpen(t *testing.T) { |
| 44 | for _, body := range []string{`{"sqid":""}`, `{"sqid":"../admin"}`, `{"sqid":"has space"}`, `{"sqid":"` + strings.Repeat("a", 33) + `"}`, `not json`, `{}`} { |
| 45 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 46 | w.Write([]byte(body)) |
| 47 | })) |
| 48 | if _, err := StartMeeting(t.Context(), server.URL, server.Client()); err == nil { |
| 49 | t.Errorf("accepted %q", body) |
| 50 | } |
| 51 | server.Close() |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestStartMeetingNeverFollowsARedirectWithTheSession(t *testing.T) { |
| 56 | // A session that went comes back as the sign-in page's redirect. Follow |
| 57 | // it and the app carries a credential to wherever it points, then |
| 58 | // decodes a page as JSON. |
| 59 | var calls atomic.Int32 |
| 60 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 61 | calls.Add(1) |
| 62 | http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| 63 | })) |
| 64 | defer server.Close() |
| 65 | _, err := StartMeeting(t.Context(), server.URL, server.Client()) |
| 66 | if err == nil { |
| 67 | t.Fatal("redirect accepted") |
| 68 | } |
| 69 | if !strings.Contains(err.Error(), "sign in") { |
| 70 | t.Fatal("unhelpful error", err) |
| 71 | } |
| 72 | if calls.Load() != 1 { |
| 73 | t.Fatal("followed the redirect", calls.Load()) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestStartMeetingRefusesAnUnsafeOriginBeforeSendingAnything(t *testing.T) { |
| 78 | var calls atomic.Int32 |
| 79 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| 80 | defer server.Close() |
| 81 | for _, origin := range []string{"http://example.test", "https://alice:secret@example.test", "https://example.test?x=1", "https://example.test/m/abc", "", "not a url"} { |
| 82 | if _, err := StartMeeting(t.Context(), origin, server.Client()); err == nil { |
| 83 | t.Errorf("accepted %q", origin) |
| 84 | } |
| 85 | } |
| 86 | if calls.Load() != 0 { |
| 87 | t.Fatal("a refused origin still reached a server", calls.Load()) |
| 88 | } |
| 89 | if _, err := StartMeeting(t.Context(), server.URL, nil); err == nil { |
| 90 | t.Fatal("accepted a client with no session") |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) { |
| 95 | for _, c := range []struct { |
| 96 | status int |
| 97 | want string |
| 98 | }{ |
| 99 | {http.StatusUnauthorized, "sign in"}, |
| 100 | {http.StatusForbidden, "cannot start meetings"}, |
| 101 | {http.StatusNotFound, "cannot start meetings"}, |
| 102 | {http.StatusInternalServerError, "just now"}, |
| 103 | } { |
| 104 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | http.Error(w, "no", c.status) |
| 106 | })) |
| 107 | _, err := StartMeeting(t.Context(), server.URL, server.Client()) |
| 108 | if err == nil || !strings.Contains(err.Error(), c.want) { |
| 109 | t.Errorf("status %d gave %v, wanted something about %q", c.status, err, c.want) |
| 110 | } |
| 111 | server.Close() |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestRegisterDeviceSendsTheTokenAndBothLocks(t *testing.T) { |
| 116 | var body, origin, contentType atomic.Value |
| 117 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 118 | origin.Store(r.Header.Get("Origin")) |
| 119 | contentType.Store(r.Header.Get("Content-Type")) |
| 120 | var in map[string]string |
| 121 | json.NewDecoder(r.Body).Decode(&in) |
| 122 | body.Store(in) |
| 123 | w.WriteHeader(http.StatusNoContent) |
| 124 | })) |
| 125 | defer server.Close() |
| 126 | if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabbcc", "macos"); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | in := body.Load().(map[string]string) |
| 130 | if in["token"] != "aabbcc" || in["platform"] != "macos" { |
| 131 | t.Fatal("wrong body", in) |
| 132 | } |
| 133 | if origin.Load() != server.URL || contentType.Load() != "application/json" { |
| 134 | t.Fatal("missing a lock", origin.Load(), contentType.Load()) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestRegisterDeviceRefusesAnUnknownPlatformWithoutCallingTheServer(t *testing.T) { |
| 139 | var calls atomic.Int32 |
| 140 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| 141 | defer server.Close() |
| 142 | if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "android"); err == nil { |
| 143 | t.Fatal("accepted an unknown platform") |
| 144 | } |
| 145 | if calls.Load() != 0 { |
| 146 | t.Fatal("called the server anyway") |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestRegisterDeviceOnAnOlderServerIsAPlainRefusal(t *testing.T) { |
| 151 | // A server without the endpoint has no push. That is a fact about |
| 152 | // the server, not a failure worth showing anyone. |
| 153 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 154 | http.NotFound(w, r) |
| 155 | })) |
| 156 | defer server.Close() |
| 157 | err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "ios") |
| 158 | if err == nil || !strings.Contains(err.Error(), "does not take device registrations") { |
| 159 | t.Fatal("unhelpful error", err) |
| 160 | } |
| 161 | } |
| 162 | |