oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

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.
14package memoriatest
15
16import (
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
35const 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.
39func 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.
53func 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.
60func 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.
79func 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.
106func 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.
124func 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
139func 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
146func 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.
158func 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.
179func 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.
186type 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.
193func 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.
234func 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.
265func 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.
282func 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.
296func 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.
315func storedCount(t *testing.T, h http.Handler) int {
316 t.Helper()
317 return len(storedTitles(t, get(t, h, "/").Body.String()))
318}
319