| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/docs/internal/docs" |
| 11 | ) |
| 12 | |
| 13 | func TestCreateListGetRoundTrip(t *testing.T) { |
| 14 | app, err := docs.NewForTest(t) |
| 15 | if err != nil { |
| 16 | t.Fatalf("NewForTest: %v", err) |
| 17 | } |
| 18 | ctx := context.Background() |
| 19 | owner := app.TestPerson(t, "owner@example.com") |
| 20 | |
| 21 | made, err := app.Store().Create(ctx, owner, "First document") |
| 22 | if err != nil { |
| 23 | t.Fatalf("Create: %v", err) |
| 24 | } |
| 25 | if made.Sqid == "" { |
| 26 | t.Fatal("Create returned an empty sqid") |
| 27 | } |
| 28 | if made.GUID == "" || made.ClientID == 0 { |
| 29 | t.Fatal("Create must assign a GUID and client id, never draw them from yrs") |
| 30 | } |
| 31 | |
| 32 | list, err := app.Store().List(ctx, owner) |
| 33 | if err != nil { |
| 34 | t.Fatalf("List: %v", err) |
| 35 | } |
| 36 | if len(list) != 1 || list[0].Title != "First document" { |
| 37 | t.Fatalf("List = %+v, want one document titled First document", list) |
| 38 | } |
| 39 | |
| 40 | got, err := app.Store().Get(ctx, owner, made.Sqid) |
| 41 | if err != nil { |
| 42 | t.Fatalf("Get: %v", err) |
| 43 | } |
| 44 | if got.ID != made.ID { |
| 45 | t.Errorf("Get returned id %d, want %d", got.ID, made.ID) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestTextComesFromTheCore(t *testing.T) { |
| 50 | app, err := docs.NewForTest(t) |
| 51 | if err != nil { |
| 52 | t.Fatalf("NewForTest: %v", err) |
| 53 | } |
| 54 | ctx := context.Background() |
| 55 | owner := app.TestPerson(t, "owner@example.com") |
| 56 | |
| 57 | doc, err := app.Store().Create(ctx, owner, "With content") |
| 58 | if err != nil { |
| 59 | t.Fatalf("Create: %v", err) |
| 60 | } |
| 61 | |
| 62 | // The "hello" fragment fixture is a real Yjs update produced by |
| 63 | // real yjs (fixtures/generate-fragment.mjs), building a |
| 64 | // Y.XmlFragment on root "prosemirror" — the root Store.Text |
| 65 | // actually reads (core/src/abi.rs's text_string doc comment). |
| 66 | // ReadFixture's own fixtures/data "hello" applies just as cleanly |
| 67 | // but targets root "content", which Store.Text no longer reads — |
| 68 | // it would read back "", proving nothing about the CRDT. |
| 69 | update := docs.ReadFragmentFixture(t, "hello") |
| 70 | if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil { |
| 71 | t.Fatalf("ApplyUpdate: %v", err) |
| 72 | } |
| 73 | |
| 74 | text, err := app.Store().Text(ctx, doc) |
| 75 | if err != nil { |
| 76 | t.Fatalf("Text: %v", err) |
| 77 | } |
| 78 | if text != "hello world" { |
| 79 | t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // TestListOrdersByUpdatedAt is fix round 2's own regression test: List |
| 84 | // used to order by CreatedAt while handlers.go's groupByRecency/ |
| 85 | // formatWhen bucket and display UpdatedAt, and ApplyUpdate never |
| 86 | // touched UpdatedAt at all — the two clocks only ever agreed because |
| 87 | // Store.Create sets both columns to the same timestamp, so nothing |
| 88 | // exercised the divergence until an update actually moved UpdatedAt |
| 89 | // away from CreatedAt. This creates an older document, then a newer |
| 90 | // one, confirms the newer one lists first (ordinary creation order), |
| 91 | // applies an update to the *older* document, and asserts it jumps to |
| 92 | // the top — the assertion that would have caught the bug: it fails |
| 93 | // against an order-by-CreatedAt List even though ApplyUpdate bumps |
| 94 | // UpdatedAt, and it fails against an ApplyUpdate that does not bump |
| 95 | // UpdatedAt even though List orders by it. Both halves of the fix are |
| 96 | // required to pass it. |
| 97 | func TestListOrdersByUpdatedAt(t *testing.T) { |
| 98 | app, err := docs.NewForTest(t) |
| 99 | if err != nil { |
| 100 | t.Fatalf("NewForTest: %v", err) |
| 101 | } |
| 102 | ctx := context.Background() |
| 103 | owner := app.TestPerson(t, "owner@example.com") |
| 104 | |
| 105 | older, err := app.Store().Create(ctx, owner, "Older document") |
| 106 | if err != nil { |
| 107 | t.Fatalf("Create(older): %v", err) |
| 108 | } |
| 109 | // A real gap between the two rows' timestamps: two time.Now() |
| 110 | // calls back to back can land in the same tick on a coarse clock, |
| 111 | // and this test's whole point is ordering, not simultaneity. |
| 112 | time.Sleep(5 * time.Millisecond) |
| 113 | newer, err := app.Store().Create(ctx, owner, "Newer document") |
| 114 | if err != nil { |
| 115 | t.Fatalf("Create(newer): %v", err) |
| 116 | } |
| 117 | |
| 118 | list, err := app.Store().List(ctx, owner) |
| 119 | if err != nil { |
| 120 | t.Fatalf("List: %v", err) |
| 121 | } |
| 122 | if len(list) != 2 || list[0].Sqid != newer.Sqid || list[1].Sqid != older.Sqid { |
| 123 | t.Fatalf("List before any update = %+v, want [newer, older] by plain creation order", list) |
| 124 | } |
| 125 | |
| 126 | time.Sleep(5 * time.Millisecond) |
| 127 | update := docs.ReadFixture(t, "hello") |
| 128 | if _, err := app.Store().ApplyUpdate(ctx, older, app.TestActorFor(t, older), update); err != nil { |
| 129 | t.Fatalf("ApplyUpdate(older): %v", err) |
| 130 | } |
| 131 | |
| 132 | list, err = app.Store().List(ctx, owner) |
| 133 | if err != nil { |
| 134 | t.Fatalf("List after update: %v", err) |
| 135 | } |
| 136 | if len(list) != 2 || list[0].Sqid != older.Sqid || list[1].Sqid != newer.Sqid { |
| 137 | t.Fatalf("List after applying an update to the older document = %+v, want [older, newer] — applying an update must move a document to the top of the list", list) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestADocumentYouDoNotOwnIsNotThere is the brief's own literal test |
| 142 | // (Task 2, plan 3): Bob must not be able to fetch Alice's document by |
| 143 | // sqid, and it must not appear in his own list either — both routed |
| 144 | // through Store.Get/List's owner parameter and store.go's owned seam, |
| 145 | // never a separate "is this yours" check that could leak a distinct |
| 146 | // error shape. |
| 147 | func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) { |
| 148 | app, _ := docs.NewForTest(t) |
| 149 | ctx := context.Background() |
| 150 | |
| 151 | alice := app.TestPerson(t, "alice@example.com") |
| 152 | bob := app.TestPerson(t, "bob@example.com") |
| 153 | |
| 154 | doc, err := app.Store().Create(ctx, alice, "Alice's private notes") |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | |
| 159 | // Bob must not be able to fetch it, and must not be told it exists. |
| 160 | if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil { |
| 161 | t.Fatal("Bob fetched Alice's document") |
| 162 | } |
| 163 | |
| 164 | list, err := app.Store().List(ctx, bob) |
| 165 | if err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if len(list) != 0 { |
| 169 | t.Errorf("Bob's list has %d documents, want 0", len(list)) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | // TestUnownedDocumentOverHTTPIs404Not403 is the brief's own literal |
| 174 | // test: the same isolation TestADocumentYouDoNotOwnIsNotThere proves |
| 175 | // at the store, proven again over the real router, because a store |
| 176 | // that scopes correctly and a handler that still leaks a 403 (or a |
| 177 | // 200 on someone else's document) would both pass the store-level |
| 178 | // test alone. Spec §4.2: a document you cannot see returns 404, so a |
| 179 | // stranger cannot learn a sqid exists by probing it. |
| 180 | func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) { |
| 181 | app, _ := docs.NewForTest(t) |
| 182 | ctx := context.Background() |
| 183 | alice := app.TestPerson(t, "alice@example.com") |
| 184 | doc, _ := app.Store().Create(ctx, alice, "Alice's private notes") |
| 185 | |
| 186 | req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil) |
| 187 | rec := httptest.NewRecorder() |
| 188 | app.Handler().ServeHTTP(rec, req) |
| 189 | |
| 190 | if rec.Code != http.StatusNotFound { |
| 191 | t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // TestApplyUpdateRefusesAnotherOwnersDocument is fix round 2's own |
| 196 | // regression test (Finding 1, corrected): fix round 1's version of |
| 197 | // this test was named as if it proved the write was refused, but it |
| 198 | // only ever asserted that UpdatedAt held still — a green test |
| 199 | // certifying a property nobody wanted, while the actual defect (the |
| 200 | // content write) went unchecked and unfixed underneath it. |
| 201 | // |
| 202 | // The real bug: ApplyUpdate's doc_updates INSERT (and the MAX(seq) |
| 203 | // query that sizes it) has always been keyed on doc.ID alone, with no |
| 204 | // owner predicate at all. Only the trailing updated_at bump was ever |
| 205 | // scoped through owned(doc.OwnerID), and it runs last — long after |
| 206 | // the content has already been replayed, applied and persisted. A |
| 207 | // gorm UPDATE matching zero rows returns no error, so a *Document |
| 208 | // carrying a stranger's OwnerID used to replay and persist the |
| 209 | // stranger's content into the real owner's document, then return nil: |
| 210 | // only the timestamp was refused, silently, while "hello world" |
| 211 | // landed in the victim's log. |
| 212 | // |
| 213 | // This asserts the actual contract: a mismatched owner must refuse |
| 214 | // the whole operation, up front, with a real error — not partially |
| 215 | // apply and then fail silently on the last write. It checks all four |
| 216 | // legs: the call returns an error, the victim's content never changes |
| 217 | // (via Store.Text, reading the real CRDT replay — not a column that |
| 218 | // could look right while the log underneath it is wrong), the |
| 219 | // victim's UpdatedAt never moves (fix round 1's original property, |
| 220 | // kept), and — so this test cannot pass by making ApplyUpdate refuse |
| 221 | // everything — the legitimate owner's own ApplyUpdate still works end |
| 222 | // to end. SKILL.md §3, verbatim: "Scope the write, not just the read |
| 223 | // that loaded the row." |
| 224 | func TestApplyUpdateRefusesAnotherOwnersDocument(t *testing.T) { |
| 225 | app, err := docs.NewForTest(t) |
| 226 | if err != nil { |
| 227 | t.Fatalf("NewForTest: %v", err) |
| 228 | } |
| 229 | ctx := context.Background() |
| 230 | owner := app.TestPerson(t, "owner@example.com") |
| 231 | |
| 232 | doc, err := app.Store().Create(ctx, owner, "Real owner's document") |
| 233 | if err != nil { |
| 234 | t.Fatalf("Create: %v", err) |
| 235 | } |
| 236 | before, err := app.Store().Get(ctx, owner, doc.Sqid) |
| 237 | if err != nil { |
| 238 | t.Fatalf("Get (before): %v", err) |
| 239 | } |
| 240 | textBefore, err := app.Store().Text(ctx, before) |
| 241 | if err != nil { |
| 242 | t.Fatalf("Text (before): %v", err) |
| 243 | } |
| 244 | |
| 245 | // A real gap so a spurious bump would be visible against the |
| 246 | // clock, not hidden by two timestamps landing in the same tick. |
| 247 | time.Sleep(5 * time.Millisecond) |
| 248 | |
| 249 | // Sharing re-pointed this test, and the re-pointing is the finding. |
| 250 | // |
| 251 | // It used to hand ApplyUpdate a *Document whose OwnerID had been |
| 252 | // tampered to a person who does not exist, and assert the write was |
| 253 | // refused. That worked because ApplyUpdate authorized the struct it was |
| 254 | // given. It no longer does: it authorizes the ACTING person against the |
| 255 | // row in the database, so a tampered OwnerID on a caller's copy is now |
| 256 | // simply ignored — which is the safer behaviour, and means the old |
| 257 | // assertion would pass for the wrong reason or fail for one. |
| 258 | // |
| 259 | // So the hazard is expressed the way it actually exists now: a stranger, |
| 260 | // holding a perfectly accurate *Document, is refused, and nothing is |
| 261 | // written. The tampered struct stays, unused by the authorization, to |
| 262 | // prove exactly that — a caller cannot make themselves the owner by |
| 263 | // editing a field on their own copy. |
| 264 | tampered := *doc |
| 265 | tampered.OwnerID = owner + 1_000_000 // guaranteed to belong to nobody |
| 266 | stranger := app.TestActorForPerson(t, app.TestPerson(t, "stranger@example.com")) |
| 267 | |
| 268 | // Fragment fixture, not ReadFixture's plain-Y.Text one: this test's |
| 269 | // content assertions below go through Store.Text, which reads root |
| 270 | // "prosemirror" now — see TestTextComesFromTheCore's own comment. |
| 271 | update := docs.ReadFragmentFixture(t, "hello") |
| 272 | if _, err := app.Store().ApplyUpdate(ctx, &tampered, stranger, update); err == nil { |
| 273 | t.Fatal("ApplyUpdate returned nil for a stranger — want a refusal, before anything is written") |
| 274 | } |
| 275 | |
| 276 | after, err := app.Store().Get(ctx, owner, doc.Sqid) |
| 277 | if err != nil { |
| 278 | t.Fatalf("Get (after): %v", err) |
| 279 | } |
| 280 | textAfter, err := app.Store().Text(ctx, after) |
| 281 | if err != nil { |
| 282 | t.Fatalf("Text (after): %v", err) |
| 283 | } |
| 284 | if textAfter != textBefore { |
| 285 | t.Errorf("victim document's content changed from %q to %q — the stranger's update landed despite the mismatched owner", textBefore, textAfter) |
| 286 | } |
| 287 | if !after.UpdatedAt.Equal(before.UpdatedAt) { |
| 288 | t.Errorf("UpdatedAt moved from %v to %v even though ApplyUpdate was called with a mismatched owner (%d instead of the document's real owner %d)", |
| 289 | before.UpdatedAt, after.UpdatedAt, tampered.OwnerID, owner) |
| 290 | } |
| 291 | |
| 292 | // Prove the fix does not break the working path: the legitimate |
| 293 | // owner's own ApplyUpdate must still apply end to end. |
| 294 | if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil { |
| 295 | t.Fatalf("ApplyUpdate (legitimate owner): %v", err) |
| 296 | } |
| 297 | legit, err := app.Store().Text(ctx, doc) |
| 298 | if err != nil { |
| 299 | t.Fatalf("Text (legitimate owner): %v", err) |
| 300 | } |
| 301 | if legit != "hello world" { |
| 302 | t.Errorf("legitimate owner's ApplyUpdate did not persist: Text = %q, want %q", legit, "hello world") |
| 303 | } |
| 304 | } |
| 305 | |