| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | func 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. |
| 36 | func 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). |
| 51 | func 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. |
| 78 | func 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. |
| 141 | func 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. |
| 212 | func 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. |
| 241 | func 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. |
| 292 | func 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. |
| 322 | func 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. |
| 350 | func 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. |
| 373 | func 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. |
| 398 | func 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 | |