| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "mime/multipart" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // The sheet list is a list of sheets. |
| 15 | // |
| 16 | // It used to open with two stacked forms — a title field with a Create |
| 17 | // button, then a dashed drop zone with a file input and four lines of |
| 18 | // limits — so the sheets themselves started below both. These tests pin |
| 19 | // the removal, because the easiest way to undo it is for somebody to |
| 20 | // add "just one small form" back to the top of the page. |
| 21 | |
| 22 | // signedInListBody renders the list for a fresh signed-in member. |
| 23 | // preview_test.go's listBody takes an app and cookies because the |
| 24 | // thumbnail tests need sheets in it; these tests want the chrome around |
| 25 | // an empty one, so this wraps the sign-in rather than duplicating it. |
| 26 | func signedInListBody(t *testing.T) string { |
| 27 | t.Helper() |
| 28 | app, cookies := signIn(t) |
| 29 | return listBody(t, app, cookies) |
| 30 | } |
| 31 | |
| 32 | // dropOverlay returns the sheet list's body up to the drop overlay, and |
| 33 | // the overlay's own start tag. Everything before the overlay is what |
| 34 | // the page actually renders; the overlay itself is hidden until a file |
| 35 | // is dragged over the window. |
| 36 | func dropOverlay(t *testing.T, body string) (inFlow, tag string) { |
| 37 | t.Helper() |
| 38 | start := strings.Index(body, "<form data-drop") |
| 39 | if start < 0 { |
| 40 | t.Fatal("the sheet list has no drop overlay — dropping a file on this page is the affordance it traded its drop box for") |
| 41 | } |
| 42 | end := strings.Index(body[start:], ">") |
| 43 | if end < 0 { |
| 44 | t.Fatal("the drop overlay's start tag never closes") |
| 45 | } |
| 46 | return body[:start], body[start : start+end+1] |
| 47 | } |
| 48 | |
| 49 | func TestSheetListCarriesNoInlineForms(t *testing.T) { |
| 50 | body := signedInListBody(t) |
| 51 | |
| 52 | // The title field is simply gone. A sheet is named by renaming it, |
| 53 | // on the sheet itself, so there is nothing left for this to be. |
| 54 | if strings.Contains(body, `name="title"`) { |
| 55 | t.Error(`the sheet list is carrying name="title" again — naming a sheet is a rename, in the top bar of the sheet`) |
| 56 | } |
| 57 | |
| 58 | // The file input is back, but only inside the overlay. Anything of |
| 59 | // it appearing BEFORE the overlay's start tag is a box in the page |
| 60 | // again, which is the thing this change removed. |
| 61 | inFlow, _ := dropOverlay(t, body) |
| 62 | for _, gone := range []string{ |
| 63 | `type="file"`, |
| 64 | `enctype="multipart/form-data"`, |
| 65 | "data-drop", |
| 66 | } { |
| 67 | if strings.Contains(inFlow, gone) { |
| 68 | t.Errorf("%s renders in the flow of the sheet list — the drop target is an overlay, not a box above the sheets", gone) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // The overlay is a real import form that happens to be invisible until |
| 74 | // a drag brings it up. Both halves of that matter: hidden at rest, so |
| 75 | // it costs the list no layout and no tab stop, and a genuine multipart |
| 76 | // POST to /sheets/import, so a drop is the ordinary upload and there is |
| 77 | // no second code path on the server. |
| 78 | func TestTheDropOverlayIsHiddenAtRest(t *testing.T) { |
| 79 | _, tag := dropOverlay(t, signedInListBody(t)) |
| 80 | |
| 81 | for _, want := range []string{ |
| 82 | "hidden", |
| 83 | `method="post"`, |
| 84 | `action="/sheets/import"`, |
| 85 | `enctype="multipart/form-data"`, |
| 86 | } { |
| 87 | if !strings.Contains(tag, want) { |
| 88 | t.Errorf("the drop overlay's start tag is missing %s: %s", want, tag) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // The overlay is nothing without drop.js: it is `hidden`, and only that |
| 94 | // script ever takes the attribute off. A page that renders the markup |
| 95 | // and does not load the script has an import affordance that can never |
| 96 | // appear. |
| 97 | func TestTheSheetListLoadsDropJS(t *testing.T) { |
| 98 | // By prefix, not by filename: assets are served fingerprinted, so |
| 99 | // the tag reads /static/drop.<hash>.js and matching the literal |
| 100 | // name passes only until the file next changes. |
| 101 | body := signedInListBody(t) |
| 102 | if !strings.Contains(body, `src="/static/drop.`) { |
| 103 | t.Error("the sheet list renders the drop overlay but never loads drop.js, so nothing will ever reveal it") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The paint that keeps the overlay hidden. |
| 108 | // |
| 109 | // [rst-modal-overlay] sets display:flex, and an author declaration beats |
| 110 | // the user agent's [hidden] { display: none } however weak its selector |
| 111 | // looks. Drop the reinstating rule and the overlay covers the sheet list |
| 112 | // permanently — worst with scripting off, where nothing would ever come |
| 113 | // along to hide it again. The markup test above passes either way, so |
| 114 | // this reads the stylesheet. |
| 115 | func TestTheDropOverlayStaysHiddenInCSS(t *testing.T) { |
| 116 | css, err := os.ReadFile(filepath.Join("static", "sheets.css")) |
| 117 | if err != nil { |
| 118 | t.Fatalf("read sheets.css: %v", err) |
| 119 | } |
| 120 | if !strings.Contains(string(css), "[data-drop-page][hidden]") { |
| 121 | t.Error("sheets.css no longer reinstates display:none for the hidden drop overlay — it will cover the sheet list") |
| 122 | } |
| 123 | |
| 124 | // The drop BOX's paint must exclude the overlay, which carries |
| 125 | // data-drop too. [data-drop][data-drop-over] is 0-2-0 against |
| 126 | // [rst-modal-overlay]'s 0-1-0, so without the exclusion the scrim is |
| 127 | // painted accent-soft and the sheet list disappears behind a flat |
| 128 | // blue screen mid-drag. A browser found that; nothing here could. |
| 129 | if !strings.Contains(string(css), "[data-drop]:not([data-drop-page])") { |
| 130 | t.Error("the drop box's paint no longer excludes the overlay — it will out-specify the modal scrim and cover the list") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // Create is a real form posting to /sheets, in the top bar. A link |
| 135 | // would be wrong twice over: it makes a sheet, so it must not be a GET, |
| 136 | // and it has to work with scripting off. |
| 137 | func TestCreateLivesInTheTopBar(t *testing.T) { |
| 138 | body := signedInListBody(t) |
| 139 | |
| 140 | bar := body[:strings.Index(body, `<main`)] |
| 141 | if !strings.Contains(bar, `action="/sheets"`) || !strings.Contains(bar, `type="submit"`) { |
| 142 | t.Error("the top bar has no Create form") |
| 143 | } |
| 144 | if !strings.Contains(bar, `href="/sheets/import"`) { |
| 145 | t.Error("the top bar has no Import link") |
| 146 | } |
| 147 | // Beside the account menu, not adrift somewhere else in the shell. |
| 148 | if strings.Index(bar, `action="/sheets"`) > strings.Index(bar, `action="/signout"`) { |
| 149 | t.Error("Create renders after the account menu, not beside it") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // The empty state is the one screen with nothing to do but say what to |
| 154 | // do next, and it used to say "make one above" — which pointed at the |
| 155 | // form this change removed. |
| 156 | func TestEmptyStateDoesNotPointAtAFormThatIsGone(t *testing.T) { |
| 157 | body := signedInListBody(t) |
| 158 | if !strings.Contains(body, "No sheets yet") { |
| 159 | t.Fatal("no empty state on a list with no sheets") |
| 160 | } |
| 161 | if strings.Contains(body, "above and it opens") || strings.Contains(body, "Make one above") { |
| 162 | t.Error("the empty state still points at the removed form") |
| 163 | } |
| 164 | if !strings.Contains(body, `href="/sheets/import"`) { |
| 165 | t.Error("the empty state offers no way to import, which is the one route a person with no sheets wants") |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // /sheets/import must reach the import page, not be parsed as a sheet |
| 170 | // called "import". chi prefers a static segment over {sheetID} whatever |
| 171 | // the registration order, so this passes today — it is pinned because a |
| 172 | // route that only works by resolution order is one refactor from a 404 |
| 173 | // that looks like a missing sheet. |
| 174 | func TestImportPathIsNotASheetID(t *testing.T) { |
| 175 | app, cookies := signIn(t) |
| 176 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets/import", nil), cookies)) |
| 177 | if rec.Code != http.StatusOK { |
| 178 | t.Fatalf("GET /sheets/import = %d, want 200", rec.Code) |
| 179 | } |
| 180 | if !strings.Contains(rec.Body.String(), `action="/sheets/import"`) { |
| 181 | t.Error("/sheets/import did not render the import form") |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // A refused upload re-renders the import page, where the form is — |
| 186 | // never the sheet list, which no longer has one to try again with. |
| 187 | func TestRefusalRendersOnTheImportPage(t *testing.T) { |
| 188 | app, cookies := signIn(t) |
| 189 | |
| 190 | // An empty multipart body: the shape a submit with nothing chosen |
| 191 | // actually arrives in. A urlencoded post would refuse too, but with |
| 192 | // "could not be read" rather than "choose a spreadsheet", which is |
| 193 | // a different sentence about a different mistake. |
| 194 | var buf bytes.Buffer |
| 195 | w := multipart.NewWriter(&buf) |
| 196 | if err := w.Close(); err != nil { |
| 197 | t.Fatalf("close writer: %v", err) |
| 198 | } |
| 199 | req := httptest.NewRequest(http.MethodPost, "/sheets/import", &buf) |
| 200 | req.Header.Set("Content-Type", w.FormDataContentType()) |
| 201 | |
| 202 | rec := do(t, app, as(req, cookies)) |
| 203 | if rec.Code != http.StatusBadRequest { |
| 204 | t.Fatalf("import with no file = %d, want 400", rec.Code) |
| 205 | } |
| 206 | body := rec.Body.String() |
| 207 | if !strings.Contains(body, refusalNoFile) { |
| 208 | t.Error("the refusal is not on the page") |
| 209 | } |
| 210 | if !strings.Contains(body, `action="/sheets/import"`) { |
| 211 | t.Error("the refusal rendered somewhere without the form to retry from") |
| 212 | } |
| 213 | if strings.Contains(body, "All sheets</h1>") { |
| 214 | t.Error("the refusal rendered on the sheet list") |
| 215 | } |
| 216 | } |
| 217 | |