oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
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.
29func 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.
48func 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.
68func 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.
87type mirror struct {
88 rt *core.Runtime
89 h *core.Handle
90}
91
92func 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
116func (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.
127func (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.
137func 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.
153func 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.
183func 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.
206func 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.
216func 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.
227func 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.
271func 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.
314func 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.
354func 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.
414func 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.
473func 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.
503const 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.
509func 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.
541func 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.
575func 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.
586var 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.
595func 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.
617func 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