| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // Spec §15.19 over the real handler: a peer's own media report reaches |
| 11 | // the other admitted peer as `media`, from the hub's envelope, with the |
| 12 | // payload re-encoded from the two booleans the handler judged — never |
| 13 | // the bytes the sender chose — and the sender hears nothing back. |
| 14 | func TestMediaReachesTheOtherPeerCanonicallyAndNotTheSender(t *testing.T) { |
| 15 | a, sqid, pa, sb := chatPair(t) |
| 16 | |
| 17 | // Whitespace, and the keys in the other order: both legal, and |
| 18 | // neither must survive onto Bob's stream. |
| 19 | body := `{"peer":"` + pa + `","type":"media","payload":{ "cameraOff" : false , "muted" : true }}` |
| 20 | res, rb := a.signalRaw(sqid, body) |
| 21 | if res.StatusCode != http.StatusOK { |
| 22 | t.Fatalf("media: %d %s", res.StatusCode, rb) |
| 23 | } |
| 24 | ev, data, ok := sb.nextOf("media", 2*time.Second) |
| 25 | if !ok || ev != "media" { |
| 26 | t.Fatal("Bob did not receive the media report") |
| 27 | } |
| 28 | s := string(data) |
| 29 | if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) || |
| 30 | !strings.Contains(s, `"payload":{"muted":true,"cameraOff":false}`) { |
| 31 | t.Fatalf("envelope %s", s) |
| 32 | } |
| 33 | if strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) || strings.Contains(s, `"gen"`) { |
| 34 | t.Fatalf("a media envelope carries no seq, to or gen: %s", s) |
| 35 | } |
| 36 | |
| 37 | // The same state again is accepted and silent. |
| 38 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| 39 | if res.StatusCode != http.StatusOK { |
| 40 | t.Fatalf("repeat: %d", res.StatusCode) |
| 41 | } |
| 42 | if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" { |
| 43 | t.Fatal("a report that changed nothing reached Bob") |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // §15.19's "state, not event": a peer who joins after the report reads |
| 48 | // it from the roster it is admitted with, and a peer who has never |
| 49 | // reported — an old client — is in that roster with no media at all. |
| 50 | func TestMediaIsInTheRosterALateJoinerGets(t *testing.T) { |
| 51 | x, sqid, _ := memberWithMeeting(t) |
| 52 | a := x.browser(t) |
| 53 | a.signIn("paul@example.com") |
| 54 | sa := a.stream("/m/" + sqid + "/stream") |
| 55 | defer sa.close() |
| 56 | pa, _ := sa.hello() |
| 57 | a.signal(sqid, pa, `{"type":"join"}`) |
| 58 | sa.nextOf("roster", 2*time.Second) |
| 59 | if res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":true}}`); res.StatusCode != http.StatusOK { |
| 60 | t.Fatalf("media: %d %s", res.StatusCode, rb) |
| 61 | } |
| 62 | |
| 63 | b := newMemberBrowser(t, x, "paul@rslw.com") |
| 64 | sb := b.stream("/m/" + sqid + "/stream") |
| 65 | defer sb.close() |
| 66 | pb, _ := sb.hello() |
| 67 | b.signal(sqid, pb, `{"type":"join"}`) |
| 68 | _, data, ok := sb.nextOf("roster", 2*time.Second) |
| 69 | if !ok { |
| 70 | t.Fatal("Bob got no roster") |
| 71 | } |
| 72 | s := string(data) |
| 73 | want := `"peer":"` + pa + `","name":"paul","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}` |
| 74 | if !strings.Contains(s, want) { |
| 75 | t.Fatalf("Bob's roster should carry Paul's media; got %s", s) |
| 76 | } |
| 77 | // Bob has not reported, so his own entry carries no media key. |
| 78 | if strings.Count(s, `"media"`) != 1 { |
| 79 | t.Fatalf("only Paul has reported; roster %s", s) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // The shape is exact, and judged on raw bytes the way chat and share |
| 84 | // are (§15.15): both keys, spelled exactly, each once, booleans, and |
| 85 | // nothing else — and never targeted. |
| 86 | func TestMediaShapes(t *testing.T) { |
| 87 | a, sqid, pa, _ := chatPair(t) |
| 88 | bad := []struct { |
| 89 | name, payload string |
| 90 | }{ |
| 91 | {"missing cameraOff", `{"muted":true}`}, |
| 92 | {"missing muted", `{"cameraOff":true}`}, |
| 93 | {"empty object", `{}`}, |
| 94 | {"a third key", `{"muted":true,"cameraOff":false,"deafened":true}`}, |
| 95 | {"a capitalised key", `{"Muted":true,"cameraOff":false}`}, |
| 96 | {"snake case", `{"muted":true,"camera_off":false}`}, |
| 97 | {"a duplicated key", `{"muted":true,"muted":false,"cameraOff":false}`}, |
| 98 | {"a string for a bool", `{"muted":"yes","cameraOff":false}`}, |
| 99 | {"null for a bool", `{"muted":null,"cameraOff":false}`}, |
| 100 | {"a number for a bool", `{"muted":1,"cameraOff":0}`}, |
| 101 | {"not an object", `[true,false]`}, |
| 102 | {"trailing content", `{"muted":true,"cameraOff":false} {}`}, |
| 103 | // §15.20's two optional members, each held as hard as the two |
| 104 | // required ones. |
| 105 | {"audioBlocked null", `{"muted":true,"cameraOff":false,"audioBlocked":null}`}, |
| 106 | {"audioBlocked a string", `{"muted":true,"cameraOff":false,"audioBlocked":"yes"}`}, |
| 107 | {"audioBlocked twice", `{"muted":true,"cameraOff":false,"audioBlocked":true,"audioBlocked":false}`}, |
| 108 | {"audioBlocked alone", `{"audioBlocked":true}`}, |
| 109 | {"noSoundFrom null", `{"muted":true,"cameraOff":false,"noSoundFrom":null}`}, |
| 110 | {"noSoundFrom an object", `{"muted":true,"cameraOff":false,"noSoundFrom":{"` + strings.Repeat("a", 32) + `":true}}`}, |
| 111 | {"noSoundFrom a short id", `{"muted":true,"cameraOff":false,"noSoundFrom":["abc"]}`}, |
| 112 | {"noSoundFrom an upper-case id", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("A", 32) + `"]}`}, |
| 113 | {"noSoundFrom a number", `{"muted":true,"cameraOff":false,"noSoundFrom":[1]}`}, |
| 114 | {"noSoundFrom a repeat", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, |
| 115 | {"noSoundFrom sixteen ids", `{"muted":true,"cameraOff":false,"noSoundFrom":[` + sixteenIDs() + `]}`}, |
| 116 | // §15.21's screenStalled, held exactly as noSoundFrom is. |
| 117 | {"screenStalled null", `{"muted":true,"cameraOff":false,"screenStalled":null}`}, |
| 118 | {"screenStalled a bool", `{"muted":true,"cameraOff":false,"screenStalled":true}`}, |
| 119 | {"screenStalled a short id", `{"muted":true,"cameraOff":false,"screenStalled":["abc"]}`}, |
| 120 | {"screenStalled a repeat", `{"muted":true,"cameraOff":false,"screenStalled":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, |
| 121 | {"screenStalled sixteen ids", `{"muted":true,"cameraOff":false,"screenStalled":[` + sixteenIDs() + `]}`}, |
| 122 | {"screenStalled twice", `{"muted":true,"cameraOff":false,"screenStalled":[],"screenStalled":[]}`}, |
| 123 | {"screenStalled snake case", `{"muted":true,"cameraOff":false,"screen_stalled":[]}`}, |
| 124 | // §15.24's hearing, held exactly as noSoundFrom is. |
| 125 | {"hearing null", `{"muted":true,"cameraOff":false,"hearing":null}`}, |
| 126 | {"hearing a bool", `{"muted":true,"cameraOff":false,"hearing":true}`}, |
| 127 | {"hearing an object", `{"muted":true,"cameraOff":false,"hearing":{"` + strings.Repeat("a", 32) + `":true}}`}, |
| 128 | {"hearing a short id", `{"muted":true,"cameraOff":false,"hearing":["abc"]}`}, |
| 129 | {"hearing an upper-case id", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("A", 32) + `"]}`}, |
| 130 | {"hearing a number", `{"muted":true,"cameraOff":false,"hearing":[1]}`}, |
| 131 | {"hearing a repeat", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, |
| 132 | {"hearing sixteen ids", `{"muted":true,"cameraOff":false,"hearing":[` + sixteenIDs() + `]}`}, |
| 133 | {"hearing twice", `{"muted":true,"cameraOff":false,"hearing":[],"hearing":[]}`}, |
| 134 | {"hearing capitalised", `{"muted":true,"cameraOff":false,"Hearing":[]}`}, |
| 135 | {"hearing alone", `{"hearing":[]}`}, |
| 136 | } |
| 137 | for _, c := range bad { |
| 138 | t.Run(c.name, func(t *testing.T) { |
| 139 | body := `{"peer":"` + pa + `","type":"media","payload":` + c.payload + `}` |
| 140 | res, rb := a.signalRaw(sqid, body) |
| 141 | if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(rb), `"error":"bad-request"`) { |
| 142 | t.Fatalf("%d %s, want 400 bad-request", res.StatusCode, rb) |
| 143 | } |
| 144 | }) |
| 145 | } |
| 146 | t.Run("no payload at all", func(t *testing.T) { |
| 147 | res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media"}`) |
| 148 | if res.StatusCode != http.StatusBadRequest { |
| 149 | t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| 150 | } |
| 151 | }) |
| 152 | t.Run("targeted", func(t *testing.T) { |
| 153 | res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","to":"`+strings.Repeat("a", 32)+`","payload":{"muted":true,"cameraOff":false}}`) |
| 154 | if res.StatusCode != http.StatusBadRequest { |
| 155 | t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| 156 | } |
| 157 | }) |
| 158 | t.Run("with a seq", func(t *testing.T) { |
| 159 | res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","seq":3,"payload":{"muted":true,"cameraOff":false}}`) |
| 160 | if res.StatusCode != http.StatusBadRequest { |
| 161 | t.Fatalf("%d %s, want 400", res.StatusCode, rb) |
| 162 | } |
| 163 | }) |
| 164 | t.Run("padded past the cap", func(t *testing.T) { |
| 165 | payload := `{"muted":true,` + strings.Repeat(" ", 3072) + `"cameraOff":false}` |
| 166 | res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`) |
| 167 | if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) { |
| 168 | t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb) |
| 169 | } |
| 170 | }) |
| 171 | } |
| 172 | |
| 173 | func sixteenIDs() string { |
| 174 | ids := make([]string, 16) |
| 175 | for i := range ids { |
| 176 | ids[i] = `"` + strings.Repeat("0", 30) + string("0123456789abcdef"[i]) + `0"` |
| 177 | } |
| 178 | return strings.Join(ids, ",") |
| 179 | } |
| 180 | |
| 181 | // Spec §15.20: the receiving half rides the same report. A browser that |
| 182 | // was refused sound, and one hearing silence from a peer, say so; the |
| 183 | // hub carries it canonically to the others and in rosters; the ordinary |
| 184 | // report is still the two-key object, so a report that clears the bad |
| 185 | // news is a change that goes out as exactly that. |
| 186 | func TestMediaCarriesWhatTheReceiverCanHear(t *testing.T) { |
| 187 | a, sqid, pa, sb := chatPair(t) |
| 188 | silent := strings.Repeat("0", 31) + "1" |
| 189 | body := `{"peer":"` + pa + `","type":"media","payload":{"noSoundFrom":["` + silent + `"], "audioBlocked":true,"cameraOff":false,"muted":false}}` |
| 190 | res, rb := a.signalRaw(sqid, body) |
| 191 | if res.StatusCode != http.StatusOK { |
| 192 | t.Fatalf("media: %d %s", res.StatusCode, rb) |
| 193 | } |
| 194 | _, data, ok := sb.nextOf("media", 2*time.Second) |
| 195 | if !ok { |
| 196 | t.Fatal("Bob did not receive the report") |
| 197 | } |
| 198 | want := `"payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + silent + `"]}` |
| 199 | if !strings.Contains(string(data), want) { |
| 200 | t.Fatalf("envelope %s, want %s", data, want) |
| 201 | } |
| 202 | |
| 203 | // Fifteen ids is the most there can be, and is accepted. |
| 204 | ids := strings.Split(sixteenIDs(), ",")[:15] |
| 205 | res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[`+strings.Join(ids, ",")+`]}}`) |
| 206 | if res.StatusCode != http.StatusOK { |
| 207 | t.Fatalf("fifteen: %d %s", res.StatusCode, rb) |
| 208 | } |
| 209 | sb.nextOf("media", 2*time.Second) |
| 210 | |
| 211 | // Cleared: back to the two-key report, which is a change. |
| 212 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[]}}`) |
| 213 | if res.StatusCode != http.StatusOK { |
| 214 | t.Fatalf("clear: %d", res.StatusCode) |
| 215 | } |
| 216 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 217 | if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { |
| 218 | t.Fatalf("cleared report %s", data) |
| 219 | } |
| 220 | // And an empty list is the same report as no list at all. |
| 221 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":false}}`) |
| 222 | if res.StatusCode != http.StatusOK { |
| 223 | t.Fatalf("repeat: %d", res.StatusCode) |
| 224 | } |
| 225 | if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" { |
| 226 | t.Fatal("a report that changed nothing reached Bob") |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // Spec §15.21: a receiver whose view of someone's screen has stalled says |
| 231 | // so in the same report, beside §15.20's lists; the hub carries it in the |
| 232 | // canonical order, and the longest honest report — both lists full — is |
| 233 | // accepted. |
| 234 | func TestMediaCarriesStalledScreens(t *testing.T) { |
| 235 | a, sqid, pa, sb := chatPair(t) |
| 236 | sharer := strings.Repeat("0", 31) + "2" |
| 237 | res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"screenStalled":["`+sharer+`"],"muted":false,"cameraOff":true}}`) |
| 238 | if res.StatusCode != http.StatusOK { |
| 239 | t.Fatalf("media: %d %s", res.StatusCode, rb) |
| 240 | } |
| 241 | _, data, ok := sb.nextOf("media", 2*time.Second) |
| 242 | want := `"payload":{"muted":false,"cameraOff":true,"screenStalled":["` + sharer + `"]}` |
| 243 | if !ok || !strings.Contains(string(data), want) { |
| 244 | t.Fatalf("envelope %s, want %s", data, want) |
| 245 | } |
| 246 | ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",") |
| 247 | res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}}`) |
| 248 | if res.StatusCode != http.StatusOK { |
| 249 | t.Fatalf("both lists full: %d %s", res.StatusCode, rb) |
| 250 | } |
| 251 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 252 | if !ok || !strings.Contains(string(data), `"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}`) { |
| 253 | t.Fatalf("full report %s", data) |
| 254 | } |
| 255 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"screenStalled":[]}}`) |
| 256 | if res.StatusCode != http.StatusOK { |
| 257 | t.Fatalf("clear: %d", res.StatusCode) |
| 258 | } |
| 259 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 260 | if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { |
| 261 | t.Fatalf("cleared report %s", data) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // Spec §15.24: who this client can actually hear. Good news, unlike the |
| 266 | // lists before it, and its presence is news too: an empty list ("nobody |
| 267 | // yet") travels as [] and is a different report from one without the |
| 268 | // key, which is a client that does not say. The longest honest report — |
| 269 | // all three lists full — fits under the cap. |
| 270 | func TestMediaCarriesWhoTheReceiverHears(t *testing.T) { |
| 271 | a, sqid, pa, sb := chatPair(t) |
| 272 | res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`) |
| 273 | if res.StatusCode != http.StatusOK { |
| 274 | t.Fatalf("empty hearing: %d %s", res.StatusCode, rb) |
| 275 | } |
| 276 | _, data, ok := sb.nextOf("media", 2*time.Second) |
| 277 | if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false,"hearing":[]}`) { |
| 278 | t.Fatalf("empty hearing must travel as []: %s", data) |
| 279 | } |
| 280 | heard := strings.Repeat("0", 31) + "3" |
| 281 | res, rb = a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"hearing":["`+heard+`"],"muted":true,"cameraOff":false,"noSoundFrom":["`+heard+`"]}}`) |
| 282 | if res.StatusCode != http.StatusOK { |
| 283 | t.Fatalf("hearing: %d %s", res.StatusCode, rb) |
| 284 | } |
| 285 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 286 | want := `"payload":{"muted":true,"cameraOff":false,"noSoundFrom":["` + heard + `"],"hearing":["` + heard + `"]}` |
| 287 | if !ok || !strings.Contains(string(data), want) { |
| 288 | t.Fatalf("envelope %s, want %s", data, want) |
| 289 | } |
| 290 | ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",") |
| 291 | full := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[` + ids + `],"screenStalled":[` + ids + `],"hearing":[` + ids + `]}` |
| 292 | res, rb = a.signal(sqid, pa, `{"type":"media","payload":`+full+`}`) |
| 293 | if res.StatusCode != http.StatusOK { |
| 294 | t.Fatalf("all three lists full: %d %s", res.StatusCode, rb) |
| 295 | } |
| 296 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 297 | if !ok || !strings.Contains(string(data), `"payload":`+full) { |
| 298 | t.Fatalf("full report %s", data) |
| 299 | } |
| 300 | // Without the key: a different report from the empty list, so it is |
| 301 | // a change and goes out as the two-key object. |
| 302 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`) |
| 303 | if res.StatusCode != http.StatusOK { |
| 304 | t.Fatalf("back to empty: %d", res.StatusCode) |
| 305 | } |
| 306 | sb.nextOf("media", 2*time.Second) |
| 307 | res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false}}`) |
| 308 | if res.StatusCode != http.StatusOK { |
| 309 | t.Fatalf("no key: %d", res.StatusCode) |
| 310 | } |
| 311 | _, data, ok = sb.nextOf("media", 2*time.Second) |
| 312 | if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { |
| 313 | t.Fatalf("absent hearing must be a change from [], got %s", data) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // A waiting guest has nobody to tell: its report is the usual 404, and |
| 318 | // it hears nothing of the room's. |
| 319 | func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) { |
| 320 | x, sqid, tok := memberWithMeeting(t) |
| 321 | enableWaitingRoom(t, x, sqid) |
| 322 | a := x.browser(t) |
| 323 | a.signIn("paul@example.com") |
| 324 | sa := a.stream("/m/" + sqid + "/stream") |
| 325 | defer sa.close() |
| 326 | pa, _ := sa.hello() |
| 327 | a.signal(sqid, pa, `{"type":"join"}`) |
| 328 | sa.nextOf("roster", 2*time.Second) |
| 329 | |
| 330 | g := x.browser(t) |
| 331 | g.get("/j/" + tok) |
| 332 | g.signIn("waiting-guest@example.com") |
| 333 | gs := g.stream("/m/" + sqid + "/stream") |
| 334 | defer gs.close() |
| 335 | gpeer, _ := gs.hello() |
| 336 | g.signal(sqid, gpeer, `{"type":"join"}`) |
| 337 | sa.nextOf("knock", 2*time.Second) |
| 338 | |
| 339 | res, rb := g.signal(sqid, gpeer, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| 340 | if res.StatusCode != http.StatusNotFound || !strings.Contains(string(rb), `"error":"not-found"`) { |
| 341 | t.Fatalf("a waiting guest's media must be the usual 404: %d %s", res.StatusCode, rb) |
| 342 | } |
| 343 | a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) |
| 344 | if ev, _, ok := gs.next(400 * time.Millisecond); ok && ev == "media" { |
| 345 | t.Fatal("a waiting guest heard a media report") |
| 346 | } |
| 347 | } |
| 348 | |