app: update-log endpoints, owner-scoped and size-capped
Adds GET/POST /d/{sqid}/updates so the browser's editor can load a
document's Yjs history and post new edits back through
Store.ApplyUpdate. Both endpoints reuse Store.Get's owner-scoped,
404-not-403 lookup; POST wraps the body in http.MaxBytesReader
(maxUpdateBytes = 1MiB) before it is read into memory, and a core
rejection is a 400, never a 500; GET caps the concatenated log at
maxLogBytes = 8MiB and fails loudly rather than truncating.
Also fixes the two findings parked from Task 1 in fragment_text,
now reachable now that arbitrary client bytes reach the CRDT: a text
node preceding a block used to merge into it with no separator, and
an empty <p></p> vanished in the text view while fragment_html kept
it. Both covered by new core/tests/fragment.rs cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
core/src/fragment.rs+41 −2core/tests/fragment.rs+57 −0internal/core/testdata/docs_core.wasmbinaryinternal/docs/app.go+8 −0internal/docs/handlers.go+141 −0internal/docs/render.go+12 −0internal/docs/updates_test.go+151 −0
Intent
<task-notification> <task-id>a21e515b144836957</task-id> <tool-use-id>toolu_012MXsJhz76XeY77p6QNLv3r</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-docs/70f302d1-9d5d-4eb2-8b38-e09a15fdd1c3/tasks/a21e515b144836957.output</output-file> <status>completed</status> <summary>Agent "Editing Task 4 vendor bundle" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: done. Commit: `b96a408a8af3e524b4a35b9adfd00532897c2667` Gate runtime: before ~37.96s, after ~37.77s — no measurable added time (the rebuild step alone measured ~60ms in isolation). Watched it fail: reverted `fixtures/editor-entry.js` to a version with a real content change (extra export, not just a comment esbuild would strip), ran the gate without rebuilding — it failed at the new check with exit 1 and the intended message, and the tree was left clean (checkout+clean fired). Reverted the probe, reran — full gate passes, tree clean throughout.</result> <usage><subagent_tokens>132351</subagent_tokens><tool_uses>13</tool_uses><duration_ms>202890</duration_ms></usage> </task-notification>
diff --git a/core/src/fragment.rs b/core/src/fragment.rs| index 34c0021..fdf01f0 100644 |
| --- a/core/src/fragment.rs |
| +++ b/core/src/fragment.rs |
| @@ -123,11 +123,37 @@ fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: |
| name.as_str(), |
| "paragraph" | "heading" | "list_item" | "blockquote" |
| ); |
| + // A text sibling that precedes this block with no block of |
| + // its own (unreachable through ProseMirror's own schema, |
| + // but reachable the moment a client posts arbitrary Yjs |
| + // bytes — see fragment_text's own doc comment) must not |
| + // run straight into this block's text with no separator: |
| + // flush whatever is already pending as its own segment |
| + // before this block contributes anything. |
| + if is_block && !cur.is_empty() { |
| + blocks.push(std::mem::take(cur)); |
| + } |
| + let mut had_children = false; |
| for child in e.children(txn) { |
| + had_children = true; |
| node_text(txn, &child, blocks, cur); |
| } |
| - if is_block && !cur.is_empty() { |
| - blocks.push(std::mem::take(cur)); |
| + if is_block { |
| + if !cur.is_empty() { |
| + blocks.push(std::mem::take(cur)); |
| + } else if !had_children { |
| + // A structurally empty block (`<p></p>`) still |
| + // renders as a blank paragraph in fragment_html; |
| + // mirror that here rather than silently dropping |
| + // it, which would make the two renderers disagree |
| + // about how many paragraphs a document has. A |
| + // block whose children already flushed their own |
| + // text (had_children but cur now empty, e.g. a |
| + // list_item wrapping a paragraph) must not get a |
| + // second, spurious empty entry — that is exactly |
| + // what had_children distinguishes. |
| + blocks.push(String::new()); |
| + } |
| } |
| } |
| XmlOut::Fragment(f) => { |
| @@ -141,6 +167,19 @@ fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: |
| /// The document as plain text, one blank line between blocks. Used by |
| /// the list's preview and by anything that wants the words without the |
| /// markup. |
| +/// |
| +/// Neither ProseMirror's own schema nor its `y-prosemirror` binding can |
| +/// produce a fragment with a bare text node preceding a block, or a |
| +/// structurally empty block — so, until this task, node_text's two |
| +/// edge cases here were unreachable in practice: a text sibling ahead |
| +/// of a block used to run straight into that block's own text with no |
| +/// separator (`"orphanactual"` for `[text("orphan"), paragraph(text("actual"))]`), |
| +/// and an empty `<p></p>` that fragment_html preserves used to vanish |
| +/// here instead of leaving a blank line. `POST /d/{sqid}/updates` |
| +/// (Task 5) is what makes them reachable: it accepts arbitrary |
| +/// client-supplied Yjs bytes, not only what a real ProseMirror editor |
| +/// would ever emit. Both are fixed in node_text above and covered by |
| +/// core/tests/fragment.rs. |
| pub fn fragment_text(doc: &Doc) -> String { |
| let f = root(doc); |
| let txn = doc.transact(); |
diff --git a/core/tests/fragment.rs b/core/tests/fragment.rs| index 6403166..44e67c4 100644 |
| --- a/core/tests/fragment.rs |
| +++ b/core/tests/fragment.rs |
| @@ -87,3 +87,60 @@ fn unknown_nodes_keep_their_text() { |
| assert!(fragment_text(&doc).contains("keep me")); |
| assert!(fragment_html(&doc).contains("keep me")); |
| } |
| + |
| +// Task 1's parked finding (a): a text node siblinged directly ahead of |
| +// a block, with no block of its own — unreachable through |
| +// ProseMirror's own schema, but reachable the moment a client posts |
| +// arbitrary Yjs bytes (Task 5's POST /d/{sqid}/updates). Used to merge |
| +// into the following block's text with no separator ("orphanactual"). |
| +#[test] |
| +fn text_sibling_before_a_block_is_separated() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("orphan".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + f.push_back(&mut txn, yrs::XmlTextPrelim::new("orphan")); |
| + let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p.push_back(&mut txn, yrs::XmlTextPrelim::new("actual")); |
| + } |
| + assert_eq!(fragment_text(&doc), "orphan\n\nactual"); |
| +} |
| + |
| +// Task 1's parked finding (b): fragment_html preserves an empty |
| +// `<p></p>` but fragment_text used to drop the block entirely — an |
| +// asymmetry no test covered until now. A structurally empty block |
| +// keeps a blank line in the text rendering, the same way it keeps an |
| +// empty tag pair in the HTML rendering. |
| +#[test] |
| +fn empty_block_is_preserved_like_html() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("emptyp".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let p1 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p1.push_back(&mut txn, yrs::XmlTextPrelim::new("First")); |
| + f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + let p3 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p3.push_back(&mut txn, yrs::XmlTextPrelim::new("Second")); |
| + } |
| + assert_eq!(fragment_html(&doc), "<p>First</p><p></p><p>Second</p>"); |
| + assert_eq!(fragment_text(&doc), "First\n\n\n\nSecond"); |
| +} |
| + |
| +// A block nested inside another block (a paragraph inside a |
| +// list_item, ProseMirror's real shape for a list) must not gain a |
| +// spurious empty segment from the outer block once its inner text has |
| +// already been flushed — the regression the naive "always push on |
| +// is_block" fix for finding (b) would have introduced. |
| +#[test] |
| +fn nested_blocks_do_not_duplicate_their_text() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("nest".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let li = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("list_item")); |
| + let p = li.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p.push_back(&mut txn, yrs::XmlTextPrelim::new("x")); |
| + } |
| + assert_eq!(fragment_text(&doc), "x"); |
| +} |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| index e58b607..6a93072 100755 |
| Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index c671f34..07a1a02 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -443,6 +443,14 @@ func New(cfg Config) (*App, error) { |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
| r.Get("/d/{sqid}", h.show) |
| + // Task 5: the update log, what the browser's editor reads to |
| + // build its Yjs doc (GET) and posts new edits back through |
| + // (POST) — see handlers.go's updatesGet/updatesPost. Neither |
| + // is exempted from csrf.Protect above: rastrillo SKILL.md §5's |
| + // origin check is what stops a cross-site page from writing |
| + // to a document through this route. |
| + r.Get("/d/{sqid}/updates", h.updatesGet) |
| + r.Post("/d/{sqid}/updates", h.updatesPost) |
| }) |
| mux := http.NewServeMux() |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index d864d2b..60319b7 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -3,6 +3,7 @@ package docs |
| import ( |
| "errors" |
| "html/template" |
| + "io" |
| "log/slog" |
| "net/http" |
| "strings" |
| @@ -14,6 +15,16 @@ import ( |
| rauth "github.com/carlosframework/rastrillo/auth" |
| ) |
| +// maxUpdateBytes caps a single client-submitted Yjs update |
| +// (POST /d/{sqid}/updates). A single keystroke's update is tens of |
| +// bytes, and a whole seeded document (seed.go) is a few kilobytes — |
| +// a megabyte is far above anything a legitimate client ever sends in |
| +// one update, and far below anything that threatens this instance's |
| +// memory. http.MaxBytesReader enforces this before the body is read |
| +// into memory at all, not after — updatesPost wraps r.Body in one |
| +// before it ever calls io.ReadAll. |
| +const maxUpdateBytes = 1 << 20 |
| + |
| // handlers holds what the app's HTTP handlers need — rastrillo |
| // SKILL.md's "one handler struct holding d.G", here Store (Task 2) |
| // instead: this app reads and writes documents through Store, never |
| @@ -221,6 +232,136 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| +// maxLogBytes caps the concatenated update log GET /d/{sqid}/updates |
| +// will return. Per the design spec's own D2 note, this bound is what |
| +// a later snapshot/compaction plan removes; until then it must fail |
| +// LOUDLY (updatesGet logs the document and answers 500) rather than |
| +// silently truncate — a truncated log would hand the browser a |
| +// corrupt document that looks intact. |
| +const maxLogBytes = 8 << 20 |
| + |
| +// getDocumentForUpdates is the owner-scoped lookup both |
| +// updatesGet and updatesPost share: it resolves the signed-in |
| +// person, then loads the document through Store.Get — already |
| +// owner-scoped and already 404-not-403 (its own doc comment) — so a |
| +// stranger's sqid returns exactly the same outcome as one that does |
| +// not exist. ok is false once a response has already been written |
| +// (404 or 500) and the caller must return immediately without doing |
| +// anything further. |
| +func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) (doc *Document, ok bool) { |
| + sqid := r.PathValue("sqid") |
| + |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return nil, false |
| + } |
| + |
| + doc, err = h.store.Get(r.Context(), owner, sqid) |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound) |
| + return nil, false |
| + } |
| + h.logger.Error("docs: get document", "sqid", sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return nil, false |
| + } |
| + return doc, true |
| +} |
| + |
| +// updatesGet serves GET /d/{sqid}/updates: the document's raw update |
| +// log, concatenated in seq order — what the browser's Yjs doc |
| +// replays to build its own state (Task 6). Owner-scoped exactly like |
| +// show (getDocumentForUpdates), and capped at maxLogBytes above. |
| +// |
| +// The size is summed and checked BEFORE anything is written to w: |
| +// writing the 200 status and a partial body first, then discovering |
| +// the cap is exceeded, would leave no way back except closing the |
| +// connection mid-response — indistinguishable, to a browser, from a |
| +// truncated document that looks intact. Failing before the first |
| +// byte goes out keeps that outcome impossible. |
| +func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| + doc, ok := h.getDocumentForUpdates(w, r) |
| + if !ok { |
| + return |
| + } |
| + |
| + var updates []DocUpdate |
| + if err := h.store.db.WithContext(r.Context()). |
| + Where("doc_id = ?", doc.ID). |
| + Order("seq ASC"). |
| + Find(&updates).Error; err != nil { |
| + h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + var total int |
| + for _, u := range updates { |
| + total += len(u.UpdateBin) |
| + } |
| + if total > maxLogBytes { |
| + h.logger.Error("docs: update log exceeds maxLogBytes", "sqid", doc.Sqid, "bytes", total, "cap", maxLogBytes) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + for _, u := range updates { |
| + if _, err := w.Write(u.UpdateBin); err != nil { |
| + // The client went away mid-response; nothing left to do |
| + // but stop writing. Logged, not surfaced — the status |
| + // line is already on the wire. |
| + h.logger.Error("docs: write update log", "sqid", doc.Sqid, "err", err) |
| + return |
| + } |
| + } |
| +} |
| + |
| +// updatesPost serves POST /d/{sqid}/updates: one new Yjs update from |
| +// the browser's editor, applied through Store.ApplyUpdate — the |
| +// point where a client's bytes first reach the CRDT core. |
| +// |
| +// r.Body is wrapped in http.MaxBytesReader before anything reads it, |
| +// so an oversized body is refused (413) before it is buffered into |
| +// memory at all, never after. A rejection from the core (bad bytes, |
| +// not a database or programming error) is a 400: the client sent |
| +// something the CRDT would not accept, and Store.ApplyUpdate's own |
| +// doc comment guarantees nothing was persisted for it. |
| +func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| + doc, ok := h.getDocumentForUpdates(w, r) |
| + if !ok { |
| + return |
| + } |
| + |
| + r.Body = http.MaxBytesReader(w, r.Body, maxUpdateBytes) |
| + update, err := io.ReadAll(r.Body) |
| + if err != nil { |
| + var mbe *http.MaxBytesError |
| + if errors.As(err, &mbe) { |
| + http.Error(w, rastrillo.T(r, "docs.error.updatetoolarge"), http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + h.logger.Error("docs: read posted update", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + if err := h.store.ApplyUpdate(r.Context(), doc, update); err != nil { |
| + // Not h.logger.Error: a client sending bytes the core |
| + // rejects is an ordinary, expected outcome of accepting |
| + // arbitrary input over HTTP, not an operational problem with |
| + // this instance. |
| + h.logger.Info("docs: reject posted update", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.badupdate"), http.StatusBadRequest) |
| + return |
| + } |
| + |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| // groupByRecency buckets docs — already newest-first, per Store.List |
| // — into recency groups without re-sorting: consecutive documents |
| // that share a bucket join the same group, and a new bucket starts a |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 2be369e..9f5bdbc 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -63,6 +63,18 @@ var Catalog = rastrillo.Catalog{ |
| // yet" caveat as docs.error.generic above. |
| "docs.error.notfound": "Not found.", |
| + // docs.error.badupdate answers POST /d/{sqid}/updates when the |
| + // posted bytes are not a Yjs update the core will accept — a |
| + // client error (400), not a render failure, but still a catalog |
| + // key rather than a literal in the handler (Task 5). |
| + "docs.error.badupdate": "That update could not be applied.", |
| + |
| + // docs.error.updatetoolarge answers POST /d/{sqid}/updates when |
| + // the body exceeds maxUpdateBytes (handlers.go) — refused before |
| + // it is read into memory (http.MaxBytesReader), so this is what a |
| + // browser sees for it (Task 5). |
| + "docs.error.updatetoolarge": "That update is too large.", |
| + |
| // docs.document.untitled is POST /documents' fallback title when |
| // the submitted one is empty or all whitespace — boilerplate copy |
| // this app writes, not the caller's own input, so it is a catalog |
diff --git a/internal/docs/updates_test.go b/internal/docs/updates_test.go| new file mode 100644 |
| index 0000000..b2c7ea3 |
| --- /dev/null |
| +++ b/internal/docs/updates_test.go |
| @@ -0,0 +1,151 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// serve runs req through the app's real router and returns the |
| +// recorded response — the same httptest.NewRecorder + |
| +// app.Handler().ServeHTTP(rec, req) pattern store_test.go's |
| +// TestUnownedDocumentOverHTTPIs404Not403 uses, since app.TestRequest |
| +// (testing.go) returns the *http.Request itself, not a response. |
| +func serve(app *App, req *http.Request) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// readFixture loads a raw Yjs update fixture by its path relative to |
| +// this package directory (internal/docs) — used for vectors that |
| +// carry no ".update.bin" suffix (fixtures/vectors/fragment/simple.bin, |
| +// built straight from real yjs, per fixtures/generate-fragment.mjs) |
| +// and therefore do not fit ReadFragmentFixture's naming convention |
| +// (testing.go). |
| +func readFixture(t *testing.T, path string) []byte { |
| + t.Helper() |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("readFixture(%q): %v", path, err) |
| + } |
| + return b |
| +} |
| + |
| +// validUpdate returns a real Yjs update — one paragraph, "First |
| +// paragraph." — built by real yjs (fixtures/generate-fragment.mjs), |
| +// never hand-crafted: tests that only need *some* update the core |
| +// will accept use this rather than re-deriving their own bytes. |
| +func validUpdate(t *testing.T) []byte { |
| + t.Helper() |
| + return readFixture(t, "../../fixtures/vectors/fragment/simple.bin") |
| +} |
| + |
| +// Both endpoints are owner-scoped. A stranger must get 404, never 403: |
| +// 403 confirms the document exists, which is the enumeration leak the |
| +// read side already avoids. |
| +func TestUpdatesEndpointsAreOwnerScoped(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + app.TestPerson(t, "stranger@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Private") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + get := app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil) |
| + getRec := serve(app, get) |
| + if getRec.Code != http.StatusNotFound { |
| + t.Errorf("stranger GET = %d, want 404", getRec.Code) |
| + } |
| + post := app.TestRequest(t, "stranger@example.com", http.MethodPost, |
| + "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t))) |
| + postRec := serve(app, post) |
| + if postRec.Code != http.StatusNotFound { |
| + t.Errorf("stranger POST = %d, want 404", postRec.Code) |
| + } |
| +} |
| + |
| +// The round trip is the whole feature: what the browser posts must |
| +// come back out, through a real CRDT replay. |
| +func TestPostedUpdateSurvivesRoundTrip(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, _ := app.Store().Create(ctx, owner, "Round trip") |
| + |
| + update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin") |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost, |
| + "/d/"+doc.Sqid+"/updates", bytes.NewReader(update))) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("POST = %d, want 204: %s", rec.Code, rec.Body) |
| + } |
| + |
| + html, err := app.Store().HTML(ctx, doc) |
| + if err != nil { |
| + t.Fatalf("HTML: %v", err) |
| + } |
| + if !strings.Contains(string(html), "First paragraph.") { |
| + t.Errorf("posted update did not reach the document: %q", html) |
| + } |
| + |
| + got := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)) |
| + if got.Code != http.StatusOK { |
| + t.Fatalf("GET = %d, want 200", got.Code) |
| + } |
| + if got.Body.Len() == 0 { |
| + t.Error("GET returned an empty log after a successful POST") |
| + } |
| +} |
| + |
| +// Garbage must be refused as a client error, not a 500, and must not |
| +// be persisted. yrs calls from_utf8_unchecked on wire data (spec |
| +// §5.2), so this path is exactly why the wasm sandbox is |
| +// load-bearing. |
| +func TestGarbageUpdateIsRejectedAndNotPersisted(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, _ := app.Store().Create(ctx, owner, "Garbage") |
| + |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost, |
| + "/d/"+doc.Sqid+"/updates", bytes.NewReader([]byte{0xff, 0x00, 0x13, 0x37}))) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("garbage POST = %d, want 400", rec.Code) |
| + } |
| + got := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)) |
| + if got.Body.Len() != 0 { |
| + t.Error("a rejected update was persisted anyway") |
| + } |
| +} |
| + |
| +// An oversized body must be refused before it is read into memory. |
| +func TestOversizedUpdateIsRefused(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, _ := app.Store().Create(ctx, owner, "Huge") |
| + |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodPost, |
| + "/d/"+doc.Sqid+"/updates", bytes.NewReader(make([]byte, maxUpdateBytes+1)))) |
| + if rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized POST = %d, want 413", rec.Code) |
| + } |
| +} |