| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "io" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // A grant is checked when the stream opens and never again, so a |
| 12 | // revoked guest keeps receiving cells on the connection they already |
| 13 | // hold — for up to maxStreamLife, and every edit resets the idle timer |
| 14 | // that would otherwise end it. |
| 15 | // |
| 16 | // Found by an independent review (Codex, 2026-09-05). It is the quiet |
| 17 | // shape of leak the stream gate was written to prevent: the gate was |
| 18 | // real, and it ran once. |
| 19 | func TestARevokedGuestStopsReceivingCells(t *testing.T) { |
| 20 | app, cookies := signIn(t) |
| 21 | s, cells := newSheet(t, app, cookies, "Budget") |
| 22 | ada := guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 23 | adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| 24 | |
| 25 | srv := liveServer(t, app) |
| 26 | resp := openStream(t, srv, s, adaCookies) |
| 27 | waitForSubscriber(t, app, s.TabID) |
| 28 | |
| 29 | // Still granted: this edit must arrive. Without it a later silence |
| 30 | // would prove nothing — the stream might simply never have worked. |
| 31 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"before"}`) |
| 32 | if line := readEventWithin(t, resp.Body, 5*time.Second); !strings.Contains(line, "before") { |
| 33 | t.Fatalf("the guest did not receive the first edit: %q", line) |
| 34 | } |
| 35 | |
| 36 | if err := app.revoke(s.ID, ada.ID); err != nil { |
| 37 | t.Fatalf("revoke: %v", err) |
| 38 | } |
| 39 | postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"CONFIDENTIAL"}`) |
| 40 | |
| 41 | if got := readDataOrSilence(resp.Body, 3*time.Second); strings.Contains(got, "CONFIDENTIAL") { |
| 42 | t.Fatalf("a revoked guest received a cell written after the revoke: %q", got) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // readDataOrSilence returns the next SSE data line, or "" if the stream |
| 47 | // says nothing in time. |
| 48 | // |
| 49 | // Unlike readEventWithin it does not fail on silence, because silence |
| 50 | // is the passing case here. It reads in a goroutine and abandons it on |
| 51 | // timeout rather than blocking the test. |
| 52 | func readDataOrSilence(body io.Reader, d time.Duration) string { |
| 53 | ch := make(chan string, 1) |
| 54 | go func() { |
| 55 | sc := bufio.NewScanner(body) |
| 56 | for sc.Scan() { |
| 57 | if strings.HasPrefix(sc.Text(), "data: ") { |
| 58 | ch <- sc.Text() |
| 59 | return |
| 60 | } |
| 61 | } |
| 62 | ch <- "" |
| 63 | }() |
| 64 | select { |
| 65 | case line := <-ch: |
| 66 | return line |
| 67 | case <-time.After(d): |
| 68 | return "" |
| 69 | } |
| 70 | } |
| 71 | |