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 "net/http/httptest"
8 "os"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13)
14
15// serve runs req through the app's real router and returns the
16// recorded response — the same httptest.NewRecorder +
17// app.Handler().ServeHTTP(rec, req) pattern store_test.go's
18// TestUnownedDocumentOverHTTPIs404Not403 uses, since app.TestRequest
19// (testing.go) returns the *http.Request itself, not a response.
20func serve(app *App, req *http.Request) *httptest.ResponseRecorder {
21 rec := httptest.NewRecorder()
22 app.Handler().ServeHTTP(rec, req)
23 return rec
24}
25
26// readFixture loads a raw Yjs update fixture by its path relative to
27// this package directory (internal/docs) — used for vectors that
28// carry no ".update.bin" suffix (fixtures/vectors/fragment/simple.bin,
29// built straight from real yjs, per fixtures/generate-fragment.mjs)
30// and therefore do not fit ReadFragmentFixture's naming convention
31// (testing.go).
32func readFixture(t *testing.T, path string) []byte {
33 t.Helper()
34 b, err := os.ReadFile(path)
35 if err != nil {
36 t.Fatalf("readFixture(%q): %v", path, err)
37 }
38 return b
39}
40
41// validUpdate returns a real Yjs update — one paragraph, "First
42// paragraph." — built by real yjs (fixtures/generate-fragment.mjs),
43// never hand-crafted: tests that only need *some* update the core
44// will accept use this rather than re-deriving their own bytes.
45func validUpdate(t *testing.T) []byte {
46 t.Helper()
47 return readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
48}
49
50// Both endpoints are owner-scoped. A stranger must get 404, never 403:
51// 403 confirms the document exists, which is the enumeration leak the
52// read side already avoids.
53func TestUpdatesEndpointsAreOwnerScoped(t *testing.T) {
54 app, err := NewForTest(t)
55 if err != nil {
56 t.Fatalf("NewForTest: %v", err)
57 }
58 ctx := context.Background()
59 owner := app.TestPerson(t, "owner@example.com")
60 app.TestPerson(t, "stranger@example.com")
61 doc, err := app.Store().Create(ctx, owner, "Private")
62 if err != nil {
63 t.Fatalf("Create: %v", err)
64 }
65
66 get := app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
67 getRec := serve(app, get)
68 if getRec.Code != http.StatusNotFound {
69 t.Errorf("stranger GET = %d, want 404", getRec.Code)
70 }
71 post := app.TestRequest(t, "stranger@example.com", http.MethodPost,
72 "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t)))
73 postRec := serve(app, post)
74 if postRec.Code != http.StatusNotFound {
75 t.Errorf("stranger POST = %d, want 404", postRec.Code)
76 }
77}
78
79// The round trip is the whole feature: what the browser posts must
80// come back out, through a real CRDT replay.
81func TestPostedUpdateSurvivesRoundTrip(t *testing.T) {
82 app, err := NewForTest(t)
83 if err != nil {
84 t.Fatalf("NewForTest: %v", err)
85 }
86 ctx := context.Background()
87 owner := app.TestPerson(t, "owner@example.com")
88 doc, _ := app.Store().Create(ctx, owner, "Round trip")
89
90 update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
91 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost,
92 "/d/"+doc.Sqid+"/updates", bytes.NewReader(update)))
93 if rec.Code != http.StatusNoContent {
94 t.Fatalf("POST = %d, want 204: %s", rec.Code, rec.Body)
95 }
96
97 html, err := app.Store().HTML(ctx, doc)
98 if err != nil {
99 t.Fatalf("HTML: %v", err)
100 }
101 if !strings.Contains(string(html), "First paragraph.") {
102 t.Errorf("posted update did not reach the document: %q", html)
103 }
104
105 got := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil))
106 if got.Code != http.StatusOK {
107 t.Fatalf("GET = %d, want 200", got.Code)
108 }
109 if got.Body.Len() == 0 {
110 t.Error("GET returned an empty log after a successful POST")
111 }
112}
113
114// Garbage must be refused as a client error, not a 500, and must not
115// be persisted. yrs calls from_utf8_unchecked on wire data (spec
116// §5.2), so this path is exactly why the wasm sandbox is
117// load-bearing.
118func TestGarbageUpdateIsRejectedAndNotPersisted(t *testing.T) {
119 app, err := NewForTest(t)
120 if err != nil {
121 t.Fatalf("NewForTest: %v", err)
122 }
123 ctx := context.Background()
124 owner := app.TestPerson(t, "owner@example.com")
125 doc, _ := app.Store().Create(ctx, owner, "Garbage")
126
127 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost,
128 "/d/"+doc.Sqid+"/updates", bytes.NewReader([]byte{0xff, 0x00, 0x13, 0x37})))
129 if rec.Code != http.StatusBadRequest {
130 t.Errorf("garbage POST = %d, want 400", rec.Code)
131 }
132 got := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil))
133 if got.Body.Len() != 0 {
134 t.Error("a rejected update was persisted anyway")
135 }
136}
137
138// An oversized body must be refused before it is read into memory.
139func TestOversizedUpdateIsRefused(t *testing.T) {
140 app, err := NewForTest(t)
141 if err != nil {
142 t.Fatalf("NewForTest: %v", err)
143 }
144 ctx := context.Background()
145 owner := app.TestPerson(t, "owner@example.com")
146 doc, _ := app.Store().Create(ctx, owner, "Huge")
147
148 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost,
149 "/d/"+doc.Sqid+"/updates", bytes.NewReader(make([]byte, maxUpdateBytes+1))))
150 if rec.Code != http.StatusRequestEntityTooLarge {
151 t.Errorf("oversized POST = %d, want 413", rec.Code)
152 }
153}
154
155// TestInternalApplyFailureReturns500NotBadUpdate is fix round 1's
156// finding (1): updatesPost used to collapse every Store.ApplyUpdate
157// error into 400 at Info, so a genuine failure on our own end (a
158// corrupted historical row that the real core cannot even replay) was
159// reported to the client as "your bytes were bad" — the exact wrong-
160// side-of-the-mistake shape this codebase keeps producing.
161//
162// The failure here is real, not mocked: a garbage row is written
163// directly into doc_updates as if it were already-persisted history
164// (bypassing ApplyUpdate's own validation entirely — the only way
165// such a row could exist, since ApplyUpdate never persists what the
166// core rejects), and the POST that follows drives the actual wasm
167// core through Store.replay to really fail decoding it. A mock
168// Store returning an arbitrary error would only prove the handler's
169// switch statement compiles, not that the classification matches
170// what the core actually returns for a real internal failure — the
171// reviewer's own words for why this test exists.
172func TestInternalApplyFailureReturns500NotBadUpdate(t *testing.T) {
173 app, err := NewForTest(t)
174 if err != nil {
175 t.Fatalf("NewForTest: %v", err)
176 }
177 ctx := context.Background()
178 owner := app.TestPerson(t, "owner@example.com")
179 doc, err := app.Store().Create(ctx, owner, "Corrupted history")
180 if err != nil {
181 t.Fatalf("Create: %v", err)
182 }
183
184 // app.store.db is package-private (same package, docs) — reached
185 // directly here because this row must land in doc_updates without
186 // ever going through ApplyUpdate's own core validation, which
187 // would (correctly) refuse it.
188 corrupt := &DocUpdate{
189 DocID: doc.ID,
190 Seq: 1,
191 UpdateBin: []byte{0xff, 0x00, 0x13, 0x37},
192 CreatedAt: time.Now().UTC(),
193 }
194 if err := app.store.db.WithContext(ctx).Create(corrupt).Error; err != nil {
195 t.Fatalf("seed a corrupted historical row: %v", err)
196 }
197
198 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost,
199 "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t))))
200 if rec.Code != http.StatusInternalServerError {
201 t.Errorf("POST with unreplayable history = %d, want 500 (not 400 — this is not the client's fault): %s", rec.Code, rec.Body)
202 }
203}
204
205// TestConcurrentUpdatesDoNotCollideOnSeq is fix round 1's finding (2):
206// ApplyUpdate's MAX(seq)->INSERT was two separate statements, not one
207// atomic step, so two POSTs racing on the same document could both
208// compute the same next seq and both write it. Reachable for real
209// once Task 6's editor debounces edits into a flush timer AND sends a
210// beforeunload beacon with whatever is still queued — those two
211// genuinely race on a real page close.
212//
213// This drives the race with real concurrent goroutines POSTing
214// through the real router to the same document, started together off
215// one channel close rather than launched in a loop (which tends to
216// serialise on the Go scheduler before any of them touch the
217// database).
218//
219// Every goroutine posts the exact same fixture, not N different ones:
220// every hand-written fixture under fixtures/vectors/fragment/ pins
221// Y.Doc.clientID to a fixed value for reproducibility (generate-
222// fragment.mjs's own doc comment) — 1, for every one of them except
223// "concurrent" — so applying two DIFFERENT such fixtures to one
224// shared document makes two genuinely incompatible claims about that
225// same client's clock history and the real core correctly refuses the
226// second (confirmed empirically: mixing fixtures here produced real
227// "apply rejected the update: code -3" replay failures unrelated to
228// the seq race this test exists to exercise). Reapplying the identical
229// bytes N times is exactly what a real debounce+beacon race would
230// also risk doing did it retry a failed flush verbatim, and it is
231// idempotent at the CRDT level (Store.ApplyUpdate content-merges, not
232// row-dedupes), so it still exercises the real thing this test is
233// for: N real POSTs each getting persisted a distinct doc_updates row.
234func TestConcurrentUpdatesDoNotCollideOnSeq(t *testing.T) {
235 app, err := NewForTest(t)
236 if err != nil {
237 t.Fatalf("NewForTest: %v", err)
238 }
239 ctx := context.Background()
240 owner := app.TestPerson(t, "owner@example.com")
241 doc, err := app.Store().Create(ctx, owner, "Race")
242 if err != nil {
243 t.Fatalf("Create: %v", err)
244 }
245 payload := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
246
247 // One sign-in, cookies reused by every goroutine below: this test
248 // is about racing the write path, not re-proving TestSignIn's own
249 // round trip N times over.
250 link, err := app.TestSignIn(t, "owner@example.com")
251 if err != nil {
252 t.Fatalf("TestSignIn: %v", err)
253 }
254 verifyRec := httptest.NewRecorder()
255 app.Handler().ServeHTTP(verifyRec, confirmRequest(link))
256 cookies := verifyRec.Result().Cookies()
257 if len(cookies) == 0 {
258 t.Fatal("sign-in set no session cookie")
259 }
260
261 const n = 21
262 codes := make([]int, n)
263 var wg sync.WaitGroup
264 start := make(chan struct{})
265 for i := 0; i < n; i++ {
266 wg.Add(1)
267 go func(i int) {
268 defer wg.Done()
269 <-start
270 req := httptest.NewRequest(http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(payload))
271 for _, c := range cookies {
272 req.AddCookie(c)
273 }
274 req.Header.Set("Sec-Fetch-Site", "same-origin")
275 codes[i] = serve(app, req).Code
276 }(i)
277 }
278 close(start)
279 wg.Wait()
280
281 for i, code := range codes {
282 if code != http.StatusNoContent {
283 t.Errorf("goroutine %d: POST = %d, want 204", i, code)
284 }
285 }
286
287 var rows []DocUpdate
288 if err := app.store.db.WithContext(ctx).
289 Where("doc_id = ?", doc.ID).
290 Order("seq ASC").
291 Find(&rows).Error; err != nil {
292 t.Fatalf("load doc_updates: %v", err)
293 }
294 if len(rows) != n {
295 t.Fatalf("doc_updates has %d rows for document %d, want %d — a concurrent POST's row went missing", len(rows), doc.ID, n)
296 }
297 seen := make(map[int64]bool, n)
298 for _, row := range rows {
299 if seen[row.Seq] {
300 t.Fatalf("duplicate seq %d for document %d across %d concurrent POSTs — the MAX(seq)->INSERT race was not closed", row.Seq, doc.ID, n)
301 }
302 seen[row.Seq] = true
303 }
304
305 // The document must still replay — not just "no duplicate rows",
306 // but every one of those rows a real, applyable update the core
307 // accepts in sequence.
308 html, err := app.Store().HTML(ctx, doc)
309 if err != nil {
310 t.Fatalf("HTML after %d concurrent updates: %v", n, err)
311 }
312 if !strings.Contains(string(html), "First paragraph.") {
313 t.Errorf("document lost content after concurrent updates: %q", html)
314 }
315}
316