| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 12 | ) |
| 13 | |
| 14 | func TestCellBatchPublishesOnlyFinalCommittedValues(t *testing.T) { |
| 15 | app, cookies := signIn(t) |
| 16 | s, path := newSheet(t, app, cookies, "Budget") |
| 17 | sub := app.hub.Subscribe(s.TabID) |
| 18 | defer sub.Close() |
| 19 | rec := postCell(t, app, cookies, path+"/batch", `{"cells":[{"r":1,"c":0,"raw":"=A1*2"},{"r":0,"c":0,"raw":"21"}],"client":"batch-client"}`) |
| 20 | if rec.Code != http.StatusOK { |
| 21 | t.Fatalf("batch %d: %s", rec.Code, rec.Body.String()) |
| 22 | } |
| 23 | got := decodeGrid(t, rec) |
| 24 | if len(got.Cells) != 2 || got.Client != "batch-client" { |
| 25 | t.Fatalf("response %+v", got) |
| 26 | } |
| 27 | for _, c := range got.Cells { |
| 28 | if c.R == 1 && c.Display != "42" { |
| 29 | t.Fatalf("stale formula %+v", c) |
| 30 | } |
| 31 | } |
| 32 | select { |
| 33 | case m := <-sub.C(): |
| 34 | if string(m.Data) != strings.TrimSpace(rec.Body.String()) { |
| 35 | t.Fatalf("publication differs: %s", m.Data) |
| 36 | } |
| 37 | default: |
| 38 | t.Fatal("no publication") |
| 39 | } |
| 40 | select { |
| 41 | case <-sub.C(): |
| 42 | t.Fatal("intermediate publication") |
| 43 | default: |
| 44 | } |
| 45 | var events int64 |
| 46 | app.db.Model(&store.Event{}).Where("tab_id = ? AND kind = ?", s.TabID, store.KindInput).Count(&events) |
| 47 | if events != 2 { |
| 48 | t.Fatalf("input events = %d", events) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestCellBatchRejectsInvalidRequestsWithoutWrites(t *testing.T) { |
| 53 | for _, body := range []string{ |
| 54 | `{}`, `{"cells":[]}`, `{"cells":[{"r":0,"c":0,"raw":"7"}]} {}`, |
| 55 | `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":0,"c":0,"raw":"8"}]}`, |
| 56 | `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":-1,"c":0,"raw":"8"}]}`, |
| 57 | `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":1,"c":0,"raw":"=SUM(("}]}`, |
| 58 | `{"cells":[{"r":0,"c":0,"raw":"7","fromR":0}]}`, |
| 59 | `{"cells":[{"r":0,"c":0,"raw":"7","fromR":-1,"fromC":0}]}`, |
| 60 | `{"cells":[{"r":0,"c":0,"raw":"` + strings.Repeat("x", maxRawBytes+1) + `"}]}`, |
| 61 | `{"cells":[{"r":0,"c":0,"raw":"` + strings.Repeat("x", 1<<20) + `"}]}`, |
| 62 | } { |
| 63 | app, cookies := signIn(t) |
| 64 | s, path := newSheet(t, app, cookies, "Budget") |
| 65 | sub := app.hub.Subscribe(s.TabID) |
| 66 | rec := postCell(t, app, cookies, path+"/batch", body) |
| 67 | if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge { |
| 68 | t.Fatalf("invalid status %d", rec.Code) |
| 69 | } |
| 70 | if strings.Contains(rec.Body.String(), "SUM") || strings.Contains(rec.Body.String(), "xxx") { |
| 71 | t.Fatal("content in error") |
| 72 | } |
| 73 | snap := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies))) |
| 74 | if len(snap.Cells) != 0 { |
| 75 | t.Fatalf("invalid batch wrote %+v", snap.Cells) |
| 76 | } |
| 77 | select { |
| 78 | case <-sub.C(): |
| 79 | t.Fatal("failed batch published") |
| 80 | default: |
| 81 | } |
| 82 | sub.Close() |
| 83 | } |
| 84 | app, cookies := signIn(t) |
| 85 | _, path := newSheet(t, app, cookies, "Budget") |
| 86 | cells := make([]cellWrite, store.MaxCellBatch+1) |
| 87 | for i := range cells { |
| 88 | cells[i] = cellWrite{R: i, Raw: "1"} |
| 89 | } |
| 90 | body, _ := json.Marshal(map[string]any{"cells": cells}) |
| 91 | if rec := postCell(t, app, cookies, path+"/batch", string(body)); rec.Code != http.StatusBadRequest { |
| 92 | t.Fatalf("oversized batch = %d", rec.Code) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestCellBatchUsesCopyTranslationAndRejectsWrongTab(t *testing.T) { |
| 97 | app, cookies := signIn(t) |
| 98 | s, path := newSheet(t, app, cookies, "Budget") |
| 99 | rec := postCell(t, app, cookies, path+"/batch", `{"cells":[{"r":1,"c":0,"raw":"=A1+$B$1","fromR":0,"fromC":0}]}`) |
| 100 | if rec.Code != 200 { |
| 101 | t.Fatalf("copy %d", rec.Code) |
| 102 | } |
| 103 | if got := decodeGrid(t, rec).Cells[0].Raw; got != "=A2+$B$1" { |
| 104 | t.Fatalf("translated = %q", got) |
| 105 | } |
| 106 | other, _ := newSheet(t, app, cookies, "Other") |
| 107 | wrong := fmt.Sprintf("/api/sheets/%d/tabs/%d/cells/batch", s.ID, other.TabID) |
| 108 | if rec := postCell(t, app, cookies, wrong, `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code != 404 { |
| 109 | t.Fatalf("cross sheet = %d", rec.Code) |
| 110 | } |
| 111 | if rec := postCell(t, app, nil, path+"/batch", `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code == 200 { |
| 112 | t.Fatal("unsigned write accepted") |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestCellBatchEnforcesViewerCommenterAndStrangerIsolation(t *testing.T) { |
| 117 | for _, role := range []SheetRole{SheetViewer, SheetCommenter} { |
| 118 | app, cookies := signIn(t) |
| 119 | s, path := newSheet(t, app, cookies, "Budget") |
| 120 | guestOn(t, app, s.ID, "ada@elsewhere.example", role) |
| 121 | other, _ := newSheet(t, app, cookies, "Other") |
| 122 | guestOn(t, app, other.ID, "stranger@elsewhere.example", SheetEditor) |
| 123 | for _, email := range []string{"ada@elsewhere.example", "stranger@elsewhere.example"} { |
| 124 | guest := signInAs(t, app, email) |
| 125 | if rec := postCell(t, app, guest, path+"/batch", `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code != 404 { |
| 126 | t.Fatalf("%s %s batch = %d", role, email, rec.Code) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |