| 1 | // Package memoriatest tests the app from the outside: real HTTP requests |
| 2 | // against the real router, wired as cmd/memoria/main.go wires it. |
| 3 | // |
| 4 | // The one difference from production is auth: these apps are built via |
| 5 | // memoria.AppForTest, which sets an explicit allowUnauthenticated flag. |
| 6 | // |
| 7 | // An earlier version relied on requireSession falling through when auth |
| 8 | // was nil, and justified it by naming a test that did not exist — the |
| 9 | // name survived a rename and lived on only in comments. Deleting main's |
| 10 | // UseAuth call therefore compiled and left the suite green while serving |
| 11 | // every route unauthenticated. requireSession now fails closed, the seam |
| 12 | // is named in the constructor, and TestUnauthenticatedAppRefuses and |
| 13 | // TestProductionDoesNotUseTheTestConstructor hold both ends. |
| 14 | package memoriatest |
| 15 | |
| 16 | import ( |
| 17 | "bytes" |
| 18 | "io" |
| 19 | "log/slog" |
| 20 | "mime/multipart" |
| 21 | "net/http" |
| 22 | "net/http/httptest" |
| 23 | "net/url" |
| 24 | "path/filepath" |
| 25 | "regexp" |
| 26 | "strings" |
| 27 | "testing" |
| 28 | |
| 29 | "amadan.net/rastrillo/rastrillo/db" |
| 30 | |
| 31 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 32 | "amadan.net/oficina/memoria/internal/memoria/seal" |
| 33 | ) |
| 34 | |
| 35 | const testOrigin = "http://app.test" |
| 36 | |
| 37 | // testDEK is a fixed key. Fixed so a failure is reproducible; never a |
| 38 | // real one, and the plaintext gate proves it is actually being used. |
| 39 | func testDEK(t *testing.T) *seal.Sealer { |
| 40 | t.Helper() |
| 41 | d := make([]byte, 32) |
| 42 | for i := range d { |
| 43 | d[i] = byte(i * 7) |
| 44 | } |
| 45 | s, err := seal.New(d) |
| 46 | if err != nil { |
| 47 | t.Fatalf("seal.New: %v", err) |
| 48 | } |
| 49 | return s |
| 50 | } |
| 51 | |
| 52 | // newApp builds the whole app per test over a fresh temp database. |
| 53 | func newApp(t *testing.T) http.Handler { |
| 54 | h, _ := newAppAt(t, filepath.Join(t.TempDir(), "app.db")) |
| 55 | return h |
| 56 | } |
| 57 | |
| 58 | // newAppAt is newApp with the database path exposed, so a test can read |
| 59 | // the raw file back and grep it. |
| 60 | func newAppAt(t *testing.T, path string) (http.Handler, string) { |
| 61 | t.Helper() |
| 62 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 63 | d, err := db.Open(path, logger) |
| 64 | if err != nil { |
| 65 | t.Fatalf("db.Open: %v", err) |
| 66 | } |
| 67 | t.Cleanup(func() { d.Close() }) |
| 68 | mux, _, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 69 | if err != nil { |
| 70 | t.Fatalf("AppForTest: %v", err) |
| 71 | } |
| 72 | return mux, path |
| 73 | } |
| 74 | |
| 75 | // newAppWithStore is newApp with the store exposed, for tests about |
| 76 | // ingestion itself rather than about the pages that call it. Connectors |
| 77 | // reach the store directly — there is no form that carries a Notion |
| 78 | // page's ancestry — so the paths they use need a way to be tested. |
| 79 | func newAppWithStore(t *testing.T) (http.Handler, *memoria.Store, string) { |
| 80 | t.Helper() |
| 81 | path := filepath.Join(t.TempDir(), "app.db") |
| 82 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 83 | d, err := db.Open(path, logger) |
| 84 | if err != nil { |
| 85 | t.Fatalf("db.Open: %v", err) |
| 86 | } |
| 87 | t.Cleanup(func() { d.Close() }) |
| 88 | mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 89 | if err != nil { |
| 90 | t.Fatalf("AppForTest: %v", err) |
| 91 | } |
| 92 | return mux, a.Store(), path |
| 93 | } |
| 94 | |
| 95 | // newAppWithLog is newApp with the log captured, for the gate that |
| 96 | // asserts no content reaches a log line. |
| 97 | // |
| 98 | // The database handle comes back too, and it is not a convenience. The |
| 99 | // log line that gate is aimed at is the one createDocument writes when a |
| 100 | // store FAILS, and nothing a well-formed request can carry makes it fail |
| 101 | // — a binary file is refused before it and a text file stores. Closing |
| 102 | // this handle under the running app is how that path is reached, so the |
| 103 | // gate can watch the line it names actually run. Closing it twice is |
| 104 | // safe: db.Close is sql.DB.Close, which is idempotent, and the cleanup |
| 105 | // below still runs. |
| 106 | func newAppWithLog(t *testing.T) (http.Handler, *bytes.Buffer, *db.DB) { |
| 107 | t.Helper() |
| 108 | var buf bytes.Buffer |
| 109 | logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) |
| 110 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 111 | if err != nil { |
| 112 | t.Fatalf("db.Open: %v", err) |
| 113 | } |
| 114 | t.Cleanup(func() { d.Close() }) |
| 115 | mux, _, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 116 | if err != nil { |
| 117 | t.Fatalf("AppForTest: %v", err) |
| 118 | } |
| 119 | return mux, &buf, d |
| 120 | } |
| 121 | |
| 122 | // newProductionApp builds the app the way main.go does — no test seam — |
| 123 | // so a gate can prove the routes refuse without sign-in. |
| 124 | func newProductionApp(t *testing.T) http.Handler { |
| 125 | t.Helper() |
| 126 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 127 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 128 | if err != nil { |
| 129 | t.Fatalf("db.Open: %v", err) |
| 130 | } |
| 131 | t.Cleanup(func() { d.Close() }) |
| 132 | mux, _, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| 133 | if err != nil { |
| 134 | t.Fatalf("App: %v", err) |
| 135 | } |
| 136 | return mux |
| 137 | } |
| 138 | |
| 139 | func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| 140 | t.Helper() |
| 141 | rec := httptest.NewRecorder() |
| 142 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| 143 | return rec |
| 144 | } |
| 145 | |
| 146 | func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| 147 | t.Helper() |
| 148 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 149 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 150 | req.Header.Set("Origin", testOrigin) |
| 151 | rec := httptest.NewRecorder() |
| 152 | h.ServeHTTP(rec, req) |
| 153 | return rec |
| 154 | } |
| 155 | |
| 156 | // store posts a document the way the form does — multipart, because the |
| 157 | // ingest handler parses multipart to accept a file alongside the text. |
| 158 | func store(t *testing.T, h http.Handler, title, body string) *httptest.ResponseRecorder { |
| 159 | t.Helper() |
| 160 | var buf bytes.Buffer |
| 161 | mw := multipart.NewWriter(&buf) |
| 162 | if err := mw.WriteField("title", title); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | if err := mw.WriteField("body", body); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | mw.Close() |
| 169 | req := httptest.NewRequest(http.MethodPost, "/documents", &buf) |
| 170 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 171 | req.Header.Set("Origin", testOrigin) |
| 172 | rec := httptest.NewRecorder() |
| 173 | h.ServeHTTP(rec, req) |
| 174 | return rec |
| 175 | } |
| 176 | |
| 177 | // upload posts a document as a file, so it carries a filename — the |
| 178 | // stable key that decides whether a re-ingest updates or duplicates. |
| 179 | func upload(t *testing.T, h http.Handler, filename, body string) *httptest.ResponseRecorder { |
| 180 | t.Helper() |
| 181 | return submit(t, h, "", "", file{filename, body}) |
| 182 | } |
| 183 | |
| 184 | // file is one part of a submission. The zero title/body of submit plus |
| 185 | // several of these is a drop; a title and body with no files is a paste. |
| 186 | type file struct{ name, body string } |
| 187 | |
| 188 | // submit posts the upload form exactly as a browser would: the title and |
| 189 | // body fields, then one part named "file" per file. Several parts under |
| 190 | // ONE name is what `multiple` produces and what createDocument iterates, |
| 191 | // so this is the scriptless path with no script involved — which is the |
| 192 | // only way Go can exercise it. |
| 193 | func submit(t *testing.T, h http.Handler, title, body string, files ...file) *httptest.ResponseRecorder { |
| 194 | t.Helper() |
| 195 | var buf bytes.Buffer |
| 196 | mw := multipart.NewWriter(&buf) |
| 197 | if err := mw.WriteField("title", title); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if err := mw.WriteField("body", body); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | for _, f := range files { |
| 204 | w, err := mw.CreateFormFile("file", f.name) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if _, err := w.Write([]byte(f.body)); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | } |
| 212 | mw.Close() |
| 213 | req := httptest.NewRequest(http.MethodPost, "/documents", &buf) |
| 214 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 215 | req.Header.Set("Origin", testOrigin) |
| 216 | rec := httptest.NewRecorder() |
| 217 | h.ServeHTTP(rec, req) |
| 218 | return rec |
| 219 | } |
| 220 | |
| 221 | // submitJSON posts what the upload tray posts: the same multipart body |
| 222 | // at the same route, with an Accept header asking for the judgement |
| 223 | // instead of a redirect and a flash cookie. |
| 224 | // |
| 225 | // It goes through the SAME handler as submit(), and that is the point of |
| 226 | // having it here rather than a second fixture: every test that uses it is |
| 227 | // asking whether the asynchronous path judges a file exactly as the |
| 228 | // scriptless one does. |
| 229 | // |
| 230 | // The tray sends one file per request. This takes several so the caps — |
| 231 | // which are submission-wide and which the tray never triggers on its own |
| 232 | // — can still be aimed at this path, because a browser is not the only |
| 233 | // thing that can send an Accept header. |
| 234 | func submitJSON(t *testing.T, h http.Handler, files ...file) *httptest.ResponseRecorder { |
| 235 | t.Helper() |
| 236 | var buf bytes.Buffer |
| 237 | mw := multipart.NewWriter(&buf) |
| 238 | for _, f := range files { |
| 239 | w, err := mw.CreateFormFile("file", f.name) |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if _, err := w.Write([]byte(f.body)); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | } |
| 247 | mw.Close() |
| 248 | req := httptest.NewRequest(http.MethodPost, "/documents", &buf) |
| 249 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 250 | req.Header.Set("Origin", testOrigin) |
| 251 | req.Header.Set("Accept", "application/json") |
| 252 | rec := httptest.NewRecorder() |
| 253 | h.ServeHTTP(rec, req) |
| 254 | return rec |
| 255 | } |
| 256 | |
| 257 | // follow does what a browser does with a 303: it GETs the Location, |
| 258 | // carrying back whatever cookies the response set. |
| 259 | // |
| 260 | // The cookie matters. The sentence that NAMES refused files travels in a |
| 261 | // one-shot flash cookie rather than the URL, because a filename is |
| 262 | // content and a redirect target is the sort of thing platform logging |
| 263 | // keeps. A test that does not carry the cookie sees only the counts, so |
| 264 | // every assertion about naming has to come through here. |
| 265 | func follow(t *testing.T, h http.Handler, rec *httptest.ResponseRecorder) *httptest.ResponseRecorder { |
| 266 | t.Helper() |
| 267 | loc := rec.Header().Get("Location") |
| 268 | if loc == "" { |
| 269 | t.Fatalf("the response set no Location (status %d), so there is nothing to follow", rec.Code) |
| 270 | } |
| 271 | req := httptest.NewRequest(http.MethodGet, loc, nil) |
| 272 | for _, c := range rec.Result().Cookies() { |
| 273 | req.AddCookie(c) |
| 274 | } |
| 275 | out := httptest.NewRecorder() |
| 276 | h.ServeHTTP(out, req) |
| 277 | return out |
| 278 | } |
| 279 | |
| 280 | // outcome posts nothing: it follows a submission's redirect and returns |
| 281 | // the sentence the person is actually shown. |
| 282 | func outcome(t *testing.T, h http.Handler, rec *httptest.ResponseRecorder) string { |
| 283 | t.Helper() |
| 284 | return flashText(t, follow(t, h, rec).Body.String()) |
| 285 | } |
| 286 | |
| 287 | // storedTitles returns the titles in the document list, scoped to the |
| 288 | // table and nothing else. |
| 289 | // |
| 290 | // Scoped because the front page renders the search results, the flash |
| 291 | // and the whole document list, so "the filename appears in the HTML" is |
| 292 | // a false pass waiting to happen — the flash alone says "Stored 4 |
| 293 | // documents", which contains "4 documents", which is also what the |
| 294 | // list's own heading says. Only the table rows are evidence that a |
| 295 | // document exists. |
| 296 | func storedTitles(t *testing.T, body string) []string { |
| 297 | t.Helper() |
| 298 | i := strings.Index(body, "<table rst-table") |
| 299 | if i < 0 { |
| 300 | return nil // no table at all: nothing is stored |
| 301 | } |
| 302 | j := strings.Index(body[i:], "</table>") |
| 303 | if j < 0 { |
| 304 | t.Fatal("the document table never closes") |
| 305 | } |
| 306 | rows := regexp.MustCompile(`<td><a href="/documents/\d+">([^<]*)</a></td>`).FindAllStringSubmatch(body[i:i+j], -1) |
| 307 | out := make([]string, 0, len(rows)) |
| 308 | for _, r := range rows { |
| 309 | out = append(out, unescape(r[1])) |
| 310 | } |
| 311 | return out |
| 312 | } |
| 313 | |
| 314 | // storedCount is storedTitles' length, said as a count. |
| 315 | func storedCount(t *testing.T, h http.Handler) int { |
| 316 | t.Helper() |
| 317 | return len(storedTitles(t, get(t, h, "/").Body.String())) |
| 318 | } |
| 319 | |