oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "bytes"
5 "context"
6 "net/http"
7 "strconv"
8 "testing"
9)
10
11// The cursor protocol (spec 2026-09-01-live-sync-design.md §4) is what
12// lets a second tab catch up without reloading: ?since=N slices the
13// log, and Docs-Seq tells the client how far the bytes it just
14// received actually reach.
15//
16// These tests live in their own file rather than in updates_test.go
17// because they test a protocol rather than the update endpoints'
18// existing behaviour, and because the D2 test below is a regression
19// test for a specific rejected implementation — the kind that goes
20// stale silently if it ends up filed under a general heading.
21
22// postUpdate posts one real Yjs update as owner and returns the
23// response, so a test that only needs "another row exists now" does
24// not restate the whole request each time.
25func postUpdate(t *testing.T, app *App, sqid, address string, update []byte) *http.Response {
26 t.Helper()
27 rec := serve(app, app.TestRequest(t, address, http.MethodPost,
28 "/d/"+sqid+"/updates", bytes.NewReader(update)))
29 return rec.Result()
30}
31
32// docsSeq reads the Docs-Seq header off a response, failing the test
33// if it is missing or unparseable — every response from either update
34// route must carry one, so an absent header is a bug rather than a
35// case to tolerate.
36func docsSeq(t *testing.T, res *http.Response) int64 {
37 t.Helper()
38 raw := res.Header.Get("Docs-Seq")
39 if raw == "" {
40 return -1
41 }
42 n, err := strconv.ParseInt(raw, 10, 64)
43 if err != nil {
44 t.Fatalf("Docs-Seq: %q does not parse as an integer: %v", raw, err)
45 }
46 return n
47}
48
49// A POST answers with the seq of the row it just wrote, which is what
50// lets a tab advance its own cursor without a follow-up fetch (D3).
51func TestPostAnswersWithTheSeqItWrote(t *testing.T) {
52 app, err := NewForTest(t)
53 if err != nil {
54 t.Fatalf("NewForTest: %v", err)
55 }
56 ctx := context.Background()
57 owner := app.TestPerson(t, "owner@example.com")
58 doc, err := app.Store().Create(ctx, owner, "Cursor")
59 if err != nil {
60 t.Fatalf("Create: %v", err)
61 }
62
63 res := postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t))
64 if res.StatusCode != http.StatusNoContent {
65 t.Fatalf("POST = %d, want 204", res.StatusCode)
66 }
67 if got := docsSeq(t, res); got != 1 {
68 t.Errorf("Docs-Seq after the first POST = %d, want 1", got)
69 }
70
71 res = postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t))
72 if got := docsSeq(t, res); got != 2 {
73 t.Errorf("Docs-Seq after the second POST = %d, want 2", got)
74 }
75}
76
77// ?since=N returns only what came after N, and says so.
78func TestSinceSlicesTheLog(t *testing.T) {
79 app, err := NewForTest(t)
80 if err != nil {
81 t.Fatalf("NewForTest: %v", err)
82 }
83 ctx := context.Background()
84 owner := app.TestPerson(t, "owner@example.com")
85 doc, err := app.Store().Create(ctx, owner, "Sliced")
86 if err != nil {
87 t.Fatalf("Create: %v", err)
88 }
89
90 update := validUpdate(t)
91 postUpdate(t, app, doc.Sqid, "owner@example.com", update)
92 postUpdate(t, app, doc.Sqid, "owner@example.com", update)
93
94 full := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
95 "/d/"+doc.Sqid+"/updates", nil))
96 if full.Code != http.StatusOK {
97 t.Fatalf("full GET = %d, want 200", full.Code)
98 }
99 if got := docsSeq(t, full.Result()); got != 2 {
100 t.Errorf("Docs-Seq on a full GET = %d, want 2 (the highest row returned)", got)
101 }
102 fullLen := full.Body.Len()
103
104 sliced := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
105 "/d/"+doc.Sqid+"/updates?since=1", nil))
106 if sliced.Code != http.StatusOK {
107 t.Fatalf("sliced GET = %d, want 200", sliced.Code)
108 }
109 if sliced.Body.Len() >= fullLen {
110 t.Errorf("?since=1 returned %d bytes, the full log is %d — the slice was not applied",
111 sliced.Body.Len(), fullLen)
112 }
113 if sliced.Body.Len() == 0 {
114 t.Error("?since=1 returned nothing, but row 2 exists")
115 }
116 if got := docsSeq(t, sliced.Result()); got != 2 {
117 t.Errorf("Docs-Seq on ?since=1 = %d, want 2", got)
118 }
119}
120
121// TestEmptySliceEchoesSinceRatherThanTheCurrentMax is the regression
122// test for the design's D2, and it is written to fail against the
123// implementation the review rejected.
124//
125// An earlier draft defined Docs-Seq as "the highest seq included, or
126// the document's CURRENT MAX when the slice is empty". That second
127// clause needs its own MAX(seq) query, and reads go to the read pool
128// while writes go to the single-connection write pool — nothing
129// serializes the two. So a row could commit between the slice query
130// and the MAX query, and the client would receive an empty body
131// alongside a header naming a row it had just been told nothing
132// about. A client that trusts the header (which is the whole point of
133// the header) advances its cursor past that row and never fetches it
134// again: content present on the server, permanently invisible in that
135// tab until a manual reload.
136//
137// Deriving the header from the bytes in hand makes that
138// unrepresentable, which is what this asserts: with a row that the
139// slice deliberately excludes, the header must still echo `since`
140// rather than reporting the higher row.
141func TestEmptySliceEchoesSinceRatherThanTheCurrentMax(t *testing.T) {
142 app, err := NewForTest(t)
143 if err != nil {
144 t.Fatalf("NewForTest: %v", err)
145 }
146 ctx := context.Background()
147 owner := app.TestPerson(t, "owner@example.com")
148 doc, err := app.Store().Create(ctx, owner, "Echo")
149 if err != nil {
150 t.Fatalf("Create: %v", err)
151 }
152
153 // One row exists (seq 1). Asking for everything after seq 1 is
154 // empty *now*; the point of the test is what the header says.
155 postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t))
156
157 empty := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
158 "/d/"+doc.Sqid+"/updates?since=1", nil))
159 if empty.Code != http.StatusOK {
160 t.Fatalf("empty slice GET = %d, want 200", empty.Code)
161 }
162 if empty.Body.Len() != 0 {
163 t.Fatalf("?since=1 returned %d bytes, want none", empty.Body.Len())
164 }
165 if got := docsSeq(t, empty.Result()); got != 1 {
166 t.Errorf("Docs-Seq on an empty slice = %d, want 1 (an echo of since)", got)
167 }
168
169 postUpdate(t, app, doc.Sqid, "owner@example.com", validUpdate(t))
170 stale := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
171 "/d/"+doc.Sqid+"/updates?since=2", nil))
172 if stale.Body.Len() != 0 {
173 t.Fatalf("?since=2 returned %d bytes, want none", stale.Body.Len())
174 }
175 if got := docsSeq(t, stale.Result()); got != 2 {
176 t.Errorf("Docs-Seq on an empty slice = %d, want 2 (an echo of since, never a row the client did not receive)", got)
177 }
178
179 // Everything above this line passes against a MAX(seq)
180 // implementation too, which is the point of what follows.
181 //
182 // Whole-branch review caught that: every case above asks with
183 // since == the current MAX, and max(since, nothing) and MAX(seq)
184 // agree on every one of them. A test that cannot distinguish the
185 // implementation it was written to reject is a guard-rail with
186 // nothing behind it — worse than no test, because it reads like
187 // coverage.
188 //
189 // since > MAX is the case that separates them, and it is
190 // reachable in the wild rather than contrived: it is exactly the
191 // shape of the TOCTOU this rule exists to prevent, where a client
192 // holds a seq the server's own slice query cannot see. Derived
193 // answers 5, echoing what it was asked. MAX(seq) answers 2 —
194 // silently moving a client's cursor BACKWARDS onto a row it
195 // already has, and, in the interleaving that produced the number
196 // in the first place, forwards over one it does not.
197 ahead := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
198 "/d/"+doc.Sqid+"/updates?since=5", nil))
199 if ahead.Code != http.StatusOK {
200 t.Fatalf("?since=5 = %d, want 200", ahead.Code)
201 }
202 if ahead.Body.Len() != 0 {
203 t.Fatalf("?since=5 returned %d bytes, want none", ahead.Body.Len())
204 }
205 if got := docsSeq(t, ahead.Result()); got != 5 {
206 t.Errorf("Docs-Seq on ?since=5 with two rows = %d, want 5 — the header was derived from a MAX(seq) query rather than from the bytes in hand", got)
207 }
208}
209
210// pastCadence builds a document with more updates than the cadence, so a
211// snapshot exists, and returns it.
212func pastCadence(t *testing.T, app *App, title string) *Document {
213 t.Helper()
214 ctx := context.Background()
215 owner := app.TestPerson(t, "owner@example.com")
216 doc, err := app.Store().Create(ctx, owner, title)
217 if err != nil {
218 t.Fatalf("Create: %v", err)
219 }
220 for i := 0; i < snapshotCadence+3; i++ {
221 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
222 t.Fatalf("ApplyUpdate %d: %v", i, err)
223 }
224 }
225 return doc
226}
227
228// A client far behind the snapshot receives the snapshot plus the tail
229// rather than the whole log, and the header still names the highest seq
230// the bytes actually reach.
231//
232// Byte-exact, mirroring TestRawParameterReturnsOnlyLogRows's shape for
233// the raw path: a size and Docs-Seq comparison alone cannot tell a
234// correct tail (`seq > snapshot.Seq`) from one that drops exactly the
235// first row after the snapshot (`seq > snapshot.Seq + 1`) — every tail
236// row here is validUpdate's idempotent duplicate bytes, so dropping one
237// changes neither the response's length nor its Docs-Seq. Comparing the
238// body against the snapshot's own State plus the concatenation of every
239// doc_updates row with seq > snapshot.Seq is what a dropped row cannot
240// survive.
241func TestUpdatesGetServesSnapshotThenTail(t *testing.T) {
242 app, err := NewForTest(t)
243 if err != nil {
244 t.Fatalf("NewForTest: %v", err)
245 }
246 ctx := context.Background()
247 doc := pastCadence(t, app, "Snapshot then tail")
248
249 snap := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
250 "/d/"+doc.Sqid+"/updates?since=1", nil))
251 if snap.Code != http.StatusOK {
252 t.Fatalf("GET = %d, want 200", snap.Code)
253 }
254 raw := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
255 "/d/"+doc.Sqid+"/updates?since=1&raw=1", nil))
256
257 if snap.Body.Len() >= raw.Body.Len() {
258 t.Errorf("snapshot response %d bytes, raw log %d — the snapshot bought nothing",
259 snap.Body.Len(), raw.Body.Len())
260 }
261 if got := docsSeq(t, snap.Result()); got != int64(snapshotCadence+3) {
262 t.Errorf("Docs-Seq = %d, want %d", got, snapshotCadence+3)
263 }
264
265 snapshotRow, err := app.Store().newestSnapshot(ctx, doc.ID)
266 if err != nil {
267 t.Fatalf("newestSnapshot: %v", err)
268 }
269 if snapshotRow == nil {
270 t.Fatal("no snapshot exists past the cadence, so this test is not exercising snapshot-then-tail")
271 }
272
273 var rows []DocUpdate
274 if err := app.Store().db.WithContext(ctx).
275 Where("doc_id = ? AND seq > ?", doc.ID, snapshotRow.Seq).
276 Order("seq ASC").Find(&rows).Error; err != nil {
277 t.Fatalf("load tail rows: %v", err)
278 }
279
280 want := append([]byte(nil), snapshotRow.State...)
281 for _, r := range rows {
282 want = append(want, r.UpdateBin...)
283 }
284 if !bytes.Equal(snap.Body.Bytes(), want) {
285 t.Error("response body is not exactly the snapshot's State followed by the tail rows after snapshot.Seq")
286 }
287}
288
289// raw=1 is the escape hatch a browser needs when a snapshot will not
290// decode: doc_updates rows only, which is where the log's authority is
291// actually reachable from.
292func TestRawParameterReturnsOnlyLogRows(t *testing.T) {
293 app, err := NewForTest(t)
294 if err != nil {
295 t.Fatalf("NewForTest: %v", err)
296 }
297 ctx := context.Background()
298 doc := pastCadence(t, app, "Raw")
299
300 raw := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
301 "/d/"+doc.Sqid+"/updates?raw=1", nil))
302 if raw.Code != http.StatusOK {
303 t.Fatalf("GET raw = %d, want 200", raw.Code)
304 }
305
306 var rows []DocUpdate
307 if err := app.Store().db.WithContext(ctx).
308 Where("doc_id = ?", doc.ID).Order("seq ASC").Find(&rows).Error; err != nil {
309 t.Fatalf("load log: %v", err)
310 }
311 var want []byte
312 for _, r := range rows {
313 want = append(want, r.UpdateBin...)
314 }
315 if !bytes.Equal(raw.Body.Bytes(), want) {
316 t.Error("raw=1 did not return exactly the concatenated log")
317 }
318}
319
320// The live-sync design's D2 survives snapshots: the header is still
321// derived from the bytes in hand.
322func TestDocsSeqStillDerivedWithSnapshots(t *testing.T) {
323 app, err := NewForTest(t)
324 if err != nil {
325 t.Fatalf("NewForTest: %v", err)
326 }
327 doc := pastCadence(t, app, "Header")
328 top := int64(snapshotCadence + 3)
329
330 behind := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
331 "/d/"+doc.Sqid+"/updates?since=2", nil))
332 if got := docsSeq(t, behind.Result()); got != top {
333 t.Errorf("Docs-Seq behind the snapshot = %d, want %d", got, top)
334 }
335
336 ahead := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
337 "/d/"+doc.Sqid+"/updates?since="+strconv.FormatInt(top+5, 10), nil))
338 if ahead.Body.Len() != 0 {
339 t.Errorf("a cursor ahead of the log returned %d bytes, want none", ahead.Body.Len())
340 }
341 if got := docsSeq(t, ahead.Result()); got != top+5 {
342 t.Errorf("Docs-Seq ahead of the log = %d, want %d (an echo of since)", got, top+5)
343 }
344}
345
346// An unparseable or negative since is the client's error, and saying
347// so beats guessing at an intent: silently treating it as 0 would
348// resend the whole log, which is the expensive answer to a question
349// nobody asked.
350func TestMalformedSinceIsBadRequest(t *testing.T) {
351 app, err := NewForTest(t)
352 if err != nil {
353 t.Fatalf("NewForTest: %v", err)
354 }
355 ctx := context.Background()
356 owner := app.TestPerson(t, "owner@example.com")
357 doc, err := app.Store().Create(ctx, owner, "Malformed")
358 if err != nil {
359 t.Fatalf("Create: %v", err)
360 }
361
362 for _, since := range []string{"banana", "-1", "1.5", "9999999999999999999999"} {
363 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
364 "/d/"+doc.Sqid+"/updates?since="+since, nil))
365 if rec.Code != http.StatusBadRequest {
366 t.Errorf("?since=%s = %d, want 400", since, rec.Code)
367 }
368 }
369}
370
371// A document nobody has written to yet answers 0, so a fresh editor
372// starts its cursor at 0 rather than at nothing.
373func TestDocsSeqOnAnEmptyDocumentIsZero(t *testing.T) {
374 app, err := NewForTest(t)
375 if err != nil {
376 t.Fatalf("NewForTest: %v", err)
377 }
378 ctx := context.Background()
379 owner := app.TestPerson(t, "owner@example.com")
380 doc, err := app.Store().Create(ctx, owner, "Fresh")
381 if err != nil {
382 t.Fatalf("Create: %v", err)
383 }
384
385 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
386 "/d/"+doc.Sqid+"/updates", nil))
387 if rec.Code != http.StatusOK {
388 t.Fatalf("GET = %d, want 200", rec.Code)
389 }
390 if got := docsSeq(t, rec.Result()); got != 0 {
391 t.Errorf("Docs-Seq on an empty document = %d, want 0", got)
392 }
393}
394
395// ApplyUpdate's seq is only meaningful when it returns no error
396// (design D4). A rejected update must not consume a seq, or a client
397// that trusted the number would skip a row that later takes it.
398func TestRejectedUpdateConsumesNoSeq(t *testing.T) {
399 app, err := NewForTest(t)
400 if err != nil {
401 t.Fatalf("NewForTest: %v", err)
402 }
403 ctx := context.Background()
404 owner := app.TestPerson(t, "owner@example.com")
405 doc, err := app.Store().Create(ctx, owner, "Rejected")
406 if err != nil {
407 t.Fatalf("Create: %v", err)
408 }
409
410 seq, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t))
411 if err != nil {
412 t.Fatalf("ApplyUpdate: %v", err)
413 }
414 if seq != 1 {
415 t.Fatalf("first ApplyUpdate returned seq %d, want 1", seq)
416 }
417
418 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), []byte{0xff, 0x00, 0x13, 0x37}); err == nil {
419 t.Fatal("ApplyUpdate accepted garbage")
420 }
421
422 seq, err = app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t))
423 if err != nil {
424 t.Fatalf("ApplyUpdate after a rejection: %v", err)
425 }
426 if seq != 2 {
427 t.Errorf("ApplyUpdate after a rejection returned seq %d, want 2 — a refused update consumed a seq", seq)
428 }
429}
430