package callstest import ( "net/http" "strings" "testing" "time" ) // Spec §15.19 over the real handler: a peer's own media report reaches // the other admitted peer as `media`, from the hub's envelope, with the // payload re-encoded from the two booleans the handler judged — never // the bytes the sender chose — and the sender hears nothing back. func TestMediaReachesTheOtherPeerCanonicallyAndNotTheSender(t *testing.T) { a, sqid, pa, sb := chatPair(t) // Whitespace, and the keys in the other order: both legal, and // neither must survive onto Bob's stream. body := `{"peer":"` + pa + `","type":"media","payload":{ "cameraOff" : false , "muted" : true }}` res, rb := a.signalRaw(sqid, body) if res.StatusCode != http.StatusOK { t.Fatalf("media: %d %s", res.StatusCode, rb) } ev, data, ok := sb.nextOf("media", 2*time.Second) if !ok || ev != "media" { t.Fatal("Bob did not receive the media report") } s := string(data) if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) || !strings.Contains(s, `"payload":{"muted":true,"cameraOff":false}`) { t.Fatalf("envelope %s", s) } if strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) || strings.Contains(s, `"gen"`) { t.Fatalf("a media envelope carries no seq, to or gen: %s", s) } // The same state again is accepted and silent. res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) if res.StatusCode != http.StatusOK { t.Fatalf("repeat: %d", res.StatusCode) } if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" { t.Fatal("a report that changed nothing reached Bob") } } // §15.19's "state, not event": a peer who joins after the report reads // it from the roster it is admitted with, and a peer who has never // reported — an old client — is in that roster with no media at all. func TestMediaIsInTheRosterALateJoinerGets(t *testing.T) { x, sqid, _ := memberWithMeeting(t) a := x.browser(t) a.signIn("paul@example.com") sa := a.stream("/m/" + sqid + "/stream") defer sa.close() pa, _ := sa.hello() a.signal(sqid, pa, `{"type":"join"}`) sa.nextOf("roster", 2*time.Second) if res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":true}}`); res.StatusCode != http.StatusOK { t.Fatalf("media: %d %s", res.StatusCode, rb) } b := newMemberBrowser(t, x, "paul@rslw.com") sb := b.stream("/m/" + sqid + "/stream") defer sb.close() pb, _ := sb.hello() b.signal(sqid, pb, `{"type":"join"}`) _, data, ok := sb.nextOf("roster", 2*time.Second) if !ok { t.Fatal("Bob got no roster") } s := string(data) want := `"peer":"` + pa + `","name":"paul","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}` if !strings.Contains(s, want) { t.Fatalf("Bob's roster should carry Paul's media; got %s", s) } // Bob has not reported, so his own entry carries no media key. if strings.Count(s, `"media"`) != 1 { t.Fatalf("only Paul has reported; roster %s", s) } } // The shape is exact, and judged on raw bytes the way chat and share // are (§15.15): both keys, spelled exactly, each once, booleans, and // nothing else — and never targeted. func TestMediaShapes(t *testing.T) { a, sqid, pa, _ := chatPair(t) bad := []struct { name, payload string }{ {"missing cameraOff", `{"muted":true}`}, {"missing muted", `{"cameraOff":true}`}, {"empty object", `{}`}, {"a third key", `{"muted":true,"cameraOff":false,"deafened":true}`}, {"a capitalised key", `{"Muted":true,"cameraOff":false}`}, {"snake case", `{"muted":true,"camera_off":false}`}, {"a duplicated key", `{"muted":true,"muted":false,"cameraOff":false}`}, {"a string for a bool", `{"muted":"yes","cameraOff":false}`}, {"null for a bool", `{"muted":null,"cameraOff":false}`}, {"a number for a bool", `{"muted":1,"cameraOff":0}`}, {"not an object", `[true,false]`}, {"trailing content", `{"muted":true,"cameraOff":false} {}`}, // §15.20's two optional members, each held as hard as the two // required ones. {"audioBlocked null", `{"muted":true,"cameraOff":false,"audioBlocked":null}`}, {"audioBlocked a string", `{"muted":true,"cameraOff":false,"audioBlocked":"yes"}`}, {"audioBlocked twice", `{"muted":true,"cameraOff":false,"audioBlocked":true,"audioBlocked":false}`}, {"audioBlocked alone", `{"audioBlocked":true}`}, {"noSoundFrom null", `{"muted":true,"cameraOff":false,"noSoundFrom":null}`}, {"noSoundFrom an object", `{"muted":true,"cameraOff":false,"noSoundFrom":{"` + strings.Repeat("a", 32) + `":true}}`}, {"noSoundFrom a short id", `{"muted":true,"cameraOff":false,"noSoundFrom":["abc"]}`}, {"noSoundFrom an upper-case id", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("A", 32) + `"]}`}, {"noSoundFrom a number", `{"muted":true,"cameraOff":false,"noSoundFrom":[1]}`}, {"noSoundFrom a repeat", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, {"noSoundFrom sixteen ids", `{"muted":true,"cameraOff":false,"noSoundFrom":[` + sixteenIDs() + `]}`}, // §15.21's screenStalled, held exactly as noSoundFrom is. {"screenStalled null", `{"muted":true,"cameraOff":false,"screenStalled":null}`}, {"screenStalled a bool", `{"muted":true,"cameraOff":false,"screenStalled":true}`}, {"screenStalled a short id", `{"muted":true,"cameraOff":false,"screenStalled":["abc"]}`}, {"screenStalled a repeat", `{"muted":true,"cameraOff":false,"screenStalled":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, {"screenStalled sixteen ids", `{"muted":true,"cameraOff":false,"screenStalled":[` + sixteenIDs() + `]}`}, {"screenStalled twice", `{"muted":true,"cameraOff":false,"screenStalled":[],"screenStalled":[]}`}, {"screenStalled snake case", `{"muted":true,"cameraOff":false,"screen_stalled":[]}`}, // §15.24's hearing, held exactly as noSoundFrom is. {"hearing null", `{"muted":true,"cameraOff":false,"hearing":null}`}, {"hearing a bool", `{"muted":true,"cameraOff":false,"hearing":true}`}, {"hearing an object", `{"muted":true,"cameraOff":false,"hearing":{"` + strings.Repeat("a", 32) + `":true}}`}, {"hearing a short id", `{"muted":true,"cameraOff":false,"hearing":["abc"]}`}, {"hearing an upper-case id", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("A", 32) + `"]}`}, {"hearing a number", `{"muted":true,"cameraOff":false,"hearing":[1]}`}, {"hearing a repeat", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, {"hearing sixteen ids", `{"muted":true,"cameraOff":false,"hearing":[` + sixteenIDs() + `]}`}, {"hearing twice", `{"muted":true,"cameraOff":false,"hearing":[],"hearing":[]}`}, {"hearing capitalised", `{"muted":true,"cameraOff":false,"Hearing":[]}`}, {"hearing alone", `{"hearing":[]}`}, } for _, c := range bad { t.Run(c.name, func(t *testing.T) { body := `{"peer":"` + pa + `","type":"media","payload":` + c.payload + `}` res, rb := a.signalRaw(sqid, body) if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(rb), `"error":"bad-request"`) { t.Fatalf("%d %s, want 400 bad-request", res.StatusCode, rb) } }) } t.Run("no payload at all", func(t *testing.T) { res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media"}`) if res.StatusCode != http.StatusBadRequest { t.Fatalf("%d %s, want 400", res.StatusCode, rb) } }) t.Run("targeted", func(t *testing.T) { res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","to":"`+strings.Repeat("a", 32)+`","payload":{"muted":true,"cameraOff":false}}`) if res.StatusCode != http.StatusBadRequest { t.Fatalf("%d %s, want 400", res.StatusCode, rb) } }) t.Run("with a seq", func(t *testing.T) { res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","seq":3,"payload":{"muted":true,"cameraOff":false}}`) if res.StatusCode != http.StatusBadRequest { t.Fatalf("%d %s, want 400", res.StatusCode, rb) } }) t.Run("padded past the cap", func(t *testing.T) { payload := `{"muted":true,` + strings.Repeat(" ", 3072) + `"cameraOff":false}` res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`) if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) { t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb) } }) } func sixteenIDs() string { ids := make([]string, 16) for i := range ids { ids[i] = `"` + strings.Repeat("0", 30) + string("0123456789abcdef"[i]) + `0"` } return strings.Join(ids, ",") } // Spec §15.20: the receiving half rides the same report. A browser that // was refused sound, and one hearing silence from a peer, say so; the // hub carries it canonically to the others and in rosters; the ordinary // report is still the two-key object, so a report that clears the bad // news is a change that goes out as exactly that. func TestMediaCarriesWhatTheReceiverCanHear(t *testing.T) { a, sqid, pa, sb := chatPair(t) silent := strings.Repeat("0", 31) + "1" body := `{"peer":"` + pa + `","type":"media","payload":{"noSoundFrom":["` + silent + `"], "audioBlocked":true,"cameraOff":false,"muted":false}}` res, rb := a.signalRaw(sqid, body) if res.StatusCode != http.StatusOK { t.Fatalf("media: %d %s", res.StatusCode, rb) } _, data, ok := sb.nextOf("media", 2*time.Second) if !ok { t.Fatal("Bob did not receive the report") } want := `"payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + silent + `"]}` if !strings.Contains(string(data), want) { t.Fatalf("envelope %s, want %s", data, want) } // Fifteen ids is the most there can be, and is accepted. ids := strings.Split(sixteenIDs(), ",")[:15] res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[`+strings.Join(ids, ",")+`]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("fifteen: %d %s", res.StatusCode, rb) } sb.nextOf("media", 2*time.Second) // Cleared: back to the two-key report, which is a change. res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("clear: %d", res.StatusCode) } _, data, ok = sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { t.Fatalf("cleared report %s", data) } // And an empty list is the same report as no list at all. res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":false}}`) if res.StatusCode != http.StatusOK { t.Fatalf("repeat: %d", res.StatusCode) } if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" { t.Fatal("a report that changed nothing reached Bob") } } // Spec §15.21: a receiver whose view of someone's screen has stalled says // so in the same report, beside §15.20's lists; the hub carries it in the // canonical order, and the longest honest report — both lists full — is // accepted. func TestMediaCarriesStalledScreens(t *testing.T) { a, sqid, pa, sb := chatPair(t) sharer := strings.Repeat("0", 31) + "2" res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"screenStalled":["`+sharer+`"],"muted":false,"cameraOff":true}}`) if res.StatusCode != http.StatusOK { t.Fatalf("media: %d %s", res.StatusCode, rb) } _, data, ok := sb.nextOf("media", 2*time.Second) want := `"payload":{"muted":false,"cameraOff":true,"screenStalled":["` + sharer + `"]}` if !ok || !strings.Contains(string(data), want) { t.Fatalf("envelope %s, want %s", data, want) } ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",") res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("both lists full: %d %s", res.StatusCode, rb) } _, data, ok = sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}`) { t.Fatalf("full report %s", data) } res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"screenStalled":[]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("clear: %d", res.StatusCode) } _, data, ok = sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { t.Fatalf("cleared report %s", data) } } // Spec §15.24: who this client can actually hear. Good news, unlike the // lists before it, and its presence is news too: an empty list ("nobody // yet") travels as [] and is a different report from one without the // key, which is a client that does not say. The longest honest report — // all three lists full — fits under the cap. func TestMediaCarriesWhoTheReceiverHears(t *testing.T) { a, sqid, pa, sb := chatPair(t) res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("empty hearing: %d %s", res.StatusCode, rb) } _, data, ok := sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false,"hearing":[]}`) { t.Fatalf("empty hearing must travel as []: %s", data) } heard := strings.Repeat("0", 31) + "3" res, rb = a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"hearing":["`+heard+`"],"muted":true,"cameraOff":false,"noSoundFrom":["`+heard+`"]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("hearing: %d %s", res.StatusCode, rb) } _, data, ok = sb.nextOf("media", 2*time.Second) want := `"payload":{"muted":true,"cameraOff":false,"noSoundFrom":["` + heard + `"],"hearing":["` + heard + `"]}` if !ok || !strings.Contains(string(data), want) { t.Fatalf("envelope %s, want %s", data, want) } ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",") full := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[` + ids + `],"screenStalled":[` + ids + `],"hearing":[` + ids + `]}` res, rb = a.signal(sqid, pa, `{"type":"media","payload":`+full+`}`) if res.StatusCode != http.StatusOK { t.Fatalf("all three lists full: %d %s", res.StatusCode, rb) } _, data, ok = sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"payload":`+full) { t.Fatalf("full report %s", data) } // Without the key: a different report from the empty list, so it is // a change and goes out as the two-key object. res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`) if res.StatusCode != http.StatusOK { t.Fatalf("back to empty: %d", res.StatusCode) } sb.nextOf("media", 2*time.Second) res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false}}`) if res.StatusCode != http.StatusOK { t.Fatalf("no key: %d", res.StatusCode) } _, data, ok = sb.nextOf("media", 2*time.Second) if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { t.Fatalf("absent hearing must be a change from [], got %s", data) } } // A waiting guest has nobody to tell: its report is the usual 404, and // it hears nothing of the room's. func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) { x, sqid, tok := memberWithMeeting(t) enableWaitingRoom(t, x, sqid) a := x.browser(t) a.signIn("paul@example.com") sa := a.stream("/m/" + sqid + "/stream") defer sa.close() pa, _ := sa.hello() a.signal(sqid, pa, `{"type":"join"}`) sa.nextOf("roster", 2*time.Second) g := x.browser(t) g.get("/j/" + tok) g.signIn("waiting-guest@example.com") gs := g.stream("/m/" + sqid + "/stream") defer gs.close() gpeer, _ := gs.hello() g.signal(sqid, gpeer, `{"type":"join"}`) sa.nextOf("knock", 2*time.Second) res, rb := g.signal(sqid, gpeer, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) if res.StatusCode != http.StatusNotFound || !strings.Contains(string(rb), `"error":"not-found"`) { t.Fatalf("a waiting guest's media must be the usual 404: %d %s", res.StatusCode, rb) } a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`) if ev, _, ok := gs.next(400 * time.Millisecond); ok && ev == "media" { t.Fatal("a waiting guest heard a media report") } }