oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package callstest
2
3import (
4 "encoding/json"
5 "net/http"
6 "strings"
7 "testing"
8 "time"
9)
10
11// signalJSON marshals body into the JSON string client.signal expects.
12// body must not carry "peer": client.signal always injects that itself
13// from the peer argument, the way every other test in this package
14// calls it with a literal string. This helper only exists because a
15// handful of chat/share cases below need dynamic values (a repeated
16// string, a peer id inlined as "to") that a literal template would make
17// unreadable.
18//
19// It normalises through a map (json.Marshal, then json.Unmarshal inside
20// client.signal itself), so it can only ever produce one well-formed
21// JSON object with each key once — it cannot express a duplicated key
22// or a raw, unescaped byte sequence. A case that needs either of those
23// byte-level shapes goes through client.signalRaw instead, which posts
24// its string argument verbatim.
25func signalJSON(t *testing.T, body map[string]any) string {
26 t.Helper()
27 b, err := json.Marshal(body)
28 if err != nil {
29 t.Fatal(err)
30 }
31 return string(b)
32}
33
34// Spec §4.3's fan-out: chat reaches every other admitted peer, never
35// the sender, and the envelope names the sender's peer id and display
36// name — never anything the sender's own request claimed.
37func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *testing.T) {
38 x, sqid, _ := memberWithMeeting(t)
39 a := x.browser(t)
40 a.signIn("paul@example.com")
41 sa := a.stream("/m/" + sqid + "/stream")
42 defer sa.close()
43 pa, _ := sa.hello()
44 a.signal(sqid, pa, `{"type":"join"}`)
45 sa.nextOf("roster", 2*time.Second)
46
47 b := newMemberBrowser(t, x, "paul@rslw.com")
48 sb := b.stream("/m/" + sqid + "/stream")
49 defer sb.close()
50 pb, _ := sb.hello()
51 b.signal(sqid, pb, `{"type":"join"}`)
52 sb.nextOf("roster", 2*time.Second)
53 sa.nextOf("peer", 2*time.Second)
54
55 res, _ := a.signal(sqid, pa, signalJSON(t, map[string]any{"type": "chat", "payload": map[string]any{"text": "hello there"}}))
56 if res.StatusCode != http.StatusOK {
57 t.Fatalf("chat: %d", res.StatusCode)
58 }
59 ev, data, ok := sb.nextOf("chat", 2*time.Second)
60 if !ok || ev != "chat" {
61 t.Fatal("Bob did not receive the chat")
62 }
63 s := string(data)
64 if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) || !strings.Contains(s, `"text":"hello there"`) || strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) {
65 t.Fatalf("envelope %s", s)
66 }
67 if ev, _, ok := sa.next(300 * time.Millisecond); ok && ev == "chat" {
68 t.Fatal("the sender received its own chat")
69 }
70}
71
72// Spec §2.5 and §4.3: a waiting guest is not admitted, so fan-out never
73// reaches it, and its own signal is the usual "a meeting that is not
74// yours does not exist" 404 — never a 403, whatever it tries to send.
75// A positive control closes the test: the same guest, once admitted,
76// does receive a chat — proof the silence above was the guest's
77// standing, not a stream that never delivers anything.
78//
79// ice_test.go's guestJoinedAndWaiting builds the identical waiting
80// guest but returns only (*client, peer): its own stream is registered
81// for t.Cleanup and never handed back. This test needs to read from
82// that stream directly (to prove the guest receives nothing, and later
83// that it does), so the same three steps — join link, sign in, open
84// stream, join — are inlined here instead of reusing that helper.
85func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) {
86 x, sqid, tok := memberWithMeeting(t)
87 enableWaitingRoom(t, x, sqid)
88 a := x.browser(t)
89 a.signIn("paul@example.com")
90 sa := a.stream("/m/" + sqid + "/stream")
91 defer sa.close()
92 pa, _ := sa.hello()
93 a.signal(sqid, pa, `{"type":"join"}`)
94 sa.nextOf("roster", 2*time.Second)
95
96 g := x.browser(t)
97 g.get("/j/" + tok)
98 g.signIn("waiting-guest@example.com")
99 gs := g.stream("/m/" + sqid + "/stream")
100 defer gs.close()
101 gpeer, _ := gs.hello()
102 if resp, b := g.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
103 t.Fatalf("guest join (the knock): %d %s", resp.StatusCode, b)
104 }
105 sa.nextOf("knock", 2*time.Second)
106
107 a.signal(sqid, pa, signalJSON(t, map[string]any{"type": "chat", "payload": map[string]any{"text": "secret"}}))
108 if ev, data, ok := gs.next(500 * time.Millisecond); ok && (ev == "chat" || strings.Contains(string(data), "secret")) {
109 t.Fatalf("a waiting guest received %s %s", ev, data)
110 }
111 res, body := g.signal(sqid, gpeer, signalJSON(t, map[string]any{"type": "chat", "payload": map[string]any{"text": "let me in"}}))
112 if res.StatusCode != http.StatusNotFound || !strings.Contains(string(body), `"error":"not-found"`) {
113 t.Fatalf("a waiting guest's chat must be the usual 404: %d %s", res.StatusCode, body)
114 }
115
116 if resp, b := a.signal(sqid, pa, `{"type":"admit","payload":{"peer":"`+gpeer+`"}}`); resp.StatusCode != http.StatusOK {
117 t.Fatalf("admit: %d %s", resp.StatusCode, b)
118 }
119 if ev, _, ok := gs.nextOf("roster", 2*time.Second); !ok {
120 t.Fatalf("the newly admitted guest never saw its own roster (got %q)", ev)
121 }
122 res, _ = a.signal(sqid, pa, signalJSON(t, map[string]any{"type": "chat", "payload": map[string]any{"text": "welcome"}}))
123 if res.StatusCode != http.StatusOK {
124 t.Fatalf("chat after admit: %d", res.StatusCode)
125 }
126 ev, data, ok := gs.nextOf("chat", 2*time.Second)
127 if !ok || ev != "chat" || !strings.Contains(string(data), `"text":"welcome"`) {
128 t.Fatalf("the now-admitted guest did not receive the chat: %q ok=%v %s", ev, ok, data)
129 }
130}
131
132// Spec §4.4: chat text is capped at 2048 bytes, counted in bytes, not
133// characters; anything not exactly {text: string} (chat) or {on: bool}
134// (share), including a chat carrying "to", is the usual 400.
135func TestChatLimitsAndShapes(t *testing.T) {
136 x, sqid, _ := memberWithMeeting(t)
137 a := x.browser(t)
138 a.signIn("paul@example.com")
139 sa := a.stream("/m/" + sqid + "/stream")
140 defer sa.close()
141 pa, _ := sa.hello()
142 a.signal(sqid, pa, `{"type":"join"}`)
143 sa.nextOf("roster", 2*time.Second)
144
145 cases := []struct {
146 name string
147 body map[string]any
148 status int
149 }{
150 {"2048 bytes ok", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2048)}}, http.StatusOK},
151 {"2049 bytes too large", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2049)}}, http.StatusRequestEntityTooLarge},
152 {"multibyte counted in bytes", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("é", 1025)}}, http.StatusRequestEntityTooLarge},
153 {"text not a string", map[string]any{"type": "chat", "payload": map[string]any{"text": 5}}, http.StatusBadRequest},
154 {"extra key", map[string]any{"type": "chat", "payload": map[string]any{"text": "x", "html": "<b>"}}, http.StatusBadRequest},
155 {"empty text", map[string]any{"type": "chat", "payload": map[string]any{"text": ""}}, http.StatusBadRequest},
156 {"share on", map[string]any{"type": "share", "payload": map[string]any{"on": true}}, http.StatusOK},
157 {"share off", map[string]any{"type": "share", "payload": map[string]any{"on": false}}, http.StatusOK},
158 {"share not a bool", map[string]any{"type": "share", "payload": map[string]any{"on": "yes"}}, http.StatusBadRequest},
159 {"chat with a target", map[string]any{"type": "chat", "to": pa, "payload": map[string]any{"text": "x"}}, http.StatusBadRequest},
160 }
161 for _, c := range cases {
162 t.Run(c.name, func(t *testing.T) {
163 res, body := a.signal(sqid, pa, signalJSON(t, c.body))
164 if res.StatusCode != c.status {
165 t.Fatalf("%s: %d, want %d", c.name, res.StatusCode, c.status)
166 }
167 switch c.status {
168 case http.StatusRequestEntityTooLarge:
169 if !strings.Contains(string(body), `"error":"too-large"`) {
170 t.Fatalf("%s: body %s, want error=too-large", c.name, body)
171 }
172 case http.StatusBadRequest:
173 if !strings.Contains(string(body), `"error":"bad-request"`) {
174 t.Fatalf("%s: body %s, want error=bad-request", c.name, body)
175 }
176 }
177 })
178 }
179}
180
181// Review fix round 1, item 1 (HIGH): the dispatch in signal.go hands
182// hub.Fanout the payload's raw bytes verbatim — never a re-encoding of
183// what validateFanout decoded — so validateFanout must judge those same
184// raw bytes, not just the decoded text's length. Each case here needs a
185// shape signalJSON cannot express (a duplicated JSON key, or a literal
186// invalid byte), so all five go through client.signalRaw, which posts
187// its string argument verbatim and, unlike client.signal, does not
188// inject "peer" itself.
189func TestChatRawPayloadSizeAndUTF8(t *testing.T) {
190 x, sqid, _ := memberWithMeeting(t)
191 a := x.browser(t)
192 a.signIn("paul@example.com")
193 sa := a.stream("/m/" + sqid + "/stream")
194 defer sa.close()
195 pa, _ := sa.hello()
196 a.signal(sqid, pa, `{"type":"join"}`)
197 sa.nextOf("roster", 2*time.Second)
198
199 t.Run("4096-byte duplicated text key", func(t *testing.T) {
200 // encoding/json keeps only the last value of a duplicated key —
201 // this decodes to a one-byte "x" — but the raw payload handed to
202 // Fanout still carries all 4096+ bytes of the first value.
203 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + strings.Repeat("a", 4096) + `","text":"x"}}`
204 res, respBody := a.signalRaw(sqid, body)
205 if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) {
206 t.Fatalf("duplicated text key: %d %s", res.StatusCode, respBody)
207 }
208 })
209
210 t.Run("5000 duplicated on keys", func(t *testing.T) {
211 var b strings.Builder
212 b.WriteByte('{')
213 for i := 0; i < 5000; i++ {
214 if i > 0 {
215 b.WriteByte(',')
216 }
217 b.WriteString(`"on":true`)
218 }
219 b.WriteByte('}')
220 body := `{"peer":"` + pa + `","type":"share","payload":` + b.String() + `}`
221 res, respBody := a.signalRaw(sqid, body)
222 if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) {
223 t.Fatalf("duplicated on keys: %d %s", res.StatusCode, respBody)
224 }
225 })
226
227 t.Run("2048 backslash-u escapes", func(t *testing.T) {
228 // Each A escape decodes to one ASCII byte ("A"), so the
229 // decoded text is exactly 2048 bytes — at the cap, not over it —
230 // while the raw escapes are six times that on the wire.
231 text := strings.Repeat(`\u0041`, 2048)
232 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + text + `"}}`
233 res, respBody := a.signalRaw(sqid, body)
234 if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(respBody), `"error":"too-large"`) {
235 t.Fatalf("escaped text: %d %s", res.StatusCode, respBody)
236 }
237 })
238
239 t.Run("2048-byte multibyte text is still accepted", func(t *testing.T) {
240 // "é" is two raw UTF-8 bytes, unescaped: 1024 of them is exactly
241 // 2048 bytes both raw and decoded — legitimate multibyte content
242 // at the cap must not be caught by the new raw-size guard.
243 text := strings.Repeat("é", 1024)
244 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + text + `"}}`
245 res, respBody := a.signalRaw(sqid, body)
246 if res.StatusCode != http.StatusOK {
247 t.Fatalf("multibyte text at the cap: %d %s", res.StatusCode, respBody)
248 }
249 })
250
251 t.Run("invalid UTF-8 is refused", func(t *testing.T) {
252 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"bad` + "\xff" + `byte"}}`
253 res, respBody := a.signalRaw(sqid, body)
254 if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(respBody), `"error":"bad-request"`) {
255 t.Fatalf("invalid UTF-8: %d %s", res.StatusCode, respBody)
256 }
257 })
258}
259
260// chatCap is spec §4.4's 2 KB, retyped here because callstest is an
261// external test package and cannot read meet's own constant. The tests
262// below fail loudly if the two ever disagree: 2048 + 32 is the raw cap
263// they post either side of.
264const chatCap = 2048
265
266// chatPair opens a meeting with two admitted members and hands back the
267// sender (browser, room, peer) and the other member's stream, which is
268// where every assertion about what actually went out on the wire is
269// made. The four tests below need exactly this and nothing else from
270// the dance that builds it.
271func chatPair(t *testing.T) (*client, string, string, *sseReader) {
272 t.Helper()
273 x, sqid, _ := memberWithMeeting(t)
274 a := x.browser(t)
275 a.signIn("paul@example.com")
276 sa := a.stream("/m/" + sqid + "/stream")
277 t.Cleanup(sa.close)
278 pa, _ := sa.hello()
279 a.signal(sqid, pa, `{"type":"join"}`)
280 sa.nextOf("roster", 2*time.Second)
281
282 b := newMemberBrowser(t, x, "paul@rslw.com")
283 sb := b.stream("/m/" + sqid + "/stream")
284 t.Cleanup(sb.close)
285 pb, _ := sb.hello()
286 b.signal(sqid, pb, `{"type":"join"}`)
287 sb.nextOf("roster", 2*time.Second)
288 sa.nextOf("peer", 2*time.Second)
289 return a, sqid, pa, sb
290}
291
292// Codex S4-1: encoding/json matches an object's keys to struct fields
293// case-insensitively, and DisallowUnknownFields does not change that.
294// {"text":"<2049 a>","Text":"x"} therefore validated the *last* match —
295// a one-byte text — while the raw bytes handed to Fanout carried both
296// keys, and every recipient reads the lowercase one: 2049 bytes
297// delivered through a 2048-byte cap, inside a raw payload small enough
298// to clear the +32 guard. So the keys are judged byte-exactly and
299// counted now, before any value is looked at.
300func TestChatKeysAreExactCaseSensitiveAndUnique(t *testing.T) {
301 a, sqid, pa, _ := chatPair(t)
302
303 cases := []struct {
304 name string
305 typ string
306 payload string
307 }{
308 {"a mixed-case second key smuggles the value past the cap", "chat",
309 `{"text":"` + strings.Repeat("a", chatCap+1) + `","Text":"x"}`},
310 {"a capitalised key alone is not text", "chat", `{"Text":"hello"}`},
311 {"a shouted key alone is not text", "chat", `{"TEXT":"hello"}`},
312 {"a duplicated key below the raw cap", "chat",
313 `{"text":"` + strings.Repeat("a", 64) + `","text":"x"}`},
314 {"a capitalised on is not on", "share", `{"On":true}`},
315 {"a duplicated on below the raw cap", "share", `{"on":true,"on":false}`},
316 {"a second key beside on", "share", `{"on":true,"off":false}`},
317 {"an empty payload object", "chat", `{}`},
318 {"a payload that is not an object", "chat", `"text"`},
319 {"trailing content after the object", "chat", `{"text":"x"} {}`},
320 }
321 for _, c := range cases {
322 t.Run(c.name, func(t *testing.T) {
323 body := `{"peer":"` + pa + `","type":"` + c.typ + `","payload":` + c.payload + `}`
324 res, respBody := a.signalRaw(sqid, body)
325 if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(respBody), `"error":"bad-request"`) {
326 t.Fatalf("%s: %d %s, want 400 bad-request", c.name, res.StatusCode, respBody)
327 }
328 })
329 }
330}
331
332// Codex S4-2, and the other half of S4-1: what reaches a recipient is
333// the *validated* payload, re-encoded from the value that was checked,
334// and never whatever byte sequence the sender chose to spell it with.
335// So an escape is delivered decoded, an angle bracket is delivered as
336// itself (writeEvent no longer HTML-escapes — SSE data is not HTML),
337// and the payload on the wire is byte-for-byte the canonical form.
338func TestChatDeliversTheCanonicalValidatedPayload(t *testing.T) {
339 a, sqid, pa, sb := chatPair(t)
340
341 cases := []struct {
342 name string
343 typ string
344 sent string
345 want string
346 }{
347 {"angle brackets and ampersands go out as themselves", "chat",
348 `{"text":"a<b>c&d"}`, `"payload":{"text":"a<b>c&d"}`},
349 // Codex S4-11: every case here used to be its own answer — a
350 // canonical payload in and the same bytes out, which an
351 // implementation that forwarded the request's raw bytes would
352 // have passed just as well. The two escape cases are the ones
353 // that cannot: they can only come out this way if the payload
354 // was rebuilt from the value that was validated. Verified by
355 // putting the raw bytes back on the wire, where these two fail
356 // and nothing else does. (Whitespace is not a discriminator —
357 // the SSE encoder compacts a RawMessage on its way out — but
358 // the shape is worth pinning anyway.) The backslashes are real:
359 // a Go raw string sends the six characters \u0041, not the
360 // letter A.
361 {"an escape really is delivered decoded", "chat",
362 `{"text":"\u0041\u003c"}`, `"payload":{"text":"A<"}`},
363 {"a tab escape survives as the escape JSON gives it", "chat",
364 `{"text":"a\u0009b"}`, `"payload":{"text":"a\tb"}`},
365 {"whitespace inside the payload is not the payload", "chat",
366 `{ "text" : "spaced" }`, `"payload":{"text":"spaced"}`},
367 {"an ordinary line is untouched", "chat",
368 `{"text":"plain"}`, `"payload":{"text":"plain"}`},
369 // And the other fan-out type, which had no output coverage at
370 // all: `on` is rebuilt from the bool, so the spelling a sender
371 // chose for it never reaches anybody.
372 {"share on is canonical too", "share",
373 `{"on":true}`, `"payload":{"on":true}`},
374 {"share off is canonical too", "share",
375 `{ "on" :false }`, `"payload":{"on":false}`},
376 }
377 for _, c := range cases {
378 t.Run(c.name, func(t *testing.T) {
379 body := `{"peer":"` + pa + `","type":"` + c.typ + `","payload":` + c.sent + `}`
380 if res, respBody := a.signalRaw(sqid, body); res.StatusCode != http.StatusOK {
381 t.Fatalf("%s: %d %s", c.name, res.StatusCode, respBody)
382 }
383 ev, data, ok := sb.nextOf(c.typ, 2*time.Second)
384 if !ok || ev != c.typ {
385 t.Fatalf("%s: no %s arrived (%q)", c.name, c.typ, ev)
386 }
387 if !strings.Contains(string(data), c.want) {
388 t.Fatalf("%s: wire %s, want it to carry %s", c.name, data, c.want)
389 }
390 })
391 }
392}
393
394// Codex S4-2: json.Marshal HTML-escapes `<`, `>` and `&` even inside a
395// json.RawMessage, so a text of 2048 literal `<` — 2059 raw bytes,
396// comfortably inside the cap both checks enforce — left this process as
397// 12,299 payload bytes, six times what was validated. The cap is a
398// promise about what the fan-out puts on every other peer's stream, so
399// the fan-out is where it has to hold.
400func TestChatDoesNotExpandOnTheWire(t *testing.T) {
401 a, sqid, pa, sb := chatPair(t)
402
403 text := strings.Repeat("<", chatCap)
404 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + text + `"}}`
405 if res, respBody := a.signalRaw(sqid, body); res.StatusCode != http.StatusOK {
406 t.Fatalf("2048 angle brackets: %d %s", res.StatusCode, respBody)
407 }
408 ev, data, ok := sb.nextOf("chat", 2*time.Second)
409 if !ok || ev != "chat" {
410 t.Fatalf("no chat arrived (%q)", ev)
411 }
412 if want := `{"text":"` + text + `"}`; !strings.Contains(string(data), want) {
413 t.Fatalf("the payload on the wire is not the 2059 bytes that were validated (%d bytes of event data)", len(data))
414 }
415 // The envelope's own fields — type, from, name — are the only thing
416 // beyond the payload, and all three are bounded: a peer id, a
417 // display name, and the field names around them.
418 if len(data) > chatCap+32+256 {
419 t.Fatalf("one chat event is %d bytes on the wire for a %d-byte text", len(data), len(text))
420 }
421}
422
423// The whole-branch review's W4-1: the panel counted 2048 UTF-8 bytes of
424// the text and the server counted 2080 raw JSON bytes of the payload, so
425// a 2048-byte draft with twenty-three quotes in it passed the panel and
426// came back 413 — a refusal nothing on the client could have foreseen.
427// Both sides count one thing now: the JSON-escaped length of the text,
428// which is exactly what the canonical payload puts on the wire. The raw
429// cap stays as the outer guard, refusing an oversized body before any of
430// this is reached.
431func TestChatCapCountsTheEscapedText(t *testing.T) {
432 a, sqid, pa, _ := chatPair(t)
433
434 cases := []struct {
435 name string
436 text string
437 status int
438 }{
439 {"a quote at the boundary", strings.Repeat("a", chatCap-2) + `\"`, http.StatusOK},
440 {"a quote one byte past it", strings.Repeat("a", chatCap-1) + `\"`, http.StatusRequestEntityTooLarge},
441 {"a newline at the boundary", strings.Repeat("a", chatCap-2) + `\n`, http.StatusOK},
442 {"a newline one byte past it", strings.Repeat("a", chatCap-1) + `\n`, http.StatusRequestEntityTooLarge},
443 {"a backslash one byte past it", strings.Repeat("a", chatCap-1) + `\\`, http.StatusRequestEntityTooLarge},
444 {"twenty-three quotes inside 2048 bytes of text",
445 strings.Repeat("a", chatCap-23) + strings.Repeat(`\"`, 23), http.StatusRequestEntityTooLarge},
446 {"angle brackets cost one byte each, not six",
447 strings.Repeat("<", chatCap), http.StatusOK},
448 // Codex S4-7: the two characters Go escapes even with
449 // SetEscapeHTML(false), and JavaScript's JSON.stringify leaves
450 // literal. Three bytes on the wire as themselves, six once Go
451 // has written them, and the composer used to count the first
452 // number while the server counted the second — so a draft the
453 // panel accepted came back 413. Sent here as themselves, which
454 // is how a paste arrives.
455 {"a line separator at the boundary",
456 strings.Repeat("a", chatCap-6) + "\u2028", http.StatusOK},
457 {"a line separator one byte past it",
458 strings.Repeat("a", chatCap-5) + "\u2028", http.StatusRequestEntityTooLarge},
459 {"a paragraph separator at the boundary",
460 strings.Repeat("a", chatCap-6) + "\u2029", http.StatusOK},
461 {"a paragraph separator one byte past it",
462 strings.Repeat("a", chatCap-5) + "\u2029", http.StatusRequestEntityTooLarge},
463 {"the draft the composer used to accept",
464 strings.Repeat("a", 2045) + "\u2028", http.StatusRequestEntityTooLarge},
465 }
466 for _, c := range cases {
467 t.Run(c.name, func(t *testing.T) {
468 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"` + c.text + `"}}`
469 res, respBody := a.signalRaw(sqid, body)
470 if res.StatusCode != c.status {
471 t.Fatalf("%s: %d %s, want %d", c.name, res.StatusCode, respBody, c.status)
472 }
473 })
474 }
475}
476
477// The wire is line-oriented and the payload is a person's own text, so
478// the one thing a chat must never be able to do is end its own frame
479// and start another. It cannot: JSON escapes a newline inside a string
480// rather than emitting one, and that stays true of the canonical
481// re-encoding. This is the test that says so, and it is why disabling
482// HTML escaping on the way out is safe.
483func TestChatCannotForgeASecondSSEFrame(t *testing.T) {
484 a, sqid, pa, sb := chatPair(t)
485
486 body := `{"peer":"` + pa + `","type":"chat","payload":{"text":"one\n\nevent: roster\ndata: {\"peers\":[]}\n\ntwo"}}`
487 if res, respBody := a.signalRaw(sqid, body); res.StatusCode != http.StatusOK {
488 t.Fatalf("newlines in text: %d %s", res.StatusCode, respBody)
489 }
490 ev, data, ok := sb.nextOf("chat", 2*time.Second)
491 if !ok || ev != "chat" {
492 t.Fatalf("no chat arrived (%q)", ev)
493 }
494 if !strings.Contains(string(data), `\n\nevent: roster`) {
495 t.Fatalf("the newlines did not survive as escapes: %s", data)
496 }
497 if ev, data, ok := sb.next(300 * time.Millisecond); ok {
498 t.Fatalf("a second event came out of one chat: %q %s", ev, data)
499 }
500}
501