oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev d1d088460c8dce8ebd3e65ef7f565cce40fbb270 parent b96a408
7 files changed, +410 −2
  • core/src/fragment.rs +41 −2
  • core/tests/fragment.rs +57 −0
  • internal/core/testdata/docs_core.wasm binary
  • internal/docs/app.go +8 −0
  • internal/docs/handlers.go +141 −0
  • internal/docs/render.go +12 −0
  • internal/docs/updates_test.go +151 −0

Intent

Human

<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)
+ }
+}