| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "context" |
| 7 | "encoding/binary" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "net/url" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "amadan.net/oficina/slides/internal/slides/core" |
| 19 | ) |
| 20 | |
| 21 | // --- helpers --- |
| 22 | |
| 23 | // postBody makes a POST with a body of this test's choosing — the |
| 24 | // binary update endpoint and the JSON presence endpoint both take one, |
| 25 | // and neither is a form. |
| 26 | // |
| 27 | // It sends the same same-origin evidence `post` does: these are |
| 28 | // browser-made requests, inside csrf.Protect like every other write. |
| 29 | func postBody(t *testing.T, a *testApp, target, contentType string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder { |
| 30 | t.Helper() |
| 31 | req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body)) |
| 32 | req.Header.Set("Content-Type", contentType) |
| 33 | req.Header.Set("Origin", testOrigin) |
| 34 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 35 | for _, c := range cookies { |
| 36 | req.AddCookie(c) |
| 37 | } |
| 38 | rec := httptest.NewRecorder() |
| 39 | a.ServeHTTP(rec, req) |
| 40 | return rec |
| 41 | } |
| 42 | |
| 43 | // frames splits an updates response into the updates it carries: a |
| 44 | // 4-byte big-endian length then that many bytes, repeated. A body that |
| 45 | // does not divide cleanly is a failure rather than a short read — the |
| 46 | // framing is the protocol, and a client that guessed would be a client |
| 47 | // that silently loses the last update. |
| 48 | func frames(t *testing.T, body []byte) [][]byte { |
| 49 | t.Helper() |
| 50 | var out [][]byte |
| 51 | for len(body) > 0 { |
| 52 | if len(body) < 4 { |
| 53 | t.Fatalf("%d trailing bytes with no length prefix", len(body)) |
| 54 | } |
| 55 | n := binary.BigEndian.Uint32(body[:4]) |
| 56 | body = body[4:] |
| 57 | if uint32(len(body)) < n { |
| 58 | t.Fatalf("a frame claims %d bytes and %d remain", n, len(body)) |
| 59 | } |
| 60 | out = append(out, body[:n]) |
| 61 | body = body[n:] |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | // getUpdates fetches one cursor's worth of the log and returns the |
| 67 | // frames plus the advisory seq the response carried. |
| 68 | func getUpdates(t *testing.T, a *testApp, path string, since int64, cookies []*http.Cookie) ([][]byte, int64) { |
| 69 | t.Helper() |
| 70 | target := path + "/updates?since=" + strconv.FormatInt(since, 10) |
| 71 | rec := mustGet(t, a, target, http.StatusOK, cookies) |
| 72 | if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" { |
| 73 | t.Fatalf("GET %s: Content-Type = %q, want application/octet-stream", target, ct) |
| 74 | } |
| 75 | seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64) |
| 76 | if err != nil { |
| 77 | t.Fatalf("GET %s: Slides-Seq = %q, not a number", target, rec.Header().Get("Slides-Seq")) |
| 78 | } |
| 79 | return frames(t, rec.Body.Bytes()), seq |
| 80 | } |
| 81 | |
| 82 | // mirror is a browser's copy of a deck: a core document of its own, |
| 83 | // caught up to the server's log, that produces the update bytes a real |
| 84 | // editor would post. It is the only honest way to test this wire — |
| 85 | // bytes made up by hand are not Yjs updates and prove nothing about the |
| 86 | // path they travel. |
| 87 | type mirror struct { |
| 88 | rt *core.Runtime |
| 89 | h *core.Handle |
| 90 | } |
| 91 | |
| 92 | func newMirror(t *testing.T, sqid string, log [][]byte) *mirror { |
| 93 | t.Helper() |
| 94 | ctx := context.Background() |
| 95 | rt, err := core.New(ctx) |
| 96 | if err != nil { |
| 97 | t.Fatalf("core.New: %v", err) |
| 98 | } |
| 99 | // Client id 2: the server writes as 1 (decks.go's serverClientID), |
| 100 | // and two writers sharing one client id is the one thing a CRDT |
| 101 | // cannot forgive. |
| 102 | h, err := rt.NewDoc(ctx, sqid, 2) |
| 103 | if err != nil { |
| 104 | rt.Close(ctx) |
| 105 | t.Fatalf("core.NewDoc: %v", err) |
| 106 | } |
| 107 | m := &mirror{rt: rt, h: h} |
| 108 | t.Cleanup(func() { |
| 109 | h.Close(ctx) |
| 110 | rt.Close(ctx) |
| 111 | }) |
| 112 | m.apply(t, log) |
| 113 | return m |
| 114 | } |
| 115 | |
| 116 | func (m *mirror) apply(t *testing.T, log [][]byte) { |
| 117 | t.Helper() |
| 118 | for i, u := range log { |
| 119 | if err := m.h.Apply(context.Background(), u); err != nil { |
| 120 | t.Fatalf("applying frame %d to the mirror: %v", i, err) |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // ops runs a batch against the mirror and returns the update it |
| 126 | // produced — exactly what the browser's own ops module will POST. |
| 127 | func (m *mirror) ops(t *testing.T, batch string) []byte { |
| 128 | t.Helper() |
| 129 | update, err := m.h.Ops(context.Background(), []byte(batch)) |
| 130 | if err != nil { |
| 131 | t.Fatalf("mirror ops %s: %v", batch, err) |
| 132 | } |
| 133 | return update |
| 134 | } |
| 135 | |
| 136 | // postUpdate posts one update and insists on the 204 and the seq. |
| 137 | func postUpdate(t *testing.T, a *testApp, path string, update []byte, cookies []*http.Cookie) int64 { |
| 138 | t.Helper() |
| 139 | rec := postBody(t, a, path+"/updates", "application/octet-stream", update, cookies) |
| 140 | if rec.Code != http.StatusNoContent { |
| 141 | t.Fatalf("POST %s/updates: status %d, want 204\n%s", path, rec.Code, rec.Body) |
| 142 | } |
| 143 | seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64) |
| 144 | if err != nil { |
| 145 | t.Fatalf("POST %s/updates: Slides-Seq = %q, not a number", path, rec.Header().Get("Slides-Seq")) |
| 146 | } |
| 147 | return seq |
| 148 | } |
| 149 | |
| 150 | // scan opens the deck's SSE stream against a real server and returns a |
| 151 | // reader over it. The request's context is the timeout, so a stream |
| 152 | // that never speaks fails the test rather than hanging it. |
| 153 | func scan(t *testing.T, a *testApp, path string, cookies []*http.Cookie) *bufio.Reader { |
| 154 | t.Helper() |
| 155 | srv := httptest.NewServer(a) |
| 156 | t.Cleanup(srv.Close) |
| 157 | |
| 158 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 159 | t.Cleanup(cancel) |
| 160 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path+"/stream", nil) |
| 161 | if err != nil { |
| 162 | t.Fatalf("build the stream request: %v", err) |
| 163 | } |
| 164 | for _, c := range cookies { |
| 165 | req.AddCookie(c) |
| 166 | } |
| 167 | resp, err := http.DefaultClient.Do(req) |
| 168 | if err != nil { |
| 169 | t.Fatalf("open the stream: %v", err) |
| 170 | } |
| 171 | t.Cleanup(func() { resp.Body.Close() }) |
| 172 | if resp.StatusCode != http.StatusOK { |
| 173 | t.Fatalf("GET %s/stream: status %d, want 200", path, resp.StatusCode) |
| 174 | } |
| 175 | if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { |
| 176 | t.Fatalf("the stream's Content-Type is %q, want text/event-stream", got) |
| 177 | } |
| 178 | return bufio.NewReader(resp.Body) |
| 179 | } |
| 180 | |
| 181 | // nextEvent reads events until one of the named kind arrives, |
| 182 | // returning its data line. Pings and the other kind are skipped. |
| 183 | func nextEvent(t *testing.T, r *bufio.Reader, want string) string { |
| 184 | t.Helper() |
| 185 | var kind string |
| 186 | for { |
| 187 | line, err := r.ReadString('\n') |
| 188 | if err != nil { |
| 189 | t.Fatalf("reading the stream for a %q event: %v", want, err) |
| 190 | } |
| 191 | line = strings.TrimRight(line, "\r\n") |
| 192 | switch { |
| 193 | case strings.HasPrefix(line, "event: "): |
| 194 | kind = strings.TrimPrefix(line, "event: ") |
| 195 | case strings.HasPrefix(line, "data: "): |
| 196 | if kind == want { |
| 197 | return strings.TrimPrefix(line, "data: ") |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // scalar reads one value out of the app's own database, for the facts |
| 204 | // that have no screen: which actor a row recorded, which id a person |
| 205 | // got. |
| 206 | func scalar(t *testing.T, a *testApp, query string, args ...any) string { |
| 207 | t.Helper() |
| 208 | var out string |
| 209 | if err := a.db.G.Raw(query, args...).Scan(&out).Error; err != nil { |
| 210 | t.Fatalf("query %q: %v", query, err) |
| 211 | } |
| 212 | return out |
| 213 | } |
| 214 | |
| 215 | // sqidOf turns the /d/{sqid} path createDeck returns into the sqid. |
| 216 | func sqidOf(path string) string { return strings.TrimPrefix(path, "/d/") } |
| 217 | |
| 218 | // --- the wire --- |
| 219 | |
| 220 | // TestUpdatesRoundTripThroughTheWire: a browser's own update, made by |
| 221 | // the same core the server runs, posted, read back framed, and visible |
| 222 | // in the deck the server renders. |
| 223 | // |
| 224 | // The last step is the point. A test that posted bytes and read the |
| 225 | // same bytes back would pass over a table that stored them and a |
| 226 | // document that never saw them. |
| 227 | func TestUpdatesRoundTripThroughTheWire(t *testing.T) { |
| 228 | a := newApp(t) |
| 229 | cookies := signedIn(t, a, allowedAddress) |
| 230 | path := createDeck(t, a, cookies, "Wire") |
| 231 | |
| 232 | // A new deck is one update: its opening batch (decks.go). |
| 233 | log, seq := getUpdates(t, a, path, 0, cookies) |
| 234 | if len(log) != 1 { |
| 235 | t.Fatalf("a new deck's log has %d frames, want 1", len(log)) |
| 236 | } |
| 237 | if seq != 1 { |
| 238 | t.Fatalf("Slides-Seq on a new deck is %d, want 1", seq) |
| 239 | } |
| 240 | |
| 241 | m := newMirror(t, sqidOf(path), log) |
| 242 | update := m.ops(t, `[{"op":"add_slide","id":"wiretestslide","layout":"flow","template":"title"}]`) |
| 243 | |
| 244 | if got := postUpdate(t, a, path, update, cookies); got != 2 { |
| 245 | t.Fatalf("POST answered Slides-Seq %d, want 2", got) |
| 246 | } |
| 247 | |
| 248 | // Read back from the cursor the client held: exactly the one |
| 249 | // update it did not already have, byte for byte. |
| 250 | tail, seq := getUpdates(t, a, path, 1, cookies) |
| 251 | if len(tail) != 1 { |
| 252 | t.Fatalf("since=1 returned %d frames, want 1", len(tail)) |
| 253 | } |
| 254 | if !bytes.Equal(tail[0], update) { |
| 255 | t.Errorf("the frame read back is not the update posted:\n got %x\nwant %x", tail[0], update) |
| 256 | } |
| 257 | if seq != 2 { |
| 258 | t.Errorf("Slides-Seq is %d, want 2", seq) |
| 259 | } |
| 260 | |
| 261 | // And the server's own document really has the slide. |
| 262 | rec := mustGet(t, a, path+"/slides/wiretestslide.html", http.StatusOK, cookies) |
| 263 | if !strings.Contains(rec.Body.String(), "<section slides-slide") { |
| 264 | t.Errorf("the slide the update added does not render:\n%s", rec.Body) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // TestUpdatesSinceCursorIsExclusive: ?since=N means "everything after |
| 269 | // N", never "everything from N". Off by one here and every client |
| 270 | // re-applies the update it already holds on every reconnect. |
| 271 | func TestUpdatesSinceCursorIsExclusive(t *testing.T) { |
| 272 | a := newApp(t) |
| 273 | cookies := signedIn(t, a, allowedAddress) |
| 274 | path := createDeck(t, a, cookies, "Cursors") |
| 275 | |
| 276 | log, _ := getUpdates(t, a, path, 0, cookies) |
| 277 | m := newMirror(t, sqidOf(path), log) |
| 278 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Second"}]`), cookies) |
| 279 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Third"}]`), cookies) |
| 280 | |
| 281 | for _, tc := range []struct { |
| 282 | since int64 |
| 283 | frames int |
| 284 | seq int64 |
| 285 | }{ |
| 286 | {since: 0, frames: 3, seq: 3}, |
| 287 | {since: 1, frames: 2, seq: 3}, |
| 288 | {since: 2, frames: 1, seq: 3}, |
| 289 | // Nothing after the last one, and the header says where the |
| 290 | // client already is rather than dropping to zero. |
| 291 | {since: 3, frames: 0, seq: 3}, |
| 292 | // A cursor past the end is a client that is ahead of this |
| 293 | // instance, not an error: it gets nothing and keeps its place. |
| 294 | {since: 99, frames: 0, seq: 99}, |
| 295 | } { |
| 296 | got, seq := getUpdates(t, a, path, tc.since, cookies) |
| 297 | if len(got) != tc.frames { |
| 298 | t.Errorf("since=%d returned %d frames, want %d", tc.since, len(got), tc.frames) |
| 299 | } |
| 300 | if seq != tc.seq { |
| 301 | t.Errorf("since=%d answered Slides-Seq %d, want %d", tc.since, seq, tc.seq) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // A cursor that is not a number is a client bug, and it is told so |
| 306 | // rather than being handed the whole log as if it had asked. |
| 307 | mustGet(t, a, path+"/updates?since=banana", http.StatusBadRequest, cookies) |
| 308 | mustGet(t, a, path+"/updates?since=-1", http.StatusBadRequest, cookies) |
| 309 | } |
| 310 | |
| 311 | // TestStreamAnnouncesSeqOnConnectAndAfterPost: the stream says where |
| 312 | // the deck is the moment it opens — which is what heals a tab whose |
| 313 | // cursor fell behind — and says so again every time an update lands. |
| 314 | func TestStreamAnnouncesSeqOnConnectAndAfterPost(t *testing.T) { |
| 315 | a := newApp(t) |
| 316 | cookies := signedIn(t, a, allowedAddress) |
| 317 | path := createDeck(t, a, cookies, "Stream") |
| 318 | |
| 319 | log, _ := getUpdates(t, a, path, 0, cookies) |
| 320 | m := newMirror(t, sqidOf(path), log) |
| 321 | |
| 322 | events := scan(t, a, path, cookies) |
| 323 | if data := nextEvent(t, events, "update"); data != "1" { |
| 324 | t.Errorf("the connect event is %q, want the deck's current seq 1", data) |
| 325 | } |
| 326 | // Nobody has posted presence, so the connect set is empty — an |
| 327 | // event all the same, so a tab knows it is alone rather than |
| 328 | // waiting to find out. |
| 329 | if data := nextEvent(t, events, "presence"); data != "[]" { |
| 330 | t.Errorf("the connect presence is %q, want an empty set", data) |
| 331 | } |
| 332 | |
| 333 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Moved"}]`), cookies) |
| 334 | if data := nextEvent(t, events, "update"); data != "2" { |
| 335 | t.Errorf("after a POST the stream said %q, want 2", data) |
| 336 | } |
| 337 | |
| 338 | // A write through any other path says so too: the stream is the |
| 339 | // deck's, not the update endpoint's. |
| 340 | rec := post(t, a, path+"/settings", url.Values{ |
| 341 | "title": {"Renamed"}, "theme": {"ink"}, "rung": {"private"}, |
| 342 | }, cookies...) |
| 343 | if rec.Code != http.StatusSeeOther { |
| 344 | t.Fatalf("POST %s/settings: status %d, want 303", path, rec.Code) |
| 345 | } |
| 346 | if data := nextEvent(t, events, "update"); data != "3" { |
| 347 | t.Errorf("after a settings save the stream said %q, want 3", data) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // TestPresencePostReachesTheStream: a heartbeat becomes the whole set, |
| 352 | // stamped with the name and colour of the person whose SESSION it came |
| 353 | // from rather than whatever the body claimed. |
| 354 | func TestPresencePostReachesTheStream(t *testing.T) { |
| 355 | a := newSharedApp(t) |
| 356 | cookies := signedIn(t, a, allowedAddress) |
| 357 | path := createDeck(t, a, cookies, "Presence") |
| 358 | |
| 359 | events := scan(t, a, path, cookies) |
| 360 | nextEvent(t, events, "presence") |
| 361 | |
| 362 | body := `{"client":"tab-1","slide":"s1","block":"b1","name":"Somebody Else","colour":"#ff0000"}` |
| 363 | rec := postBody(t, a, path+"/presence", "application/json", []byte(body), cookies) |
| 364 | if rec.Code != http.StatusNoContent { |
| 365 | t.Fatalf("POST %s/presence: status %d, want 204\n%s", path, rec.Code, rec.Body) |
| 366 | } |
| 367 | |
| 368 | data := nextEvent(t, events, "presence") |
| 369 | for _, want := range []string{`"client":"tab-1"`, `"slide":"s1"`, `"block":"b1"`} { |
| 370 | if !strings.Contains(data, want) { |
| 371 | t.Errorf("the presence event %q does not carry %q", data, want) |
| 372 | } |
| 373 | } |
| 374 | // The name and the colour are the users row's, exactly — the two |
| 375 | // things the browser draws a person with (an avatar on the strip, |
| 376 | // an outline on their block) and the two it is never allowed to |
| 377 | // choose for itself. Nameless, a person is their address, which is |
| 378 | // what every other surface calls them (decks.go's shellFor). |
| 379 | colour := scalar(t, a, "SELECT colour FROM users WHERE email = ?", allowedAddress) |
| 380 | if colour == "" || !strings.Contains(data, `"colour":"`+colour+`"`) { |
| 381 | t.Errorf("the presence event %q does not carry the person's own colour %q", data, colour) |
| 382 | } |
| 383 | if !strings.Contains(data, `"name":"`+allowedAddress+`"`) { |
| 384 | t.Errorf("the presence event %q does not name the person by their address", data) |
| 385 | } |
| 386 | if strings.Contains(data, "Somebody Else") || strings.Contains(data, "#ff0000") { |
| 387 | t.Errorf("the body's own name or colour was trusted:\n%s", data) |
| 388 | } |
| 389 | |
| 390 | // Once they have a name, that is what their colleagues see. |
| 391 | if err := a.db.G.Exec("UPDATE users SET name = ? WHERE email = ?", "Ana Ruiz", allowedAddress).Error; err != nil { |
| 392 | t.Fatalf("name the user: %v", err) |
| 393 | } |
| 394 | rec = postBody(t, a, path+"/presence", "application/json", []byte(body), cookies) |
| 395 | if rec.Code != http.StatusNoContent { |
| 396 | t.Fatalf("POST %s/presence: status %d, want 204", path, rec.Code) |
| 397 | } |
| 398 | data = nextEvent(t, events, "presence") |
| 399 | if !strings.Contains(data, `"name":"Ana Ruiz"`) || strings.Contains(data, allowedAddress) { |
| 400 | t.Errorf("the presence event %q does not carry the person's own name", data) |
| 401 | } |
| 402 | |
| 403 | // A heartbeat with no client to key on is a client bug, not a |
| 404 | // person: refused rather than filed under the empty string. |
| 405 | rec = postBody(t, a, path+"/presence", "application/json", []byte(`{"slide":"s1"}`), cookies) |
| 406 | if rec.Code != http.StatusUnprocessableEntity { |
| 407 | t.Errorf("POST presence with no client: status %d, want 422", rec.Code) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // TestUpdatesRequireMembershipAndRecordActor: every live-sync route is |
| 412 | // behind the same visibility seam every other deck route is, answering |
| 413 | // 404 rather than 403 — and every write it accepts says who made it. |
| 414 | func TestUpdatesRequireMembershipAndRecordActor(t *testing.T) { |
| 415 | a := newSharedApp(t) |
| 416 | mine := signedIn(t, a, allowedAddress) |
| 417 | theirs := signedIn(t, a, otherAddress) |
| 418 | path := createDeck(t, a, mine, "Members only") |
| 419 | |
| 420 | log, _ := getUpdates(t, a, path, 0, mine) |
| 421 | m := newMirror(t, sqidOf(path), log) |
| 422 | update := m.ops(t, `[{"op":"set_meta","title":"Mine"}]`) |
| 423 | |
| 424 | // A stranger cannot read the log, write to it, watch it, or say |
| 425 | // they are in the deck. |
| 426 | mustGet(t, a, path+"/updates?since=0", http.StatusNotFound, theirs) |
| 427 | mustGet(t, a, path+"/stream", http.StatusNotFound, theirs) |
| 428 | mustGet(t, a, path+"/slides/whatever.html", http.StatusNotFound, theirs) |
| 429 | if rec := postBody(t, a, path+"/updates", "application/octet-stream", update, theirs); rec.Code != http.StatusNotFound { |
| 430 | t.Errorf("a stranger's POST /updates: status %d, want 404", rec.Code) |
| 431 | } |
| 432 | if rec := postBody(t, a, path+"/presence", "application/json", []byte(`{"client":"x"}`), theirs); rec.Code != http.StatusNotFound { |
| 433 | t.Errorf("a stranger's POST /presence: status %d, want 404", rec.Code) |
| 434 | } |
| 435 | // Signed out is a redirect to sign in, never the log. |
| 436 | mustGet(t, a, path+"/updates?since=0", http.StatusSeeOther, nil) |
| 437 | |
| 438 | // The owner's own write records them. |
| 439 | postUpdate(t, a, path, update, mine) |
| 440 | id := scalar(t, a, "SELECT id FROM users WHERE email = ?", allowedAddress) |
| 441 | actor := scalar(t, a, |
| 442 | "SELECT actor FROM deck_updates WHERE deck_id = (SELECT id FROM decks WHERE sqid = ?) ORDER BY seq DESC LIMIT 1", |
| 443 | sqidOf(path)) |
| 444 | if want := "person:" + id; actor != want { |
| 445 | t.Errorf("the update recorded actor %q, want %q", actor, want) |
| 446 | } |
| 447 | |
| 448 | // A team member can edit: the live routes are visibleDecks' whole |
| 449 | // audience, not the owner alone. |
| 450 | if rec := post(t, a, path+"/settings", url.Values{ |
| 451 | "title": {"Members only"}, "theme": {"ink"}, "rung": {"team"}, |
| 452 | }, mine...); rec.Code != http.StatusSeeOther { |
| 453 | t.Fatalf("raising the rung: status %d, want 303", rec.Code) |
| 454 | } |
| 455 | shared, _ := getUpdates(t, a, path, 0, theirs) |
| 456 | if len(shared) == 0 { |
| 457 | t.Fatal("a team member cannot read the deck's log") |
| 458 | } |
| 459 | m.apply(t, shared[len(shared)-1:]) |
| 460 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"Ours"}]`), theirs) |
| 461 | theirID := scalar(t, a, "SELECT id FROM users WHERE email = ?", otherAddress) |
| 462 | actor = scalar(t, a, |
| 463 | "SELECT actor FROM deck_updates WHERE deck_id = (SELECT id FROM decks WHERE sqid = ?) ORDER BY seq DESC LIMIT 1", |
| 464 | sqidOf(path)) |
| 465 | if want := "person:" + theirID; actor != want { |
| 466 | t.Errorf("the team member's update recorded actor %q, want %q", actor, want) |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // TestRefusedUpdateIs422: bytes the core will not have are the client's |
| 471 | // problem, and the answer says so without pretending anything was |
| 472 | // stored. |
| 473 | func TestRefusedUpdateIs422(t *testing.T) { |
| 474 | a := newApp(t) |
| 475 | cookies := signedIn(t, a, allowedAddress) |
| 476 | path := createDeck(t, a, cookies, "Refusals") |
| 477 | |
| 478 | rec := postBody(t, a, path+"/updates", "application/octet-stream", |
| 479 | []byte("this is not a Yjs update"), cookies) |
| 480 | if rec.Code != http.StatusUnprocessableEntity { |
| 481 | t.Fatalf("POST garbage: status %d, want 422\n%s", rec.Code, rec.Body) |
| 482 | } |
| 483 | |
| 484 | // Nothing was written: the log is still the one opening update. |
| 485 | log, seq := getUpdates(t, a, path, 0, cookies) |
| 486 | if len(log) != 1 || seq != 1 { |
| 487 | t.Errorf("after a refusal the log has %d frames at seq %d, want 1 at 1", len(log), seq) |
| 488 | } |
| 489 | |
| 490 | // And an update larger than the cap is refused before it is read |
| 491 | // into memory at all. |
| 492 | rec = postBody(t, a, path+"/updates", "application/octet-stream", |
| 493 | make([]byte, maxUpdateBytesForTest+1), cookies) |
| 494 | if rec.Code != http.StatusRequestEntityTooLarge { |
| 495 | t.Errorf("POST an oversized update: status %d, want 413", rec.Code) |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // maxUpdateBytesForTest is the app's own cap, restated here rather than |
| 500 | // exported: the number is part of the wire contract (the plan's "limit |
| 501 | // 1 MiB"), so a test that read it from the app could never notice it |
| 502 | // changing. |
| 503 | const maxUpdateBytesForTest = 1 << 20 |
| 504 | |
| 505 | // TestSlideHTMLTwinRendersOneSection: the editor's stage and strip |
| 506 | // fetch slide HTML with a session cookie rather than a bearer token, |
| 507 | // and get the same section the API serves — the section alone, never a |
| 508 | // page. |
| 509 | func TestSlideHTMLTwinRendersOneSection(t *testing.T) { |
| 510 | a := newApp(t) |
| 511 | cookies := signedIn(t, a, allowedAddress) |
| 512 | path := createDeck(t, a, cookies, "Stage") |
| 513 | |
| 514 | log, _ := getUpdates(t, a, path, 0, cookies) |
| 515 | m := newMirror(t, sqidOf(path), log) |
| 516 | postUpdate(t, a, path, m.ops(t, `[{"op":"add_slide","id":"stageslide","layout":"flow","template":"title"}]`), cookies) |
| 517 | |
| 518 | for _, vp := range []string{"", "desktop", "phone", "thumb"} { |
| 519 | target := path + "/slides/stageslide.html" |
| 520 | if vp != "" { |
| 521 | target += "?vp=" + vp |
| 522 | } |
| 523 | body := mustGet(t, a, target, http.StatusOK, cookies).Body.String() |
| 524 | if !strings.HasPrefix(strings.TrimSpace(body), "<section slides-slide") { |
| 525 | t.Errorf("vp=%q is not a bare slide section:\n%s", vp, body) |
| 526 | } |
| 527 | if strings.Contains(body, "<html") { |
| 528 | t.Errorf("vp=%q carries a whole page:\n%s", vp, body) |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | mustGet(t, a, path+"/slides/nosuchslide.html", http.StatusNotFound, cookies) |
| 533 | mustGet(t, a, path+"/slides/stageslide", http.StatusNotFound, cookies) |
| 534 | } |
| 535 | |
| 536 | // TestPostedMetaReachesTheDeckRow: the editor's deck section writes |
| 537 | // meta.title and meta.theme over the wire, and the deck list reads the |
| 538 | // row — so the row follows the document on this path as it follows a |
| 539 | // set_meta on the API's (metaRowUpdates). An empty title and a theme |
| 540 | // this build does not have leave the row alone. |
| 541 | func TestPostedMetaReachesTheDeckRow(t *testing.T) { |
| 542 | a := newApp(t) |
| 543 | cookies := signedIn(t, a, allowedAddress) |
| 544 | path := createDeck(t, a, cookies, "Before") |
| 545 | sqid := sqidOf(path) |
| 546 | |
| 547 | history, _ := getUpdates(t, a, path, 0, cookies) |
| 548 | m := newMirror(t, sqid, history) |
| 549 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"After","theme":"ink"}]`), cookies) |
| 550 | if got := scalar(t, a, "SELECT title FROM decks WHERE sqid = ?", sqid); got != "After" { |
| 551 | t.Errorf("after a posted set_meta the row's title is %q, want After", got) |
| 552 | } |
| 553 | if got := scalar(t, a, "SELECT theme FROM decks WHERE sqid = ?", sqid); got != "ink" { |
| 554 | t.Errorf("after a posted set_meta the row's theme is %q, want ink", got) |
| 555 | } |
| 556 | |
| 557 | postUpdate(t, a, path, m.ops(t, `[{"op":"set_meta","title":"","theme":"nosuchtheme"}]`), cookies) |
| 558 | if got := scalar(t, a, "SELECT title FROM decks WHERE sqid = ?", sqid); got != "After" { |
| 559 | t.Errorf("an empty title reached the row: %q", got) |
| 560 | } |
| 561 | if got := scalar(t, a, "SELECT theme FROM decks WHERE sqid = ?", sqid); got != "ink" { |
| 562 | t.Errorf("an unknown theme reached the row: %q", got) |
| 563 | } |
| 564 | list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() |
| 565 | if !strings.Contains(list, "After") { |
| 566 | t.Errorf("the deck list does not show the posted title:\n%s", list) |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | // --- poison --- |
| 571 | |
| 572 | // poisonUpdate reads one of the updates fixtures/poison.mjs writes: |
| 573 | // a shape no op can produce, built directly with yjs, as real Yjs v1 |
| 574 | // bytes. Committed, because the Go suite has no yjs of its own. |
| 575 | func poisonUpdate(t *testing.T, name string) []byte { |
| 576 | t.Helper() |
| 577 | b, err := os.ReadFile(filepath.Join("testdata", "poison", name+".yjs")) |
| 578 | if err != nil { |
| 579 | t.Fatalf("read the %s poison update (run node fixtures/poison.mjs): %v", name, err) |
| 580 | } |
| 581 | return b |
| 582 | } |
| 583 | |
| 584 | // poisonSlides is the slide each poison update adds, for the shapes |
| 585 | // that add one — the slide that has to render afterwards. |
| 586 | var poisonSlides = map[string]string{ |
| 587 | "box": "poisonboxsld", |
| 588 | "background": "poisonbgsld1", |
| 589 | "magnitude": "poisonmagsld", |
| 590 | } |
| 591 | |
| 592 | // stillRenders is every surface that opens the deck: the viewer, the |
| 593 | // editor and a slide fetched for the stage. A poison update that took |
| 594 | // one down took them all down, because they share openDeck. |
| 595 | func stillRenders(t *testing.T, a *testApp, path, name string, cookies []*http.Cookie) { |
| 596 | t.Helper() |
| 597 | mustGet(t, a, path, http.StatusOK, cookies) |
| 598 | mustGet(t, a, path+"/edit", http.StatusOK, cookies) |
| 599 | if slide, ok := poisonSlides[name]; ok { |
| 600 | mustGet(t, a, path+"/slides/"+slide+".html", http.StatusOK, cookies) |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | // TestPoisonUpdateDoesNotBrickTheDeck: an update carrying a shape the |
| 605 | // server's typed reader cannot hold must never leave a deck no surface |
| 606 | // can open. Two layers, each proved on its own: |
| 607 | // |
| 608 | // - the view (core/src/view.rs) rebuilds every fixed-shape leaf from |
| 609 | // the fields that have the right type — so box.x = "a", |
| 610 | // background.kind = 5, fonts.body = [] and an asset that is a |
| 611 | // string are accepted, stored, and render; and a deck that holds |
| 612 | // such data by some other road than the gate renders too; |
| 613 | // - the gate (live.go's appendUpdate) parses the view AFTER applying |
| 614 | // the bytes and BEFORE storing them, so a shape the view passes and |
| 615 | // the reader still refuses (a phone order of 1e300 — a number, but |
| 616 | // not one an int can hold) is a 422 and nothing is stored. |
| 617 | func TestPoisonUpdateDoesNotBrickTheDeck(t *testing.T) { |
| 618 | a := newApp(t) |
| 619 | cookies := signedIn(t, a, allowedAddress) |
| 620 | |
| 621 | for _, name := range []string{"box", "background", "fonts", "assets"} { |
| 622 | t.Run(name, func(t *testing.T) { |
| 623 | path := createDeck(t, a, cookies, "Poison "+name) |
| 624 | rec := postBody(t, a, path+"/updates", "application/octet-stream", poisonUpdate(t, name), cookies) |
| 625 | if rec.Code != http.StatusNoContent { |
| 626 | t.Fatalf("POST %s/updates with the %s shape: status %d, want 204 (the view tolerates it)\n%s", path, name, rec.Code, rec.Body) |
| 627 | } |
| 628 | stillRenders(t, a, path, name, cookies) |
| 629 | }) |
| 630 | t.Run(name+"/bypassing-the-gate", func(t *testing.T) { |
| 631 | // Written straight into the log, as a row that reached the |
| 632 | // table by some road other than appendUpdate would have. |
| 633 | path := createDeck(t, a, cookies, "Stored poison "+name) |
| 634 | sqid := sqidOf(path) |
| 635 | seq, _ := strconv.ParseInt(scalar(t, a, "SELECT seq FROM decks WHERE sqid = ?", sqid), 10, 64) |
| 636 | if err := a.db.G.Exec( |
| 637 | "INSERT INTO deck_updates (deck_id, seq, actor, bytes, created_at) VALUES ((SELECT id FROM decks WHERE sqid = ?), ?, ?, ?, ?)", |
| 638 | sqid, seq+1, "person:0", poisonUpdate(t, name), time.Now().UTC()).Error; err != nil { |
| 639 | t.Fatalf("insert the poison row: %v", err) |
| 640 | } |
| 641 | if err := a.db.G.Exec("UPDATE decks SET seq = ? WHERE sqid = ?", seq+1, sqid).Error; err != nil { |
| 642 | t.Fatalf("bump the seq: %v", err) |
| 643 | } |
| 644 | stillRenders(t, a, path, name, cookies) |
| 645 | }) |
| 646 | } |
| 647 | |
| 648 | t.Run("magnitude", func(t *testing.T) { |
| 649 | path := createDeck(t, a, cookies, "Poison magnitude") |
| 650 | before, seqBefore := getUpdates(t, a, path, 0, cookies) |
| 651 | rec := postBody(t, a, path+"/updates", "application/octet-stream", poisonUpdate(t, "magnitude"), cookies) |
| 652 | if rec.Code != http.StatusUnprocessableEntity { |
| 653 | t.Fatalf("POST %s/updates with a shape the reader cannot hold: status %d, want 422\n%s", path, rec.Code, rec.Body) |
| 654 | } |
| 655 | after, seqAfter := getUpdates(t, a, path, 0, cookies) |
| 656 | if len(after) != len(before) || seqAfter != seqBefore { |
| 657 | t.Errorf("a refused update was stored: %d frames at seq %d, was %d at %d", len(after), seqAfter, len(before), seqBefore) |
| 658 | } |
| 659 | if got := scalar(t, a, "SELECT seq FROM decks WHERE sqid = ?", sqidOf(path)); got != strconv.FormatInt(seqBefore, 10) { |
| 660 | t.Errorf("the row's seq moved to %s on a refused update", got) |
| 661 | } |
| 662 | stillRenders(t, a, path, "", cookies) |
| 663 | }) |
| 664 | } |
| 665 | |