package callstest import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "regexp" "strings" "sync" "testing" "time" "amadan.net/oficina/calls/internal/calls" "amadan.net/oficina/calls/protocol" ) // Spec §15.26: quality and problem reports. These tests hold the ruling's // lines: what is logged, under which tags, and what never is; who may // report, and for how long; the bounds; the kill switch. const ( reportA = "paul@example.com" reportB = "ana@example.com" reportTitle = "Quarterly secrets review" ) // syncBuffer is a log sink a stream goroutine and the test can share. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // lines are the JSON lines whose msg is msg. func (b *syncBuffer) lines(t *testing.T, msg string) []map[string]any { t.Helper() var out []map[string]any for _, line := range strings.Split(b.String(), "\n") { if strings.TrimSpace(line) == "" { continue } var m map[string]any if err := json.Unmarshal([]byte(line), &m); err != nil { t.Fatalf("a report line is not JSON: %v\n%s", err, line) } if m["msg"] == msg { out = append(out, m) } } return out } // reportRig is a meeting with two members in it, each joined, each // holding its report token; reports go to `reports` as JSON, as the // deployed main writes them, and everything else the app logs goes to // `app`. type reportRig struct { x *harness sqid string a, b *client sa, sb *sseReader peerA, peerB string tokA, tokB string reports *syncBuffer app *syncBuffer } func newReportRig(t *testing.T, quality string) *reportRig { t.Helper() reports, app := &syncBuffer{}, &syncBuffer{} x := newHarnessWith(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(app, &slog.HandlerOptions{Level: slog.LevelDebug})), func(c *calls.Config) { c.ReportLogger = slog.New(slog.NewJSONHandler(reports, nil)) c.QualityReports = quality }, reportA, reportB) a := newMemberBrowser(t, x, reportA) sqid, _ := createMeeting(t, a, reportTitle, "") b := newMemberBrowser(t, x, reportB) r := &reportRig{x: x, sqid: sqid, a: a, b: b, reports: reports, app: app} r.sa, r.peerA, r.tokA = r.enter(t, a) r.sb, r.peerB, r.tokB = r.enter(t, b) t.Cleanup(func() { r.sa.close(); r.sb.close() }) return r } // enter opens a stream, reads the hello's peer and report token, and // joins. func (r *reportRig) enter(t *testing.T, c *client) (*sseReader, string, string) { t.Helper() s := c.stream("/m/" + r.sqid + "/stream") peer, token := helloReport(t, s) if resp, _ := c.signal(r.sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { t.Fatalf("join: %d", resp.StatusCode) } return s, peer, token } func helloReport(t *testing.T, s *sseReader) (peer, token string) { t.Helper() name, data, ok := s.next(2 * time.Second) if !ok || name != "hello" { t.Fatalf("first event is %q, want hello", name) } var body struct{ Peer, Report string } if err := json.Unmarshal(data, &body); err != nil { t.Fatal(err) } if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(body.Report) { t.Fatalf("hello carries no report token: %s", data) } return body.Peer, body.Report } // report POSTs body to /m/{sqid}/{kind} with token. func (c *client) report(sqid, kind, token string, body []byte) (*http.Response, []byte) { c.t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.x.server().URL+"/m/"+sqid+"/"+kind, bytes.NewReader(body)) if err != nil { c.t.Fatal(err) } for _, ck := range c.cookies { req.AddCookie(ck) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", testOrigin) if token != "" { req.Header.Set("X-Calls-Report", token) } resp, err := c.x.server().Client().Do(req) if err != nil { c.t.Fatalf("POST %s: %v", kind, err) } b, _ := io.ReadAll(resp.Body) resp.Body.Close() return resp, b } func num(v float64) *float64 { return &v } // sampleQuality is an honest report about one remote peer. func sampleQuality(remote string) protocol.QualityReport { return protocol.QualityReport{ V: 1, Seconds: num(30), Client: protocol.ReportClient{App: "mac", Version: "0.4.9", OS: "macos", OSVersion: "26.0.1"}, Devices: &protocol.Devices{Input: "bluetooth", Output: "built-in"}, Events: &protocol.CallEvents{Reconnecting: num(1), ReconnectingSeconds: num(4.2)}, Peers: []protocol.PeerQuality{{ Peer: protocol.PeerID(remote), Path: &protocol.Path{Local: "srflx", Remote: "relay", Protocol: "udp"}, RTT: num(48.25), Out: &protocol.Outbound{ Audio: &protocol.OutStream{Kbps: num(32), Packets: num(1500), Lost: num(3), RTT: num(50), Jitter: num(4)}, Video: &protocol.OutStream{Kbps: num(900), Limit: "bandwidth", Width: num(1280), Height: num(720), FPS: num(30)}, }, In: &protocol.Inbound{ Audio: &protocol.InAudio{Packets: num(1490), Lost: num(10), Concealed: num(4800), Samples: num(1440000), JitterBufferMs: num(62)}, Video: &protocol.InVideo{Packets: num(2700), Lost: num(300), Freezes: num(2), FreezeSeconds: num(1.5), Decoded: num(890), Dropped: num(4), Width: num(640), Height: num(360), FPS: num(29.7)}, }, Events: &protocol.PeerEvents{CantHearSeconds: num(2), NoSound: true}, }}, } } // forbidden is everything the ruling says a report line never carries: // the meeting, the people, their addresses and names, the peer ids that // the signalling log names, and the report tokens. func (r *reportRig) forbidden() []string { return []string{r.sqid, reportTitle, reportA, reportB, "example.com", r.peerA, r.peerB, r.tokA, r.tokB} } func (r *reportRig) assertClean(t *testing.T) { t.Helper() raw := r.reports.String() for _, f := range r.forbidden() { if strings.Contains(raw, f) { t.Errorf("the report log contains %q:\n%s", f, raw) } } } func TestQualityReportsShareACallTagAndNameNothing(t *testing.T) { r := newReportRig(t, "") resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, sampleQuality(r.peerB))) if resp.StatusCode != http.StatusAccepted { t.Fatalf("A's report: %d, want 202", resp.StatusCode) } resp, _ = r.b.report(r.sqid, "quality", r.tokB, mustJSON(t, sampleQuality(r.peerA))) if resp.StatusCode != http.StatusAccepted { t.Fatalf("B's report: %d, want 202", resp.StatusCode) } lines := r.reports.lines(t, "calls: quality") if len(lines) != 2 { t.Fatalf("%d quality lines, want 2:\n%s", len(lines), r.reports.String()) } a, b := lines[0], lines[1] tag := regexp.MustCompile(`^[a-z2-7]{16}$`) for _, k := range []string{"call", "peer"} { for _, l := range lines { if s, _ := l[k].(string); !tag.MatchString(s) { t.Fatalf("%s tag %v is not a random tag", k, l[k]) } } } if a["call"] != b["call"] { t.Errorf("one call, two call tags: %v and %v", a["call"], b["call"]) } if a["peer"] == b["peer"] { t.Errorf("two participations, one peer tag %v", a["peer"]) } // A's report names B by B's tag, never B's id. rep := a["report"].(map[string]any) peers := rep["peers"].([]any) if got := peers[0].(map[string]any)["peer"]; got != b["peer"] { t.Errorf("A's report names its peer %v, want B's tag %v", got, b["peer"]) } // The line's shape: what carlos logs --field can filter on, at the // top, and the rest under report. for k, want := range map[string]any{"level": "INFO", "app": "mac", "version": "0.4.9", "os": "macos", "osVersion": "26.0.1", "final": false, "seconds": 30.0, "remotes": 1.0, "relay": true, "maxLossPct": 10.0, "maxRtt": 50.0, "freezes": 2.0, "reconnecting": 1.0} { if a[k] != want { t.Errorf("line[%q] = %v (%T), want %v", k, a[k], a[k], want) } } if got := peers[0].(map[string]any)["rtt"]; got != 48.3 { t.Errorf("rtt %v, want rounded to a tenth (48.3)", got) } if _, ok := rep["client"]; ok { t.Error("the client is logged twice: at the top and under report") } r.assertClean(t) // And the app's own log says nothing about an accepted report. if strings.Contains(r.app.String(), "quality") { t.Errorf("the app log mentions a quality report:\n%s", r.app.String()) } } func TestQualityReportSchemaIsStrict(t *testing.T) { r := newReportRig(t, "") base := func() map[string]any { var m map[string]any _ = json.Unmarshal(mustJSON(t, sampleQuality(r.peerB)), &m) return m } peer0 := func(m map[string]any) map[string]any { return m["peers"].([]any)[0].(map[string]any) } cases := map[string]func(m map[string]any){ "version 2": func(m map[string]any) { m["v"] = 2 }, "no seconds": func(m map[string]any) { delete(m, "seconds") }, "zero seconds": func(m map[string]any) { m["seconds"] = 0 }, "an hour": func(m map[string]any) { m["seconds"] = 3600 }, "unknown app": func(m map[string]any) { m["client"].(map[string]any)["app"] = "android" }, "no app": func(m map[string]any) { delete(m["client"].(map[string]any), "app") }, "a version with a space": func(m map[string]any) { m["client"].(map[string]any)["version"] = "0.4 beta" }, "a user agent": func(m map[string]any) { m["client"].(map[string]any)["osVersion"] = "Mozilla/5.0" }, "a device's name": func(m map[string]any) { m["devices"].(map[string]any)["input"] = "Paul's AirPods Pro" }, "a string for a number": func(m map[string]any) { peer0(m)["rtt"] = "48" }, "a negative count": func(m map[string]any) { peer0(m)["in"].(map[string]any)["audio"].(map[string]any)["lost"] = -1 }, "a huge count": func(m map[string]any) { peer0(m)["in"].(map[string]any)["audio"].(map[string]any)["samples"] = 1e12 }, "a minute's rtt and more": func(m map[string]any) { peer0(m)["rtt"] = 600001 }, "an address for a path": func(m map[string]any) { peer0(m)["path"].(map[string]any)["local"] = "192.168.1.4" }, "an unknown limit": func(m map[string]any) { peer0(m)["out"].(map[string]any)["video"].(map[string]any)["limit"] = "thermal" }, "a name for a peer": func(m map[string]any) { peer0(m)["peer"] = "Ana" }, "the same peer twice": func(m map[string]any) { m["peers"] = append(m["peers"].([]any), peer0(m)) }, "a peer list for a room": func(m map[string]any) { m["peers"] = "everyone" }, "five peers": func(m map[string]any) { var ps []any for i := 0; i <= protocol.MaxQualityPeers; i++ { ps = append(ps, map[string]any{"peer": fmt.Sprintf("%032x", i)}) } m["peers"] = ps }, } for name, spoil := range cases { m := base() spoil(m) r.x.advance(10 * time.Second) // one report's budget each resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, m)) if resp.StatusCode != http.StatusBadRequest { t.Errorf("%s: %d, want 400", name, resp.StatusCode) } } if n := len(r.reports.lines(t, "calls: quality")); n != 0 { t.Fatalf("%d refused reports were logged", n) } // A member the schema does not name is tolerated, and not logged: // a newer client's field, or anything a client should never send. m := base() m["name"] = "Paul Campbell" m["client"].(map[string]any)["device"] = "Paul's MacBook Pro" peer0(m)["address"] = "203.0.113.9" peer0(m)["out"].(map[string]any)["audio"].(map[string]any)["codec"] = "opus" r.x.advance(10 * time.Second) if resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, m)); resp.StatusCode != http.StatusAccepted { t.Fatalf("unknown members: %d, want 202", resp.StatusCode) } raw := r.reports.String() for _, f := range []string{"Paul Campbell", "MacBook", "203.0.113.9", "codec", "opus"} { if strings.Contains(raw, f) { t.Errorf("an unknown member reached the log: %q", f) } } // Past 16 KB the body is not read. big := base() big["padding"] = strings.Repeat("x", protocol.MaxQualityBody) r.x.advance(10 * time.Second) if resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, big)); resp.StatusCode != http.StatusRequestEntityTooLarge { t.Errorf("a 16 KB report: %d, want 413", resp.StatusCode) } r.assertClean(t) } // The longest report the schema allows, every number at its ceiling and // every peer present, still fits in one platform log line: the platform // keeps 8 KB of a line and truncates the rest. func TestTheLargestQualityLineFitsThePlatformsLine(t *testing.T) { r := newReportRig(t, "") big := func() *float64 { return num(123456789.1) } ms := func() *float64 { return num(599999.9) } out := func() *protocol.OutStream { return &protocol.OutStream{Kbps: num(999999.9), Packets: big(), Lost: big(), RTT: ms(), Jitter: ms(), Limit: "bandwidth", Width: num(16384), Height: num(16384), FPS: num(240), Keyframes: big(), Rebuilds: big(), Reshares: big(), Failures: big()} } in := func() *protocol.InVideo { return &protocol.InVideo{Kbps: num(999999.9), Packets: big(), Lost: big(), Jitter: ms(), Freezes: big(), FreezeSeconds: num(599.9), Decoded: big(), Dropped: big(), Width: num(16384), Height: num(16384), FPS: num(240), Reattaches: big()} } q := sampleQuality(r.peerB) q.Peers = nil for i := 0; i < protocol.MaxQualityPeers; i++ { q.Peers = append(q.Peers, protocol.PeerQuality{ Peer: protocol.PeerID(fmt.Sprintf("%032x", i)), Path: &protocol.Path{Local: "unknown", Remote: "unknown", Protocol: "unknown"}, RTT: ms(), Out: &protocol.Outbound{Audio: out(), Video: out(), Screen: out()}, In: &protocol.Inbound{ Audio: &protocol.InAudio{Kbps: num(999999.9), Packets: big(), Lost: big(), Jitter: ms(), Samples: big(), Concealed: big(), Inserted: big(), Removed: big(), JitterBufferMs: ms()}, Video: in(), Screen: in(), }, Events: &protocol.PeerEvents{Reconnecting: big(), ReconnectingSeconds: num(599.9), CantHearSeconds: num(599.9), NoSound: true, NoSoundFromUs: true, AudioBlocked: true, ScreenStalled: true}, }) } if resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, q)); resp.StatusCode != http.StatusAccepted { t.Fatalf("the largest report: %d", resp.StatusCode) } line := strings.TrimSpace(r.reports.String()) if n := len(line); n > 8192 { t.Fatalf("the largest quality line is %d bytes; the platform keeps 8192", n) } } func TestQualityReportsAreAuthorizedByTheTokenTheSessionAndTheRoom(t *testing.T) { r := newReportRig(t, "") body := mustJSON(t, sampleQuality(r.peerB)) other, _ := createMeeting(t, r.a, "another", "") for name, try := range map[string]func() *http.Response{ // Signed out never reaches the handler: the session middleware // refuses it first, which is a refusal all the same. "no token": func() *http.Response { resp, _ := r.a.report(r.sqid, "quality", "", body); return resp }, "a made-up token": func() *http.Response { resp, _ := r.a.report(r.sqid, "quality", strings.Repeat("0", 32), body) return resp }, "a token not hex": func() *http.Response { resp, _ := r.a.report(r.sqid, "quality", "Paul", body); return resp }, "B's token from A": func() *http.Response { resp, _ := r.a.report(r.sqid, "quality", r.tokB, body); return resp }, "another room": func() *http.Response { resp, _ := r.a.report(other, "quality", r.tokA, body); return resp }, "a room id not a room": func() *http.Response { resp, _ := r.a.report("not-a-room", "quality", r.tokA, body); return resp }, "signed out": func() *http.Response { c := r.x.browser(t) resp, _ := c.report(r.sqid, "quality", r.tokA, body) return resp }, } { resp := try() want := http.StatusNotFound if name == "signed out" { want = http.StatusForbidden } if resp.StatusCode != want { t.Errorf("%s: %d, want %d", name, resp.StatusCode, want) } } if n := len(r.reports.lines(t, "calls: quality")); n != 0 { t.Fatalf("%d unauthorized reports were logged", n) } } func TestReportsOutliveTheParticipationByTenMinutes(t *testing.T) { r := newReportRig(t, "") if resp, _ := r.a.signal(r.sqid, r.peerA, `{"type":"leave"}`); resp.StatusCode != http.StatusOK { t.Fatalf("leave: %d", resp.StatusCode) } final := sampleQuality(r.peerB) final.Final = true if resp, _ := r.a.report(r.sqid, "quality", r.tokA, mustJSON(t, final)); resp.StatusCode != http.StatusAccepted { t.Fatalf("the final report after leave: %d, want 202", resp.StatusCode) } r.x.advance(9 * time.Minute) resp, body := r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{Note: "it went quiet", Client: protocol.ReportClient{App: "web"}})) if resp.StatusCode != http.StatusOK { t.Fatalf("a problem report nine minutes after leaving: %d %s", resp.StatusCode, body) } lines := r.reports.lines(t, "calls: problem report") if len(lines) != 1 || lines[0]["live"] != false { t.Fatalf("problem lines %v, want one with live=false", lines) } r.x.advance(2 * time.Minute) resp, _ = r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{Client: protocol.ReportClient{App: "web"}})) if resp.StatusCode != http.StatusNotFound { t.Fatalf("eleven minutes after leaving: %d, want 404", resp.StatusCode) } // The ended meeting: B's token keeps working for the window too. if rec := r.a.post("/meetings/"+r.sqid+"/end", nil); rec.Code != http.StatusSeeOther { t.Fatalf("end: %d", rec.Code) } if resp, _ := r.b.report(r.sqid, "quality", r.tokB, mustJSON(t, final)); resp.StatusCode != http.StatusAccepted { t.Fatalf("a final report from an ended meeting: %d, want 202", resp.StatusCode) } r.assertClean(t) } func TestAResumeKeepsTheTokenAndAnEmptyRoomStartsANewCall(t *testing.T) { r := newReportRig(t, "") // A resumed stream's hello carries the same token. r.sa.close() s := r.a.stream("/m/" + r.sqid + "/stream?peer=" + r.peerA) peer, token := helloReport(t, s) if peer != r.peerA || token != r.tokA { t.Fatalf("resume: peer %s token %s, want the same participation's", peer, token) } s.close() callOf := func(c *client, token, remote string) string { t.Helper() if resp, _ := c.report(r.sqid, "quality", token, mustJSON(t, sampleQuality(remote))); resp.StatusCode != http.StatusAccepted { t.Fatalf("report: %d", resp.StatusCode) } lines := r.reports.lines(t, "calls: quality") return lines[len(lines)-1]["call"].(string) } first := callOf(r.a, r.tokA, r.peerB) for _, x := range []struct { c *client peer string }{{r.a, r.peerA}, {r.b, r.peerB}} { if resp, _ := x.c.signal(r.sqid, x.peer, `{"type":"leave"}`); resp.StatusCode != http.StatusOK { t.Fatalf("leave: %d", resp.StatusCode) } } r.sb.close() // Everyone has gone: the next person in starts a new call. _, peerA2, tokA2 := r.enter(t, r.a) second := callOf(r.a, tokA2, peerA2) if first == second { t.Fatalf("an empty room's next call kept the call tag %s", first) } // The old token still reports under the old call. if got := callOf(r.a, r.tokA, r.peerB); got != first { t.Fatalf("the first call's token reports under %s, want %s", got, first) } } func TestReportRateLimits(t *testing.T) { r := newReportRig(t, "") body := mustJSON(t, sampleQuality(r.peerB)) for i := 0; i < 6; i++ { if resp, _ := r.a.report(r.sqid, "quality", r.tokA, body); resp.StatusCode != http.StatusAccepted { t.Fatalf("quality report %d: %d, want 202 (burst six)", i+1, resp.StatusCode) } } resp, _ := r.a.report(r.sqid, "quality", r.tokA, body) if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" { t.Fatalf("the seventh at once: %d (Retry-After %q), want 429 with Retry-After", resp.StatusCode, resp.Header.Get("Retry-After")) } // Per participation: B's budget is its own. if resp, _ := r.b.report(r.sqid, "quality", r.tokB, mustJSON(t, sampleQuality(r.peerA))); resp.StatusCode != http.StatusAccepted { t.Fatalf("B, after A spent its budget: %d", resp.StatusCode) } r.x.advance(10 * time.Second) if resp, _ := r.a.report(r.sqid, "quality", r.tokA, body); resp.StatusCode != http.StatusAccepted { t.Fatalf("ten seconds later: %d, want 202", resp.StatusCode) } problem := mustJSON(t, protocol.ProblemReport{Note: "x", Client: protocol.ReportClient{App: "web"}}) for i := 0; i < 3; i++ { if resp, _ := r.a.report(r.sqid, "problem", r.tokA, problem); resp.StatusCode != http.StatusOK { t.Fatalf("problem report %d: %d", i+1, resp.StatusCode) } } if resp, _ := r.a.report(r.sqid, "problem", r.tokA, problem); resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("the fourth problem report at once: %d, want 429", resp.StatusCode) } } func TestTheKillSwitchAnswers204AndLogsNothing(t *testing.T) { r := newReportRig(t, "off") for _, token := range []string{r.tokA, ""} { resp, _ := r.a.report(r.sqid, "quality", token, mustJSON(t, sampleQuality(r.peerB))) if resp.StatusCode != http.StatusNoContent { t.Fatalf("quality reports off (token %q): %d, want 204", token, resp.StatusCode) } } if n := len(r.reports.lines(t, "calls: quality")); n != 0 { t.Fatalf("%d quality lines with reports off", n) } // "Report a problem" is a person asking; it is not switched off. if resp, _ := r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{Client: protocol.ReportClient{App: "web"}})); resp.StatusCode != http.StatusOK { t.Fatalf("a problem report with quality reports off: %d, want 200", resp.StatusCode) } if !strings.Contains(r.app.String(), "quality reports off") { t.Error("boot did not say quality reports are off") } } func TestAProblemReportIsLoggedUnderItsReferenceAndScrubbed(t *testing.T) { r := newReportRig(t, "") // What a client sends: people as "peer" and six characters of their // id, and here also the full id and the meeting's address, which a // client could have in a log line it did not write. var diag strings.Builder diag.WriteString("Calls web diagnostics\n## Call\n") fmt.Fprintf(&diag, "peer %s: connection connected\nopened %s/m/%s\nfull id %s\n", r.peerB[:6], testOrigin, r.sqid, r.peerB) for diag.Len() < 20_000 { diag.WriteString("12:00:00.000 screen from " + r.peerB[:6] + " stalled here: \"packets\" <+0> — no packets arriving\n") } note := "Ana's video froze at 15:40 — " + strings.Repeat("é", 900) resp, body := r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{ Note: note, Diagnostics: diag.String(), Client: protocol.ReportClient{App: "web", Version: "de04e2d", Browser: "chrome", BrowserVersion: "141.0", OS: "macos", OSVersion: "10.15.7"}, })) if resp.StatusCode != http.StatusOK { t.Fatalf("problem: %d %s", resp.StatusCode, body) } var receipt protocol.ProblemReceipt if err := json.Unmarshal(body, &receipt); err != nil || !regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{8}$`).MatchString(receipt.Ref) { t.Fatalf("receipt %s: want an eight-character reference", body) } heads := r.reports.lines(t, "calls: problem report") if len(heads) != 1 { t.Fatalf("%d problem report lines, want 1", len(heads)) } head := heads[0] if head["ref"] != receipt.Ref || head["note"] != note || head["level"] != "WARN" || head["live"] != true || head["browser"] != "chrome" { t.Fatalf("head line %v", head) } parts := r.reports.lines(t, "calls: problem report diagnostics") if len(parts) < 3 || float64(len(parts)) != head["parts"] { t.Fatalf("%d diagnostics lines, head says %v; want 20 KB in several", len(parts), head["parts"]) } var joined strings.Builder for i, p := range parts { if p["ref"] != receipt.Ref || p["part"] != float64(i+1) || p["of"] != float64(len(parts)) || p["call"] != head["call"] || p["peer"] != head["peer"] { t.Fatalf("part %d: %v", i+1, p) } joined.WriteString(p["text"].(string)) } for _, line := range strings.Split(r.reports.String(), "\n") { if len(line) > 8192 { t.Fatalf("a report line of %d bytes; the platform keeps 8192", len(line)) } } // B appears under B's tag, and the meeting as [meeting]. var tagB string if resp, _ := r.b.report(r.sqid, "quality", r.tokB, mustJSON(t, sampleQuality(r.peerA))); resp.StatusCode == http.StatusAccepted { ls := r.reports.lines(t, "calls: quality") tagB = ls[len(ls)-1]["peer"].(string) } text := joined.String() if !strings.Contains(text, "peer peer:"+tagB+": connection connected") || !strings.Contains(text, "full id peer:"+tagB) || !strings.Contains(text, "/m/[meeting]") { t.Fatalf("diagnostics not scrubbed to tags:\n%.400s", text) } if len(text) < 20_000 { t.Fatalf("the diagnostics came back %d bytes, want all of them", len(text)) } r.assertClean(t) // The bounds: a note over a thousand characters, a body over 64 KB. if resp, _ := r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{Note: strings.Repeat("a", 1001), Client: protocol.ReportClient{App: "web"}})); resp.StatusCode != http.StatusBadRequest { t.Errorf("a 1,001-character note: %d, want 400", resp.StatusCode) } if resp, _ := r.a.report(r.sqid, "problem", r.tokA, mustJSON(t, protocol.ProblemReport{Diagnostics: strings.Repeat("a", 65<<10), Client: protocol.ReportClient{App: "web"}})); resp.StatusCode != http.StatusRequestEntityTooLarge { t.Errorf("65 KB of diagnostics: %d, want 413", resp.StatusCode) } }