package slidestest import ( "bufio" "bytes" "context" "encoding/binary" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "time" "amadan.net/oficina/slides/internal/slides/core" ) // --- helpers --- // postBody makes a POST with a body of this test's choosing — the // binary update endpoint and the JSON presence endpoint both take one, // and neither is a form. // // It sends the same same-origin evidence `post` does: these are // browser-made requests, inside csrf.Protect like every other write. func postBody(t *testing.T, a *testApp, target, contentType string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body)) req.Header.Set("Content-Type", contentType) req.Header.Set("Origin", testOrigin) req.Header.Set("Sec-Fetch-Site", "same-origin") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() a.ServeHTTP(rec, req) return rec } // frames splits an updates response into the updates it carries: a // 4-byte big-endian length then that many bytes, repeated. A body that // does not divide cleanly is a failure rather than a short read — the // framing is the protocol, and a client that guessed would be a client // that silently loses the last update. func frames(t *testing.T, body []byte) [][]byte { t.Helper() var out [][]byte for len(body) > 0 { if len(body) < 4 { t.Fatalf("%d trailing bytes with no length prefix", len(body)) } n := binary.BigEndian.Uint32(body[:4]) body = body[4:] if uint32(len(body)) < n { t.Fatalf("a frame claims %d bytes and %d remain", n, len(body)) } out = append(out, body[:n]) body = body[n:] } return out } // getUpdates fetches one cursor's worth of the log and returns the // frames plus the advisory seq the response carried. func getUpdates(t *testing.T, a *testApp, path string, since int64, cookies []*http.Cookie) ([][]byte, int64) { t.Helper() target := path + "/updates?since=" + strconv.FormatInt(since, 10) rec := mustGet(t, a, target, http.StatusOK, cookies) if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" { t.Fatalf("GET %s: Content-Type = %q, want application/octet-stream", target, ct) } seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64) if err != nil { t.Fatalf("GET %s: Slides-Seq = %q, not a number", target, rec.Header().Get("Slides-Seq")) } return frames(t, rec.Body.Bytes()), seq } // mirror is a browser's copy of a deck: a core document of its own, // caught up to the server's log, that produces the update bytes a real // editor would post. It is the only honest way to test this wire — // bytes made up by hand are not Yjs updates and prove nothing about the // path they travel. type mirror struct { rt *core.Runtime h *core.Handle } func newMirror(t *testing.T, sqid string, log [][]byte) *mirror { t.Helper() ctx := context.Background() rt, err := core.New(ctx) if err != nil { t.Fatalf("core.New: %v", err) } // Client id 2: the server writes as 1 (decks.go's serverClientID), // and two writers sharing one client id is the one thing a CRDT // cannot forgive. h, err := rt.NewDoc(ctx, sqid, 2) if err != nil { rt.Close(ctx) t.Fatalf("core.NewDoc: %v", err) } m := &mirror{rt: rt, h: h} t.Cleanup(func() { h.Close(ctx) rt.Close(ctx) }) m.apply(t, log) return m } func (m *mirror) apply(t *testing.T, log [][]byte) { t.Helper() for i, u := range log { if err := m.h.Apply(context.Background(), u); err != nil { t.Fatalf("applying frame %d to the mirror: %v", i, err) } } } // ops runs a batch against the mirror and returns the update it // produced — exactly what the browser's own ops module will POST. func (m *mirror) ops(t *testing.T, batch string) []byte { t.Helper() update, err := m.h.Ops(context.Background(), []byte(batch)) if err != nil { t.Fatalf("mirror ops %s: %v", batch, err) } return update } // postUpdate posts one update and insists on the 204 and the seq. func postUpdate(t *testing.T, a *testApp, path string, update []byte, cookies []*http.Cookie) int64 { t.Helper() rec := postBody(t, a, path+"/updates", "application/octet-stream", update, cookies) if rec.Code != http.StatusNoContent { t.Fatalf("POST %s/updates: status %d, want 204\n%s", path, rec.Code, rec.Body) } seq, err := strconv.ParseInt(rec.Header().Get("Slides-Seq"), 10, 64) if err != nil { t.Fatalf("POST %s/updates: Slides-Seq = %q, not a number", path, rec.Header().Get("Slides-Seq")) } return seq } // scan opens the deck's SSE stream against a real server and returns a // reader over it. The request's context is the timeout, so a stream // that never speaks fails the test rather than hanging it. func scan(t *testing.T, a *testApp, path string, cookies []*http.Cookie) *bufio.Reader { t.Helper() srv := httptest.NewServer(a) t.Cleanup(srv.Close) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path+"/stream", nil) if err != nil { t.Fatalf("build the stream request: %v", err) } for _, c := range cookies { req.AddCookie(c) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("open the stream: %v", err) } t.Cleanup(func() { resp.Body.Close() }) if resp.StatusCode != http.StatusOK { t.Fatalf("GET %s/stream: status %d, want 200", path, resp.StatusCode) } if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { t.Fatalf("the stream's Content-Type is %q, want text/event-stream", got) } return bufio.NewReader(resp.Body) } // nextEvent reads events until one of the named kind arrives, // returning its data line. Pings and the other kind are skipped. func nextEvent(t *testing.T, r *bufio.Reader, want string) string { t.Helper() var kind string for { line, err := r.ReadString('\n') if err != nil { t.Fatalf("reading the stream for a %q event: %v", want, err) } line = strings.TrimRight(line, "\r\n") switch { case strings.HasPrefix(line, "event: "): kind = strings.TrimPrefix(line, "event: ") case strings.HasPrefix(line, "data: "): if kind == want { return strings.TrimPrefix(line, "data: ") } } } } // scalar reads one value out of the app's own database, for the facts // that have no screen: which actor a row recorded, which id a person // got. func scalar(t *testing.T, a *testApp, query string, args ...any) string { t.Helper() var out string if err := a.db.G.Raw(query, args...).Scan(&out).Error; err != nil { t.Fatalf("query %q: %v", query, err) } return out } // sqidOf turns the /d/{sqid} path createDeck returns into the sqid. func sqidOf(path string) string { return strings.TrimPrefix(path, "/d/") } // --- the wire --- // TestUpdatesRoundTripThroughTheWire: a browser's own update, made by // the same core the server runs, posted, read back framed, and visible // in the deck the server renders. // // The last step is the point. A test that posted bytes and read the // same bytes back would pass over a table that stored them and a // document that never saw them. func TestUpdatesRoundTripThroughTheWire(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) path := createDeck(t, a, cookies, "Wire") // A new deck is one update: its opening batch (decks.go). log, seq := getUpdates(t, a, path, 0, cookies) if len(log) != 1 { t.Fatalf("a new deck's log has %d frames, want 1", len(log)) } if seq != 1 { t.Fatalf("Slides-Seq on a new deck is %d, want 1", seq) } m := newMirror(t, sqidOf(path), log) update := m.ops(t, `[{"op":"add_slide","id":"wiretestslide","layout":"flow","template":"title"}]`) if got := postUpdate(t, a, path, update, cookies); got != 2 { t.Fatalf("POST answered Slides-Seq %d, want 2", got) } // Read back from the cursor the client held: exactly the one // update it did not already have, byte for byte. tail, seq := getUpdates(t, a, path, 1, cookies) if len(tail) != 1 { t.Fatalf("since=1 returned %d frames, want 1", len(tail)) } if !bytes.Equal(tail[0], update) { t.Errorf("the frame read back is not the update posted:\n got %x\nwant %x", tail[0], update) } if seq != 2 { t.Errorf("Slides-Seq is %d, want 2", seq) } // And the server's own document really has the slide. rec := mustGet(t, a, path+"/slides/wiretestslide.html", http.StatusOK, cookies) if !strings.Contains(rec.Body.String(), "