// Package memoriatest tests the app from the outside: real HTTP requests // against the real router, wired as cmd/memoria/main.go wires it. // // The one difference from production is auth: these apps are built via // memoria.AppForTest, which sets an explicit allowUnauthenticated flag. // // An earlier version relied on requireSession falling through when auth // was nil, and justified it by naming a test that did not exist — the // name survived a rename and lived on only in comments. Deleting main's // UseAuth call therefore compiled and left the suite green while serving // every route unauthenticated. requireSession now fails closed, the seam // is named in the constructor, and TestUnauthenticatedAppRefuses and // TestProductionDoesNotUseTheTestConstructor hold both ends. package memoriatest import ( "bytes" "io" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" "amadan.net/oficina/memoria/internal/memoria/seal" ) const testOrigin = "http://app.test" // testDEK is a fixed key. Fixed so a failure is reproducible; never a // real one, and the plaintext gate proves it is actually being used. func testDEK(t *testing.T) *seal.Sealer { t.Helper() d := make([]byte, 32) for i := range d { d[i] = byte(i * 7) } s, err := seal.New(d) if err != nil { t.Fatalf("seal.New: %v", err) } return s } // newApp builds the whole app per test over a fresh temp database. func newApp(t *testing.T) http.Handler { h, _ := newAppAt(t, filepath.Join(t.TempDir(), "app.db")) return h } // newAppAt is newApp with the database path exposed, so a test can read // the raw file back and grep it. func newAppAt(t *testing.T, path string) (http.Handler, string) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(path, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, _, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } return mux, path } // newAppWithStore is newApp with the store exposed, for tests about // ingestion itself rather than about the pages that call it. Connectors // reach the store directly — there is no form that carries a Notion // page's ancestry — so the paths they use need a way to be tested. func newAppWithStore(t *testing.T) (http.Handler, *memoria.Store, string) { t.Helper() path := filepath.Join(t.TempDir(), "app.db") logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(path, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } return mux, a.Store(), path } // newAppWithLog is newApp with the log captured, for the gate that // asserts no content reaches a log line. // // The database handle comes back too, and it is not a convenience. The // log line that gate is aimed at is the one createDocument writes when a // store FAILS, and nothing a well-formed request can carry makes it fail // — a binary file is refused before it and a text file stores. Closing // this handle under the running app is how that path is reached, so the // gate can watch the line it names actually run. Closing it twice is // safe: db.Close is sql.DB.Close, which is idempotent, and the cleanup // below still runs. func newAppWithLog(t *testing.T) (http.Handler, *bytes.Buffer, *db.DB) { t.Helper() var buf bytes.Buffer logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, _, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } return mux, &buf, d } // newProductionApp builds the app the way main.go does — no test seam — // so a gate can prove the routes refuse without sign-in. func newProductionApp(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, _, err := memoria.App(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } return mux } func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // store posts a document the way the form does — multipart, because the // ingest handler parses multipart to accept a file alongside the text. func store(t *testing.T, h http.Handler, title, body string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) if err := mw.WriteField("title", title); err != nil { t.Fatal(err) } if err := mw.WriteField("body", body); err != nil { t.Fatal(err) } mw.Close() req := httptest.NewRequest(http.MethodPost, "/documents", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // upload posts a document as a file, so it carries a filename — the // stable key that decides whether a re-ingest updates or duplicates. func upload(t *testing.T, h http.Handler, filename, body string) *httptest.ResponseRecorder { t.Helper() return submit(t, h, "", "", file{filename, body}) } // file is one part of a submission. The zero title/body of submit plus // several of these is a drop; a title and body with no files is a paste. type file struct{ name, body string } // submit posts the upload form exactly as a browser would: the title and // body fields, then one part named "file" per file. Several parts under // ONE name is what `multiple` produces and what createDocument iterates, // so this is the scriptless path with no script involved — which is the // only way Go can exercise it. func submit(t *testing.T, h http.Handler, title, body string, files ...file) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) if err := mw.WriteField("title", title); err != nil { t.Fatal(err) } if err := mw.WriteField("body", body); err != nil { t.Fatal(err) } for _, f := range files { w, err := mw.CreateFormFile("file", f.name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(f.body)); err != nil { t.Fatal(err) } } mw.Close() req := httptest.NewRequest(http.MethodPost, "/documents", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // submitJSON posts what the upload tray posts: the same multipart body // at the same route, with an Accept header asking for the judgement // instead of a redirect and a flash cookie. // // It goes through the SAME handler as submit(), and that is the point of // having it here rather than a second fixture: every test that uses it is // asking whether the asynchronous path judges a file exactly as the // scriptless one does. // // The tray sends one file per request. This takes several so the caps — // which are submission-wide and which the tray never triggers on its own // — can still be aimed at this path, because a browser is not the only // thing that can send an Accept header. func submitJSON(t *testing.T, h http.Handler, files ...file) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for _, f := range files { w, err := mw.CreateFormFile("file", f.name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(f.body)); err != nil { t.Fatal(err) } } mw.Close() req := httptest.NewRequest(http.MethodPost, "/documents", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Origin", testOrigin) req.Header.Set("Accept", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // follow does what a browser does with a 303: it GETs the Location, // carrying back whatever cookies the response set. // // The cookie matters. The sentence that NAMES refused files travels in a // one-shot flash cookie rather than the URL, because a filename is // content and a redirect target is the sort of thing platform logging // keeps. A test that does not carry the cookie sees only the counts, so // every assertion about naming has to come through here. func follow(t *testing.T, h http.Handler, rec *httptest.ResponseRecorder) *httptest.ResponseRecorder { t.Helper() loc := rec.Header().Get("Location") if loc == "" { t.Fatalf("the response set no Location (status %d), so there is nothing to follow", rec.Code) } req := httptest.NewRequest(http.MethodGet, loc, nil) for _, c := range rec.Result().Cookies() { req.AddCookie(c) } out := httptest.NewRecorder() h.ServeHTTP(out, req) return out } // outcome posts nothing: it follows a submission's redirect and returns // the sentence the person is actually shown. func outcome(t *testing.T, h http.Handler, rec *httptest.ResponseRecorder) string { t.Helper() return flashText(t, follow(t, h, rec).Body.String()) } // storedTitles returns the titles in the document list, scoped to the // table and nothing else. // // Scoped because the front page renders the search results, the flash // and the whole document list, so "the filename appears in the HTML" is // a false pass waiting to happen — the flash alone says "Stored 4 // documents", which contains "4 documents", which is also what the // list's own heading says. Only the table rows are evidence that a // document exists. func storedTitles(t *testing.T, body string) []string { t.Helper() i := strings.Index(body, "") if j < 0 { t.Fatal("the document table never closes") } rows := regexp.MustCompile(``).FindAllStringSubmatch(body[i:i+j], -1) out := make([]string, 0, len(rows)) for _, r := range rows { out = append(out, unescape(r[1])) } return out } // storedCount is storedTitles' length, said as a count. func storedCount(t *testing.T, h http.Handler) int { t.Helper() return len(storedTitles(t, get(t, h, "/").Body.String())) }
([^<]*)