| index fe9ba87..f477448 100644 |
| --- a/internal/meettest/chat_test.go |
| +++ b/internal/meettest/chat_test.go |
| @@ -15,6 +15,13 @@ import ( |
| // handful of chat/share cases below need dynamic values (a repeated |
| // string, a peer id inlined as "to") that a literal template would make |
| // unreadable. |
| +// |
| +// It normalises through a map (json.Marshal, then json.Unmarshal inside |
| +// client.signal itself), so it can only ever produce one well-formed |
| +// JSON object with each key once — it cannot express a duplicated key |
| +// or a raw, unescaped byte sequence. A case that needs either of those |
| +// byte-level shapes goes through client.signalRaw instead, which posts |
| +// its string argument verbatim. |
| func signalJSON(t *testing.T, body map[string]any) string { |
| t.Helper() |
| b, err := json.Marshal(body) |
| @@ -65,13 +72,16 @@ func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *tes |
| // Spec §2.5 and §4.3: a waiting guest is not admitted, so fan-out never |
| // reaches it, and its own signal is the usual "a meeting that is not |
| // yours does not exist" 404 — never a 403, whatever it tries to send. |
| +// A positive control closes the test: the same guest, once admitted, |
| +// does receive a chat — proof the silence above was the guest's |
| +// standing, not a stream that never delivers anything. |
| // |
| // ice_test.go's guestJoinedAndWaiting builds the identical waiting |
| // guest but returns only (*client, peer): its own stream is registered |
| // for t.Cleanup and never handed back. This test needs to read from |
| -// that stream directly (to prove the guest receives nothing), so the |
| -// same three steps — join link, sign in, open stream, join — are |
| -// inlined here instead of reusing that helper. |
| +// that stream directly (to prove the guest receives nothing, and later |
| +// that it does), so the same three steps — join link, sign in, open |
| +// stream, join — are inlined here instead of reusing that helper. |
| func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| a := x.browser(t) |
| @@ -101,6 +111,21 @@ func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) { |
| if res.StatusCode != http.StatusNotFound || !strings.Contains(string(body), `"error":"not-found"`) { |
| t.Fatalf("a waiting guest's chat must be the usual 404: %d %s", res.StatusCode, body) |
| } |
| + |
| + if resp, b := a.signal(sqid, pa, `{"type":"admit","payload":{"peer":"`+gpeer+`"}}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("admit: %d %s", resp.StatusCode, b) |
| + } |
| + if ev, _, ok := gs.nextOf("roster", 2*time.Second); !ok { |
| + t.Fatalf("the newly admitted guest never saw its own roster (got %q)", ev) |
| + } |
| + res, _ = a.signal(sqid, pa, signalJSON(t, map[string]any{"type": "chat", "payload": map[string]any{"text": "welcome"}})) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("chat after admit: %d", res.StatusCode) |
| + } |
| + ev, data, ok := gs.nextOf("chat", 2*time.Second) |
| + if !ok || ev != "chat" || !strings.Contains(string(data), `"text":"welcome"`) { |
| + t.Fatalf("the now-admitted guest did not receive the chat: %q ok=%v %s", ev, ok, data) |
| + } |
| } |
| |
| // Spec §4.4: chat text is capped at 2048 bytes, counted in bytes, not |
| @@ -134,10 +159,99 @@ func TestChatLimitsAndShapes(t *testing.T) { |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| - res, _ := a.signal(sqid, pa, signalJSON(t, c.body)) |
| + res, body := a.signal(sqid, pa, signalJSON(t, c.body)) |
| if res.StatusCode != c.status { |
| t.Fatalf("%s: %d, want %d", c.name, res.StatusCode, c.status) |
| } |
| + switch c.status { |
| + case http.StatusRequestEntityTooLarge: |
| + if !strings.Contains(string(body), `"error":"too-large"`) { |
| + t.Fatalf("%s: body %s, want error=too-large", c.name, body) |
| + } |
| + case http.StatusBadRequest: |
| + if !strings.Contains(string(body), `"error":"bad-request"`) { |
| + t.Fatalf("%s: body %s, want error=bad-request", c.name, body) |
| + } |
| + } |
| }) |
| } |
| } |
| + |
| +// Review fix round 1, item 1 (HIGH): the dispatch in signal.go hands |
| +// hub.Fanout the payload's raw bytes verbatim — never a re-encoding of |
| +// what validateFanout decoded — so validateFanout must judge those same |
| +// raw bytes, not just the decoded text's length. Each case here needs a |
| +// shape signalJSON cannot express (a duplicated JSON key, or a literal |
| +// invalid byte), so all five go through client.signalRaw, which posts |
| +// its string argument verbatim and, unlike client.signal, does not |
| +// inject "peer" itself. |
| +func TestChatRawPayloadSizeAndUTF8(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) |
| + |
| + t.Run("4096-byte duplicated text key", func(t *testing.T) { |
| + // encoding/json keeps only the last value of a duplicated key — |
| + // this decodes to a one-byte "x" — but the raw payload handed to |
| + // Fanout still carries all 4096+ bytes of the first value. |
| + body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + strings.Repeat("a", 4096) + `","text":"x"}}` |
| + res, respBody := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) { |
| + t.Fatalf("duplicated text key: %d %s", res.StatusCode, respBody) |
| + } |
| + }) |
| + |
| + t.Run("5000 duplicated on keys", func(t *testing.T) { |
| + var b strings.Builder |
| + b.WriteByte('{') |
| + for i := 0; i < 5000; i++ { |
| + if i > 0 { |
| + b.WriteByte(',') |
| + } |
| + b.WriteString(`"on":true`) |
| + } |
| + b.WriteByte('}') |
| + body := `{"peer":"` + pa + `","type":"share","payload":` + b.String() + `}` |
| + res, respBody := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) { |
| + t.Fatalf("duplicated on keys: %d %s", res.StatusCode, respBody) |
| + } |
| + }) |
| + |
| + t.Run("2048 backslash-u escapes", func(t *testing.T) { |
| + // Each A escape decodes to one ASCII byte ("A"), so the |
| + // decoded text is exactly 2048 bytes — at the cap, not over it — |
| + // while the raw escapes are six times that on the wire. |
| + text := strings.Repeat(`\u0041`, 2048) |
| + body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + text + `"}}` |
| + res, respBody := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) { |
| + t.Fatalf("escaped text: %d %s", res.StatusCode, respBody) |
| + } |
| + }) |
| + |
| + t.Run("2048-byte multibyte text is still accepted", func(t *testing.T) { |
| + // "é" is two raw UTF-8 bytes, unescaped: 1024 of them is exactly |
| + // 2048 bytes both raw and decoded — legitimate multibyte content |
| + // at the cap must not be caught by the new raw-size guard. |
| + text := strings.Repeat("é", 1024) |
| + body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + text + `"}}` |
| + res, respBody := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("multibyte text at the cap: %d %s", res.StatusCode, respBody) |
| + } |
| + }) |
| + |
| + t.Run("invalid UTF-8 is refused", func(t *testing.T) { |
| + body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"bad` + "\xff" + `byte"}}` |
| + res, respBody := a.signalRaw(sqid, body) |
| + if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(respBody), `"error":"bad-request"`) { |
| + t.Fatalf("invalid UTF-8: %d %s", res.StatusCode, respBody) |
| + } |
| + }) |
| +} |