| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "html" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "path/filepath" |
| 13 | "regexp" |
| 14 | "strings" |
| 15 | "sync/atomic" |
| 16 | "testing" |
| 17 | |
| 18 | "amadan.net/rastrillo/rastrillo/db" |
| 19 | |
| 20 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 21 | ) |
| 22 | |
| 23 | // capturingMailer keeps the last magic link it was asked to send, so a |
| 24 | // gate can follow it the way a person would. The link is a live |
| 25 | // credential; it never leaves the test process. |
| 26 | // |
| 27 | // atomic.Value rather than a mutex, because this package already declares |
| 28 | // a helper called `sync` (connector_test.go) and a file importing the |
| 29 | // stdlib package of that name will not compile beside it. |
| 30 | type capturingMailer struct { |
| 31 | body atomic.Value // string |
| 32 | } |
| 33 | |
| 34 | func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error { |
| 35 | m.body.Store(body) |
| 36 | return nil |
| 37 | } |
| 38 | |
| 39 | func (m *capturingMailer) text() string { |
| 40 | if s, ok := m.body.Load().(string); ok { |
| 41 | return s |
| 42 | } |
| 43 | return "" |
| 44 | } |
| 45 | |
| 46 | var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`) |
| 47 | |
| 48 | func (m *capturingMailer) link() string { return linkRe.FindString(m.text()) } |
| 49 | |
| 50 | // admissionApp builds the production app with a given owners list, over a |
| 51 | // given database path, and captures outgoing mail. Two calls with the same |
| 52 | // path and instance key share the pending state, which is what lets this |
| 53 | // gate send a link under one owners list and follow it under another. |
| 54 | func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) { |
| 55 | t.Helper() |
| 56 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 57 | d, err := db.Open(dbPath, logger) |
| 58 | if err != nil { |
| 59 | t.Fatalf("db.Open: %v", err) |
| 60 | } |
| 61 | t.Cleanup(func() { d.Close() }) |
| 62 | |
| 63 | mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| 64 | if err != nil { |
| 65 | t.Fatalf("App: %v", err) |
| 66 | } |
| 67 | mailer := &capturingMailer{} |
| 68 | au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| 69 | Origin: testOrigin, |
| 70 | InstanceKey: "admission-gate-instance-key", |
| 71 | Owners: owners, |
| 72 | Mailer: mailer, |
| 73 | }, logger, a) |
| 74 | if err != nil { |
| 75 | t.Fatalf("NewAuth: %v", err) |
| 76 | } |
| 77 | a.UseAuth(au) |
| 78 | a.SetOwners(owners) |
| 79 | a.MountAuth(mux, testOrigin) |
| 80 | |
| 81 | offlineResolver(t) |
| 82 | return mux, mailer |
| 83 | } |
| 84 | |
| 85 | // offlineResolver keeps the test off the network. The classifier resolves |
| 86 | // the submitted domain, and a resolver error is what makes it choose the |
| 87 | // magic-link path rather than keymail — which is the path these gates |
| 88 | // need. |
| 89 | func offlineResolver(t *testing.T) { |
| 90 | t.Helper() |
| 91 | prevResolver := net.DefaultResolver |
| 92 | net.DefaultResolver = &net.Resolver{ |
| 93 | PreferGo: true, |
| 94 | Dial: func(ctx context.Context, network, address string) (net.Conn, error) { |
| 95 | return nil, errRecorded |
| 96 | }, |
| 97 | } |
| 98 | t.Cleanup(func() { net.DefaultResolver = prevResolver }) |
| 99 | } |
| 100 | |
| 101 | // Gate 21, spec §4.3 and §10: admission is read, not held — the lone-shape |
| 102 | // half. |
| 103 | // |
| 104 | // In lone shape memoria admits from `MEMORIA_OWNERS` and nothing else. |
| 105 | // This gate asserts it at the moment that decides, which is **verification, |
| 106 | // not submission**: rastrillo's `admit` runs `Config.Authorize` against the |
| 107 | // address it has just verified, after the magic link is followed. That is |
| 108 | // the only point where a person who was sent a link but is no longer an |
| 109 | // owner can be refused, and it is the case a gate on the submit path would |
| 110 | // miss entirely. |
| 111 | // |
| 112 | // So the gate sends a link while an address IS an owner, then follows it |
| 113 | // through an app built with a DIFFERENT owners list — the shape of an |
| 114 | // owner being removed between clicking "send me a link" and clicking the |
| 115 | // link. A session must not be minted. |
| 116 | // |
| 117 | // Two-sided, for the reason §1.7 gives: the admitted case must actually |
| 118 | // reach a session, or "no session" is satisfied by the link being broken, |
| 119 | // the mailer never firing, the pending state not surviving, or the whole |
| 120 | // flow erroring somewhere harmless. |
| 121 | // |
| 122 | // The **suite-shape half is not built** and is owed at slice 1, where |
| 123 | // admission becomes the home's roster (suite call 8) rather than the owners |
| 124 | // list. §10's entry says so rather than implying this covers both. |
| 125 | // |
| 126 | // MUTATIONS: none chosen by the author. Per AGENTS.md, mutations on a gate |
| 127 | // are picked by someone who did not write it, and this one has not had that |
| 128 | // pass yet. Recorded as owed rather than decorated with three the author |
| 129 | // already had in mind. |
| 130 | func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) { |
| 131 | const addr = "someone@example.test" |
| 132 | dbPath := filepath.Join(t.TempDir(), "app.db") |
| 133 | |
| 134 | sendLink := func(t *testing.T, h http.Handler) { |
| 135 | t.Helper() |
| 136 | form := url.Values{"address": {addr}} |
| 137 | req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) |
| 138 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 139 | req.Header.Set("Origin", testOrigin) |
| 140 | h.ServeHTTP(httptest.NewRecorder(), req) |
| 141 | } |
| 142 | |
| 143 | follow := func(t *testing.T, h http.Handler, link string) *httptest.ResponseRecorder { |
| 144 | t.Helper() |
| 145 | u, err := url.Parse(link) |
| 146 | if err != nil { |
| 147 | t.Fatalf("parse magic link: %v", err) |
| 148 | } |
| 149 | // Since rastrillo #64 the GET is the confirm door and decides |
| 150 | // nothing; admission runs at the POST its Sign in button makes. |
| 151 | return newClient(t, h).openLink(u.RequestURI()) |
| 152 | } |
| 153 | |
| 154 | // The address is an owner when the link is sent, and is not one when |
| 155 | // it is followed. Same database, same instance key, so the pending |
| 156 | // state carries across. |
| 157 | h1, mailer := admissionApp(t, dbPath, []string{addr}) |
| 158 | sendLink(t, h1) |
| 159 | link := mailer.link() |
| 160 | if link == "" { |
| 161 | t.Fatalf("no magic link was sent, so this gate is testing nothing: body=%q", mailer.text()) |
| 162 | } |
| 163 | |
| 164 | h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) |
| 165 | rr := follow(t, h2, link) |
| 166 | |
| 167 | if rr.Code != http.StatusForbidden { |
| 168 | t.Errorf("a verified non-owner got status %d, want %d — admission is "+ |
| 169 | "checked at verification and this address is no longer on the list", |
| 170 | rr.Code, http.StatusForbidden) |
| 171 | } |
| 172 | for _, c := range rr.Result().Cookies() { |
| 173 | if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" { |
| 174 | t.Errorf("a session cookie %q was set for a non-owner", c.Name) |
| 175 | } |
| 176 | } |
| 177 | // The refusal is the door (auth's Refused hook, app.refused), not |
| 178 | // rastrillo's bare text: Memoria's door with the refusal in its |
| 179 | // negative callout, and the address form as the way on. |
| 180 | body := html.UnescapeString(rr.Body.String()) |
| 181 | if !strings.Contains(body, "That address can’t sign in to Memoria.") || |
| 182 | !strings.Contains(body, `rst-tone="negative"`) || |
| 183 | !strings.Contains(body, `name="address"`) || |
| 184 | !strings.Contains(body, "estilo-signin") { |
| 185 | t.Errorf("the refusal is not the door with the refusal on it:\n%s", body) |
| 186 | } |
| 187 | if strings.Contains(body, "verified but not admitted") { |
| 188 | t.Error("the refusal is still rastrillo's bare text") |
| 189 | } |
| 190 | if cc := rr.Header().Get("Cache-Control"); cc != "no-store" { |
| 191 | t.Errorf("the refusal door's Cache-Control = %q, want no-store", cc) |
| 192 | } |
| 193 | |
| 194 | // The other side: the same link under the owners list it was sent |
| 195 | // under must actually mint a session. Without this, every assertion |
| 196 | // above is satisfied by a link that never worked. |
| 197 | dbPath2 := filepath.Join(t.TempDir(), "app2.db") |
| 198 | h3, mailer2 := admissionApp(t, dbPath2, []string{addr}) |
| 199 | sendLink(t, h3) |
| 200 | link2 := mailer2.link() |
| 201 | if link2 == "" { |
| 202 | t.Fatalf("no magic link on the admitted path either: body=%q", mailer2.text()) |
| 203 | } |
| 204 | h4, _ := admissionApp(t, dbPath2, []string{addr}) |
| 205 | rr2 := follow(t, h4, link2) |
| 206 | minted := false |
| 207 | for _, c := range rr2.Result().Cookies() { |
| 208 | if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" { |
| 209 | minted = true |
| 210 | } |
| 211 | } |
| 212 | if rr2.Code != http.StatusSeeOther || !minted { |
| 213 | t.Errorf("an owner was not signed in at verification (status %d, session %v): "+ |
| 214 | "the refusal above proves nothing, because this flow refuses everybody", |
| 215 | rr2.Code, minted) |
| 216 | } |
| 217 | } |
| 218 | |