| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "log/slog" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/oficina/calls/internal/calls" |
| 15 | ) |
| 16 | |
| 17 | // Spec §4.2: every POST /signal must carry a peer the hub finds in this |
| 18 | // room, bound to this session; anything else is refused with 404 — |
| 19 | // never 403, and the same 404 whether the peer never existed or exists |
| 20 | // through another session (§2.5's "a meeting that is not yours does not |
| 21 | // exist"). |
| 22 | func TestSignalNeedsAPeerBoundToThisSessionAndRoom(t *testing.T) { |
| 23 | x, sqid, tok := memberWithMeeting(t) |
| 24 | c := x.browser(t) |
| 25 | c.signIn("paul@example.com") |
| 26 | s := c.stream("/m/" + sqid + "/stream") |
| 27 | defer s.close() |
| 28 | peer, boot := s.hello() |
| 29 | |
| 30 | resp, unknownBody := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`) |
| 31 | if resp.StatusCode != http.StatusNotFound { |
| 32 | t.Fatalf("foreign peer: %d", resp.StatusCode) |
| 33 | } |
| 34 | // Minor 6: the 404 body carries boot, identical for ErrUnknownPeer |
| 35 | // and ErrNotYours, the same shape stream.go's own 404s carry. |
| 36 | var unknownAnswer struct { |
| 37 | Error string |
| 38 | Boot string |
| 39 | } |
| 40 | if err := json.Unmarshal(unknownBody, &unknownAnswer); err != nil { |
| 41 | t.Fatalf("404 body %s: %v", unknownBody, err) |
| 42 | } |
| 43 | if unknownAnswer.Error != "not-found" || unknownAnswer.Boot != boot { |
| 44 | t.Fatalf("404 body %+v, want error=not-found boot=%q", unknownAnswer, boot) |
| 45 | } |
| 46 | |
| 47 | other := x.browser(t) |
| 48 | other.get("/j/" + tok) |
| 49 | other.signIn("guest@example.com") |
| 50 | resp, otherSessionBody := other.signal(sqid, peer, `{"type":"join"}`) |
| 51 | if resp.StatusCode != http.StatusNotFound { |
| 52 | t.Fatalf("peer through another session: %d", resp.StatusCode) |
| 53 | } |
| 54 | if !bytes.Equal(unknownBody, otherSessionBody) { |
| 55 | t.Fatalf("404 bodies differ: unknown %q, other session %q", unknownBody, otherSessionBody) |
| 56 | } |
| 57 | |
| 58 | resp, _ = c.signal(sqid, peer, `{"type":"join"}`) |
| 59 | if resp.StatusCode != http.StatusOK { |
| 60 | t.Fatalf("own join: %d", resp.StatusCode) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // A peer that is perfectly real, and bound to this very session, but in |
| 65 | // a different room, is the same 404 — and from Task 5's fix round 2 it |
| 66 | // is refused earlier than it used to be: the LiveForSession gate answers |
| 67 | // it before the limiter, the body read or any hub dispatch, because this |
| 68 | // session holds no participation in the room named in the URL. Either |
| 69 | // way the rule is §4.2's "cross-room delivery is impossible by |
| 70 | // construction": every lookup starts from the room in the URL, so a peer |
| 71 | // id from elsewhere names nothing here. |
| 72 | func TestSignalRefusesAPeerFromAnotherMeeting(t *testing.T) { |
| 73 | x, sqid, _ := memberWithMeeting(t) |
| 74 | c := x.browser(t) |
| 75 | c.signIn("paul@example.com") |
| 76 | s := c.stream("/m/" + sqid + "/stream") |
| 77 | defer s.close() |
| 78 | peer, _ := s.hello() |
| 79 | |
| 80 | elsewhere, _ := createMeeting(t, c, "Different room", "") |
| 81 | if resp, _ := c.signal(elsewhere, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound { |
| 82 | t.Fatalf("peer from another meeting: %d, want 404", resp.StatusCode) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // Task 5 review Important 1: the limiter is keyed on session+room, never |
| 87 | // on anything the request body claims, so a second session flooding the |
| 88 | // endpoint spends only its own bucket. |
| 89 | func TestSignalRateLimitIsPerSessionNotPerClaimedPeer(t *testing.T) { |
| 90 | x, sqid, _ := memberWithMeeting(t) |
| 91 | victim := x.browser(t) |
| 92 | victim.signIn("paul@example.com") |
| 93 | vs := victim.stream("/m/" + sqid + "/stream") |
| 94 | defer vs.close() |
| 95 | vpeer, _ := vs.hello() |
| 96 | if resp, _ := victim.signal(sqid, vpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 97 | t.Fatalf("victim join: %d", resp.StatusCode) |
| 98 | } |
| 99 | |
| 100 | // A second session in the same room, flooding while *claiming the |
| 101 | // victim's own peer id* — peer ids are published in rosters, so |
| 102 | // this is exactly what a limiter keyed on the body's claimed peer |
| 103 | // cannot tell from the victim's own traffic (review Important 1). |
| 104 | attacker := newMemberBrowser(t, x, "paul@rslw.com") |
| 105 | as := attacker.stream("/m/" + sqid + "/stream") |
| 106 | defer as.close() |
| 107 | as.hello() // the attacker's own peer, unused below on purpose |
| 108 | |
| 109 | for i := 0; i < 80; i++ { |
| 110 | attacker.signal(sqid, vpeer, `{"type":"leave"}`) |
| 111 | } |
| 112 | |
| 113 | // The victim's own session, still using its own real peer, must be |
| 114 | // unaffected by traffic that only ever came from another session. |
| 115 | if resp, _ := victim.signal(sqid, vpeer, `{"type":"leave"}`); resp.StatusCode == http.StatusTooManyRequests { |
| 116 | t.Fatal("another session impersonating this peer id in its own requests drained its rate-limit bucket") |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // Task 5 review Important 1: rotating the claimed peer id within one |
| 121 | // session must not evade the limit — every claimed id, real or not, |
| 122 | // spends the same session+room bucket. (The room half of the key is |
| 123 | // gated by LiveForSession before the limiter is ever reached — fix |
| 124 | // round 2 item 1 — so this test is only about the peer half: the |
| 125 | // session here already holds a live participation in sqid throughout.) |
| 126 | func TestSignalRateLimitSharesOneBucketAcrossClaimedPeerIDs(t *testing.T) { |
| 127 | x, sqid, _ := memberWithMeeting(t) |
| 128 | c := x.browser(t) |
| 129 | c.signIn("paul@example.com") |
| 130 | s := c.stream("/m/" + sqid + "/stream") |
| 131 | defer s.close() |
| 132 | peer, _ := s.hello() |
| 133 | c.signal(sqid, peer, `{"type":"join"}`) |
| 134 | |
| 135 | var got429 bool |
| 136 | for i := 0; i < 100; i++ { |
| 137 | // A distinct, format-valid, but otherwise made-up peer id on |
| 138 | // every call: none of them is bound to anything, so if the |
| 139 | // limiter were still keyed on the claimed peer, rotating like |
| 140 | // this would never be throttled. |
| 141 | fake := fmt.Sprintf("%032x", i) |
| 142 | resp, _ := c.signal(sqid, fake, `{"type":"join"}`) |
| 143 | if resp.StatusCode == http.StatusTooManyRequests { |
| 144 | got429 = true |
| 145 | break |
| 146 | } |
| 147 | } |
| 148 | if !got429 { |
| 149 | t.Fatal("rotating the claimed peer id within one session evaded the rate limit") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // Task 5 review Important 2: an oversized peer field is refused, and |
| 154 | // never becomes a log argument — the log's "peer" is only ever a value |
| 155 | // already known to be exactly 32 lowercase hex. |
| 156 | func TestSignalRefusesAnOversizedPeerAndNeverLogsIt(t *testing.T) { |
| 157 | var buf bytes.Buffer |
| 158 | x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| 159 | c := x.browser(t) |
| 160 | c.signIn("paul@example.com") |
| 161 | sqid, _ := createMeeting(t, c, "x", "") |
| 162 | s := c.stream("/m/" + sqid + "/stream") |
| 163 | defer s.close() |
| 164 | s.hello() |
| 165 | |
| 166 | huge := strings.Repeat("a", 60*1024) |
| 167 | resp, _ := c.signal(sqid, huge, `{"type":"join"}`) |
| 168 | if resp.StatusCode != http.StatusBadRequest { |
| 169 | t.Fatalf("60 KB peer: %d, want 400", resp.StatusCode) |
| 170 | } |
| 171 | if strings.Contains(buf.String(), huge) { |
| 172 | t.Fatal("the oversized peer string reached the log") |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // fakeButValidSqid builds a room id in the exact shape ids.go's |
| 177 | // validSqid accepts (ten characters of sqidAlphabet) without touching |
| 178 | // crypto/rand — deterministic and cheap for a test that needs many |
| 179 | // distinct, well-formed ids no meeting will ever have. |
| 180 | func fakeButValidSqid(i int) string { |
| 181 | const digits = "23456789" // a safe subset of sqidAlphabet |
| 182 | b := make([]byte, 10) |
| 183 | for j := 9; j >= 0; j-- { |
| 184 | b[j] = digits[i%len(digits)] |
| 185 | i /= len(digits) |
| 186 | } |
| 187 | return string(b) |
| 188 | } |
| 189 | |
| 190 | // Task 5 review fix round 2 item 1: rotating the room id in the URL |
| 191 | // must not mint a rate-limit bucket per request. A session with no live |
| 192 | // participation in a room is refused before the limiter is ever |
| 193 | // touched, so fifty rotated (but well-formed) room ids leave the |
| 194 | // limiter's map exactly as it was after the one real signal. |
| 195 | func TestSignalSqidRotationNeverMintsARateLimiterBucket(t *testing.T) { |
| 196 | x, sqid, _ := memberWithMeeting(t) |
| 197 | c := x.browser(t) |
| 198 | c.signIn("paul@example.com") |
| 199 | s := c.stream("/m/" + sqid + "/stream") |
| 200 | defer s.close() |
| 201 | peer, _ := s.hello() |
| 202 | if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 203 | t.Fatalf("join: %d", resp.StatusCode) |
| 204 | } |
| 205 | if n := x.ctl.SignalLimiterSize(); n != 1 { |
| 206 | t.Fatalf("limiter buckets after one signal: %d, want 1", n) |
| 207 | } |
| 208 | |
| 209 | for i := 0; i < 50; i++ { |
| 210 | fake := fakeButValidSqid(i) |
| 211 | if resp, _ := c.signal(fake, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound { |
| 212 | t.Fatalf("rotated room %q: %d, want 404", fake, resp.StatusCode) |
| 213 | } |
| 214 | } |
| 215 | if n := x.ctl.SignalLimiterSize(); n != 1 { |
| 216 | t.Fatalf("limiter buckets after 50 rotated rooms: %d, want 1 — rotation must not mint a bucket per request", n) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // Task 5 review fix round 2 item 1: an oversized room id is refused |
| 221 | // before it can reach the limiter's key, mint a bucket, or a log line. |
| 222 | func TestSignalRefusesAnOversizedRoomIDAndNeverLogsIt(t *testing.T) { |
| 223 | var buf bytes.Buffer |
| 224 | x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| 225 | c := x.browser(t) |
| 226 | c.signIn("paul@example.com") |
| 227 | |
| 228 | huge := strings.Repeat("a", 1024) |
| 229 | resp, badRoomBody := c.signalRaw(huge, `{"peer":"0123456789abcdef0123456789abcdef","type":"join"}`) |
| 230 | if resp.StatusCode != http.StatusNotFound { |
| 231 | t.Fatalf("1 KB room id: %d, want 404", resp.StatusCode) |
| 232 | } |
| 233 | if n := x.ctl.SignalLimiterSize(); n != 0 { |
| 234 | t.Fatalf("limiter buckets after an oversized room id: %d, want 0", n) |
| 235 | } |
| 236 | if strings.Contains(buf.String(), huge) { |
| 237 | t.Fatal("the oversized room id reached the log") |
| 238 | } |
| 239 | |
| 240 | // Byte for byte the not-found body every other refusal here carries. |
| 241 | // A malformed room id is refused earlier and for a different reason |
| 242 | // than a peer that is not yours, and neither of those is anybody's |
| 243 | // business from outside: one answer, or the difference between them |
| 244 | // is a way to ask which meetings exist (§2.5). |
| 245 | sqid, _ := createMeeting(t, c, "A room this session has never opened a stream in", "") |
| 246 | resp, unboundBody := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`) |
| 247 | if resp.StatusCode != http.StatusNotFound { |
| 248 | t.Fatalf("a room with no live participation: %d, want 404", resp.StatusCode) |
| 249 | } |
| 250 | if !bytes.Equal(badRoomBody, unboundBody) { |
| 251 | t.Fatalf("404 bodies differ: bad room %q, no participation %q", badRoomBody, unboundBody) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // Task 5's fix round 2 ruling, and §4.5's window: the LiveForSession |
| 256 | // gate in front of the limiter is about a session that holds nothing in |
| 257 | // this room, not about a browser whose stream happens to be down. A |
| 258 | // detached participation inside its resume window is still this |
| 259 | // session's peer, so its signalling is still accepted — which is what |
| 260 | // lets a browser post `leave`, or answer an offer, in the gap between |
| 261 | // two streams. Once the window lapses and the tick clears it, the same |
| 262 | // request is the same 404 a stranger gets. |
| 263 | func TestSignalFromADetachedSessionInsideItsWindowIsAccepted(t *testing.T) { |
| 264 | x, sqid, _ := memberWithMeeting(t) |
| 265 | c := x.browser(t) |
| 266 | c.signIn("paul@example.com") |
| 267 | s := c.stream("/m/" + sqid + "/stream") |
| 268 | peer, _ := s.hello() |
| 269 | if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 270 | t.Fatalf("join: %d", resp.StatusCode) |
| 271 | } |
| 272 | if ev, _, ok := s.nextOf("roster", 2*time.Second); !ok { |
| 273 | t.Fatalf("never admitted (got %q)", ev) |
| 274 | } |
| 275 | |
| 276 | s.close() // the stream is gone; the participation has thirty seconds |
| 277 | if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 278 | t.Fatalf("signal from a detached session inside its window: %d %s", resp.StatusCode, b) |
| 279 | } |
| 280 | if n := x.ctl.SignalLimiterSize(); n != 1 { |
| 281 | t.Fatalf("limiter buckets: %d, want 1 — the detached session reached the limiter", n) |
| 282 | } |
| 283 | |
| 284 | x.advance(31 * time.Second) |
| 285 | x.ctl.TickForTest() |
| 286 | if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound { |
| 287 | t.Fatalf("signal after the window lapsed: %d, want 404", resp.StatusCode) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // Task 5's fix round 2 item 3: the 429 line names the peer the hub says |
| 292 | // this session holds, never the one the request claimed. A flood that |
| 293 | // rotates its claimed peer id must leave one log line's worth of truth |
| 294 | // behind — otherwise the rate-limit log is written by the attacker. |
| 295 | func TestTheRateLimitLogNamesTheSessionsRealPeer(t *testing.T) { |
| 296 | var buf bytes.Buffer |
| 297 | x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| 298 | c := x.browser(t) |
| 299 | c.signIn("paul@example.com") |
| 300 | sqid, _ := createMeeting(t, c, "x", "") |
| 301 | s := c.stream("/m/" + sqid + "/stream") |
| 302 | defer s.close() |
| 303 | peer, _ := s.hello() |
| 304 | c.signal(sqid, peer, `{"type":"join"}`) |
| 305 | |
| 306 | var claimed string |
| 307 | var got429 bool |
| 308 | for i := 0; i < 100; i++ { |
| 309 | claimed = fmt.Sprintf("%032x", i) |
| 310 | resp, _ := c.signal(sqid, claimed, `{"type":"join"}`) |
| 311 | if resp.StatusCode == http.StatusTooManyRequests { |
| 312 | got429 = true |
| 313 | break |
| 314 | } |
| 315 | } |
| 316 | if !got429 { |
| 317 | t.Fatal("the burst never hit the rate limit") |
| 318 | } |
| 319 | |
| 320 | var rateLine string |
| 321 | for _, line := range strings.Split(buf.String(), "\n") { |
| 322 | if strings.Contains(line, "outcome=rate") { |
| 323 | rateLine = line |
| 324 | break |
| 325 | } |
| 326 | } |
| 327 | if rateLine == "" { |
| 328 | t.Fatalf("no rate-limit line in the log:\n%s", buf.String()) |
| 329 | } |
| 330 | if !strings.Contains(rateLine, "peer="+peer) { |
| 331 | t.Fatalf("the 429 line does not name this session's own peer: %s", rateLine) |
| 332 | } |
| 333 | if strings.Contains(rateLine, "peer="+claimed) { |
| 334 | t.Fatalf("the 429 line names the peer the request claimed: %s", rateLine) |
| 335 | } |
| 336 | // And nothing else: type is unknown, because the body was never |
| 337 | // read (the limiter runs first), and the room is the one in the URL. |
| 338 | if !strings.Contains(rateLine, "type=unknown") || !strings.Contains(rateLine, "room="+sqid) { |
| 339 | t.Fatalf("the 429 line: %s", rateLine) |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | // Task 5 review minor 8: the body shapes signal.go must classify |
| 344 | // correctly, including the ones client.signal cannot express (a |
| 345 | // malformed document, a body with no peer at all). |
| 346 | func TestSignalBodyShapeTable(t *testing.T) { |
| 347 | x, sqid, _ := memberWithMeeting(t) |
| 348 | c := x.browser(t) |
| 349 | c.signIn("paul@example.com") |
| 350 | s := c.stream("/m/" + sqid + "/stream") |
| 351 | defer s.close() |
| 352 | peer, _ := s.hello() |
| 353 | if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 354 | t.Fatalf("setup join: %d", resp.StatusCode) |
| 355 | } |
| 356 | absent := fmt.Sprintf("%032x", 0) // well-formed, bound to nobody |
| 357 | |
| 358 | table := []struct { |
| 359 | name string |
| 360 | body string |
| 361 | want int |
| 362 | }{ |
| 363 | {"chat is slice 4", `{"peer":"` + string(peer) + `","type":"chat"}`, http.StatusBadRequest}, |
| 364 | {"share is slice 4", `{"peer":"` + string(peer) + `","type":"share"}`, http.StatusBadRequest}, |
| 365 | {"bogus type", `{"peer":"` + string(peer) + `","type":"bogus"}`, http.StatusBadRequest}, |
| 366 | {"malformed JSON", `{`, http.StatusBadRequest}, |
| 367 | {"missing peer", `{"type":"join"}`, http.StatusBadRequest}, |
| 368 | {"malformed admit payload.peer", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"not-hex"}}`, http.StatusBadRequest}, |
| 369 | {"well-formed absent admit target", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"` + absent + `"}}`, http.StatusConflict}, |
| 370 | } |
| 371 | for _, tc := range table { |
| 372 | t.Run(tc.name, func(t *testing.T) { |
| 373 | resp, _ := c.signalRaw(sqid, tc.body) |
| 374 | if resp.StatusCode != tc.want { |
| 375 | t.Fatalf("%s: %d, want %d", tc.name, resp.StatusCode, tc.want) |
| 376 | } |
| 377 | }) |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | func TestSignalLimits(t *testing.T) { |
| 382 | x, sqid, _ := memberWithMeeting(t) |
| 383 | c := x.browser(t) |
| 384 | c.signIn("paul@example.com") |
| 385 | s := c.stream("/m/" + sqid + "/stream") |
| 386 | defer s.close() |
| 387 | peer, _ := s.hello() |
| 388 | c.signal(sqid, peer, `{"type":"join"}`) |
| 389 | |
| 390 | big := `{"type":"candidate","to":"` + peer + `","seq":1,"gen":1,"payload":{"x":"` + strings.Repeat("a", 70*1024) + `"}}` |
| 391 | if resp, _ := c.signal(sqid, peer, big); resp.StatusCode != http.StatusRequestEntityTooLarge { |
| 392 | t.Fatalf("64 KB: %d", resp.StatusCode) |
| 393 | } |
| 394 | |
| 395 | // join, not leave: a successful leave ends the participation, and |
| 396 | // from fix round 2 on a session with no live participation in this |
| 397 | // room is refused at the LiveForSession gate before it ever reaches |
| 398 | // the limiter (item 1) — a burst that ended its own room membership |
| 399 | // on the first call would never reach the limiter again to prove |
| 400 | // anything. join on an already-admitted peer is idempotent (spec |
| 401 | // §4.3), so the participation, and the gate, stay open throughout. |
| 402 | var got429 bool |
| 403 | for i := 0; i < 100; i++ { |
| 404 | resp, _ := c.signal(sqid, peer, `{"type":"join"}`) |
| 405 | if resp.StatusCode == http.StatusTooManyRequests { |
| 406 | if resp.Header.Get("Retry-After") == "" { |
| 407 | t.Fatal("429 without Retry-After") |
| 408 | } |
| 409 | got429 = true |
| 410 | break |
| 411 | } |
| 412 | } |
| 413 | if !got429 { |
| 414 | t.Fatal("100 messages in a burst never hit the rate limit") |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // Renamed from TestSignalStaleGenAnswers409WithTheCurrentGen: the |
| 419 | // ledger records the decision that no gen goes inline in the response |
| 420 | // (see task-5-report.md) — the roster already carries it (spec §4.6). |
| 421 | func TestSignalStaleGenIsRefusedWith409(t *testing.T) { |
| 422 | x, sqid, _ := memberWithMeeting(t) |
| 423 | a := x.browser(t) |
| 424 | a.signIn("paul@example.com") |
| 425 | sa := a.stream("/m/" + sqid + "/stream") |
| 426 | defer sa.close() |
| 427 | pa, _ := sa.hello() |
| 428 | a.signal(sqid, pa, `{"type":"join"}`) |
| 429 | |
| 430 | b := newMemberBrowser(t, x, "paul@rslw.com") |
| 431 | sb := b.stream("/m/" + sqid + "/stream") |
| 432 | defer sb.close() |
| 433 | pb, _ := sb.hello() |
| 434 | b.signal(sqid, pb, `{"type":"join"}`) |
| 435 | |
| 436 | resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":1,"gen":7,"payload":{}}`) |
| 437 | if resp.StatusCode != http.StatusConflict { |
| 438 | t.Fatalf("stale gen: %d", resp.StatusCode) |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // Spec §4.3: a guest's join marks it waiting and tells admitted members |
| 443 | // with knock; the guest itself learns nothing at all from a fresh join |
| 444 | // (ruling 9, and hub's own TestMemberJoinsAndGuestWaits, which asserts |
| 445 | // zero events on a waiting guest's sub) — it is the *resume* of a |
| 446 | // waiting or unjoined participation that answers state{waiting:true} |
| 447 | // (stream.go, Task 4), not the join itself. This is the guest half of |
| 448 | // standingFor's hub.KindGuest mapping proven end-to-end over HTTP: a |
| 449 | // guest waits, a member in the same room sees knock. |
| 450 | func TestSignalGuestJoinWaitsAndKnocksTheRoom(t *testing.T) { |
| 451 | x, sqid, tok := memberWithMeeting(t) |
| 452 | enableWaitingRoom(t, x, sqid) |
| 453 | member := x.browser(t) |
| 454 | member.signIn("paul@example.com") |
| 455 | ms := member.stream("/m/" + sqid + "/stream") |
| 456 | defer ms.close() |
| 457 | mpeer, _ := ms.hello() |
| 458 | if resp, _ := member.signal(sqid, mpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 459 | t.Fatalf("member join: %d", resp.StatusCode) |
| 460 | } |
| 461 | if ev, _, ok := ms.next(2 * time.Second); !ok || ev != "roster" { |
| 462 | t.Fatalf("member after its own join: %s ok=%v, want roster", ev, ok) |
| 463 | } |
| 464 | |
| 465 | guest := x.browser(t) |
| 466 | guest.get("/j/" + tok) |
| 467 | guest.signIn("guest@example.com") |
| 468 | gs := guest.stream("/m/" + sqid + "/stream") |
| 469 | defer gs.close() |
| 470 | gpeer, _ := gs.hello() |
| 471 | |
| 472 | if resp, _ := guest.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 473 | t.Fatalf("guest join: %d", resp.StatusCode) |
| 474 | } |
| 475 | if ev, _, ok := ms.next(2 * time.Second); !ok || ev != "knock" { |
| 476 | t.Fatalf("member after the guest's join: %s ok=%v, want knock", ev, ok) |
| 477 | } |
| 478 | if ev, _, ok := gs.next(200 * time.Millisecond); ok { |
| 479 | t.Fatalf("waiting guest received %q, want nothing", ev) |
| 480 | } |
| 481 | |
| 482 | // The guest really is only waiting, not admitted: resuming its |
| 483 | // stream answers state{waiting:true}, not a roster. |
| 484 | gs.close() |
| 485 | gs2 := guest.stream("/m/" + sqid + "/stream?peer=" + gpeer) |
| 486 | defer gs2.close() |
| 487 | if p, _ := gs2.hello(); p != gpeer { |
| 488 | t.Fatalf("resume peer %q, want %q", p, gpeer) |
| 489 | } |
| 490 | ev, data, ok := gs2.next(2 * time.Second) |
| 491 | if !ok || ev != "state" { |
| 492 | t.Fatalf("waiting guest's resume: %s ok=%v, want state", ev, ok) |
| 493 | } |
| 494 | var state struct{ Waiting bool } |
| 495 | json.Unmarshal(data, &state) |
| 496 | if !state.Waiting { |
| 497 | t.Fatalf("state %s, want waiting", data) |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // Spec §4.5 and §4.6: two admitted peers, one drops its stream and |
| 502 | // resumes with ?peer= and last, and gets hello, roster, then the |
| 503 | // replay — in that order, and only the messages above last. |
| 504 | func TestSignalAdmittedResumeReplaysAfterRoster(t *testing.T) { |
| 505 | x, sqid, _ := memberWithMeeting(t) |
| 506 | a := x.browser(t) |
| 507 | a.signIn("paul@example.com") |
| 508 | sa := a.stream("/m/" + sqid + "/stream") |
| 509 | defer sa.close() |
| 510 | pa, _ := sa.hello() |
| 511 | if resp, _ := a.signal(sqid, pa, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 512 | t.Fatalf("a's join: %d", resp.StatusCode) |
| 513 | } |
| 514 | if ev, _, ok := sa.next(2 * time.Second); !ok || ev != "roster" { |
| 515 | t.Fatalf("a after its own join: %s ok=%v, want roster", ev, ok) |
| 516 | } |
| 517 | |
| 518 | b := newMemberBrowser(t, x, "paul@rslw.com") |
| 519 | sb := b.stream("/m/" + sqid + "/stream") |
| 520 | pb, _ := sb.hello() |
| 521 | if resp, _ := b.signal(sqid, pb, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 522 | t.Fatalf("b's join: %d", resp.StatusCode) |
| 523 | } |
| 524 | if ev, _, ok := sb.next(2 * time.Second); !ok || ev != "roster" { |
| 525 | t.Fatalf("b after its own join: %s ok=%v, want roster", ev, ok) |
| 526 | } |
| 527 | if ev, _, ok := sa.next(2 * time.Second); !ok || ev != "peer" { |
| 528 | t.Fatalf("a on b's admission: %s ok=%v, want peer", ev, ok) |
| 529 | } |
| 530 | |
| 531 | // One offer delivered live, so it is out of the replay's range. |
| 532 | if resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":1,"gen":1,"payload":{}}`); resp.StatusCode != http.StatusOK { |
| 533 | t.Fatalf("a's first offer: %d", resp.StatusCode) |
| 534 | } |
| 535 | if ev, _, ok := sb.next(2 * time.Second); !ok || ev != "offer" { |
| 536 | t.Fatalf("b receiving the first offer live: %s ok=%v", ev, ok) |
| 537 | } |
| 538 | |
| 539 | sb.close() |
| 540 | |
| 541 | // A second offer, sent while b is detached, lands only in the ring. |
| 542 | if resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":2,"gen":1,"payload":{}}`); resp.StatusCode != http.StatusOK { |
| 543 | t.Fatalf("a's second offer: %d", resp.StatusCode) |
| 544 | } |
| 545 | |
| 546 | last, err := json.Marshal(map[string]uint64{pa: 1}) |
| 547 | if err != nil { |
| 548 | t.Fatal(err) |
| 549 | } |
| 550 | sb2 := b.stream("/m/" + sqid + "/stream?peer=" + pb + "&last=" + url.QueryEscape(string(last))) |
| 551 | defer sb2.close() |
| 552 | if p, _ := sb2.hello(); p != pb { |
| 553 | t.Fatalf("resume hello peer %q, want %q", p, pb) |
| 554 | } |
| 555 | if ev, _, ok := sb2.next(2 * time.Second); !ok || ev != "roster" { |
| 556 | t.Fatalf("first after resume hello: %s ok=%v, want roster", ev, ok) |
| 557 | } |
| 558 | ev, data, ok := sb2.next(2 * time.Second) |
| 559 | if !ok || ev != "offer" { |
| 560 | t.Fatalf("second after resume: %s ok=%v, want the replayed offer", ev, ok) |
| 561 | } |
| 562 | var replay struct{ Seq uint64 } |
| 563 | json.Unmarshal(data, &replay) |
| 564 | if replay.Seq != 2 { |
| 565 | t.Fatalf("replayed offer seq %d, want 2 (the one sent while b was detached)", replay.Seq) |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | // The §0.1 and §4.4 promise: no payload, address, name, title, token or |
| 570 | // session key in any log line, on accepted and refused messages and on |
| 571 | // an internal-error path. Task 5 review item 4: the earlier version of |
| 572 | // this test passed vacuously — it never exercised a guest's knock, an |
| 573 | // admit, or the 500 arm, and its address ("paul@rslw.com") happened not |
| 574 | // to leave a distinctive trace ("paul" and "example.com" both appear |
| 575 | // harmlessly elsewhere). zaphod-xyzzy@example.com is chosen so its |
| 576 | // derived display name (store.ensurePerson's local-part rule, |
| 577 | // "zaphod-xyzzy") is its own needle. |
| 578 | func TestSignallingLogsCarryNoContent(t *testing.T) { |
| 579 | var buf bytes.Buffer |
| 580 | x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| 581 | c := x.browser(t) |
| 582 | c.signIn("paul@example.com") |
| 583 | sqid, tok := createMeeting(t, c, "Secret title", "") |
| 584 | enableWaitingRoom(t, x, sqid) |
| 585 | s := c.stream("/m/" + sqid + "/stream") |
| 586 | defer s.close() |
| 587 | peer, _ := s.hello() |
| 588 | if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 589 | t.Fatalf("member join: %d", resp.StatusCode) |
| 590 | } |
| 591 | |
| 592 | // A real guest, via the join link, not another member: its join is |
| 593 | // the knock, and it stays out of the log entirely until admitted. |
| 594 | guest := x.browser(t) |
| 595 | guest.get("/j/" + tok) |
| 596 | guest.signIn("zaphod-xyzzy@example.com") |
| 597 | gs := guest.stream("/m/" + sqid + "/stream") |
| 598 | defer gs.close() |
| 599 | gpeer, _ := gs.hello() |
| 600 | if resp, _ := guest.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| 601 | t.Fatalf("guest join (the knock): %d", resp.StatusCode) |
| 602 | } |
| 603 | |
| 604 | // The admit round: previously untested by this file at all. |
| 605 | if resp, _ := c.signal(sqid, peer, `{"type":"admit","payload":{"peer":"`+gpeer+`"}}`); resp.StatusCode != http.StatusOK { |
| 606 | t.Fatalf("admit: %d", resp.StatusCode) |
| 607 | } |
| 608 | |
| 609 | c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":1,"gen":1,"payload":{"sdp":"v=0 SDPSECRET a=candidate 203.0.113.9"}}`) |
| 610 | c.signal(sqid, peer, `{"type":"candidate","to":"`+gpeer+`","seq":2,"gen":1,"payload":{"candidate":"candidate:1 1 udp 2 203.0.113.9 5000 typ host"}}`) |
| 611 | c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":3,"gen":9,"payload":{"sdp":"STALESECRET"}}`) // outcome=stale-gen |
| 612 | c.signal(sqid, peer, `{"type":"bogus"}`) // outcome=type |
| 613 | |
| 614 | // An accepted chat appends nothing to the log at all — no line, let |
| 615 | // alone one carrying its text — and an oversize one is refused with |
| 616 | // the usual outcome-only line, never the text that overflowed it. |
| 617 | beforeChat := buf.Len() |
| 618 | if resp, _ := c.signal(sqid, peer, `{"type":"chat","payload":{"text":"xyzzy-plugh-chat"}}`); resp.StatusCode != http.StatusOK { |
| 619 | t.Fatalf("accepted chat: %d", resp.StatusCode) |
| 620 | } |
| 621 | if buf.Len() != beforeChat { |
| 622 | t.Fatalf("an accepted chat appended %d bytes to the log: %q", buf.Len()-beforeChat, buf.String()[beforeChat:]) |
| 623 | } |
| 624 | // "xyzzy-oversize-" is its own needle: the earlier assertion only |
| 625 | // proved the accepted chat's text never grew the log, not that a |
| 626 | // *refused* chat's text is equally absent (review fix round 1, |
| 627 | // item 2). |
| 628 | oversizeChat := `{"type":"chat","payload":{"text":"xyzzy-oversize-` + strings.Repeat("a", 2049) + `"}}` |
| 629 | if resp, _ := c.signal(sqid, peer, oversizeChat); resp.StatusCode != http.StatusRequestEntityTooLarge { |
| 630 | t.Fatalf("oversize chat: %d", resp.StatusCode) |
| 631 | } // outcome=size |
| 632 | |
| 633 | x.advance(time.Minute) |
| 634 | x.ctl.TickForTest() |
| 635 | |
| 636 | // Force the internal-error arm without breaking sign-in itself: |
| 637 | // closing the whole database also breaks rastrillo's own |
| 638 | // per-request session lookup, which refuses the request before it |
| 639 | // ever reaches the signal handler. Dropping only the meetings table |
| 640 | // leaves sessions and persons answering normally, so the request |
| 641 | // reaches hub.Join, whose standing re-derivation reads meetings and |
| 642 | // surfaces a genuine driver error — a sentinel this switch does not |
| 643 | // already name — exercising the one log line no other case in this |
| 644 | // test reaches (review minor 9). |
| 645 | // |
| 646 | // In its own trailing t.Run, named for what it does and that it |
| 647 | // must be last: the table it drops is gone for the rest of the |
| 648 | // process, so nothing after this subtest may touch it (review fix |
| 649 | // round 2 item 7 — a structural marker, not just a comment, for a |
| 650 | // constraint a future edit could otherwise violate silently). |
| 651 | t.Run("forced internal error (must run last: drops the meetings table)", func(t *testing.T) { |
| 652 | if err := x.db.G.Exec("DROP TABLE meetings").Error; err != nil { |
| 653 | t.Fatal(err) |
| 654 | } |
| 655 | resp, _ := c.signal(sqid, peer, `{"type":"join"}`) |
| 656 | if resp.StatusCode != http.StatusInternalServerError { |
| 657 | t.Fatalf("forced internal error: %d, want 500", resp.StatusCode) |
| 658 | } |
| 659 | }) |
| 660 | |
| 661 | logs := buf.String() |
| 662 | for _, want := range []string{"outcome=stale-gen", "outcome=type", "outcome=size", "outcome=internal"} { |
| 663 | if !strings.Contains(logs, want) { |
| 664 | t.Errorf("log missing %q:\n%s", want, logs) |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | forbidden := []string{ |
| 669 | "Secret title", "SDPSECRET", "203.0.113.9", "STALESECRET", tok, |
| 670 | "paul@example.com", "zaphod-xyzzy@example.com", "zaphod-xyzzy", |
| 671 | "xyzzy-plugh-chat", "xyzzy-oversize-", |
| 672 | } |
| 673 | for _, ck := range c.cookies { |
| 674 | forbidden = append(forbidden, ck.Value) |
| 675 | } |
| 676 | for _, ck := range guest.cookies { |
| 677 | forbidden = append(forbidden, ck.Value) |
| 678 | } |
| 679 | for _, needle := range forbidden { |
| 680 | if needle != "" && strings.Contains(logs, needle) { |
| 681 | t.Errorf("log carries %q", needle) |
| 682 | } |
| 683 | } |
| 684 | } |
| 685 | |