package memoriatest import ( "context" "html" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "sync/atomic" "testing" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" ) // capturingMailer keeps the last magic link it was asked to send, so a // gate can follow it the way a person would. The link is a live // credential; it never leaves the test process. // // atomic.Value rather than a mutex, because this package already declares // a helper called `sync` (connector_test.go) and a file importing the // stdlib package of that name will not compile beside it. type capturingMailer struct { body atomic.Value // string } func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error { m.body.Store(body) return nil } func (m *capturingMailer) text() string { if s, ok := m.body.Load().(string); ok { return s } return "" } var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`) func (m *capturingMailer) link() string { return linkRe.FindString(m.text()) } // admissionApp builds the production app with a given owners list, over a // given database path, and captures outgoing mail. Two calls with the same // path and instance key share the pending state, which is what lets this // gate send a link under one owners list and follow it under another. func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(dbPath, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } mailer := &capturingMailer{} au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ Origin: testOrigin, InstanceKey: "admission-gate-instance-key", Owners: owners, Mailer: mailer, }, logger, a) if err != nil { t.Fatalf("NewAuth: %v", err) } a.UseAuth(au) a.SetOwners(owners) a.MountAuth(mux, testOrigin) offlineResolver(t) return mux, mailer } // offlineResolver keeps the test off the network. The classifier resolves // the submitted domain, and a resolver error is what makes it choose the // magic-link path rather than keymail — which is the path these gates // need. func offlineResolver(t *testing.T) { t.Helper() prevResolver := net.DefaultResolver net.DefaultResolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { return nil, errRecorded }, } t.Cleanup(func() { net.DefaultResolver = prevResolver }) } // Gate 21, spec §4.3 and §10: admission is read, not held — the lone-shape // half. // // In lone shape memoria admits from `MEMORIA_OWNERS` and nothing else. // This gate asserts it at the moment that decides, which is **verification, // not submission**: rastrillo's `admit` runs `Config.Authorize` against the // address it has just verified, after the magic link is followed. That is // the only point where a person who was sent a link but is no longer an // owner can be refused, and it is the case a gate on the submit path would // miss entirely. // // So the gate sends a link while an address IS an owner, then follows it // through an app built with a DIFFERENT owners list — the shape of an // owner being removed between clicking "send me a link" and clicking the // link. A session must not be minted. // // Two-sided, for the reason §1.7 gives: the admitted case must actually // reach a session, or "no session" is satisfied by the link being broken, // the mailer never firing, the pending state not surviving, or the whole // flow erroring somewhere harmless. // // The **suite-shape half is not built** and is owed at slice 1, where // admission becomes the home's roster (suite call 8) rather than the owners // list. §10's entry says so rather than implying this covers both. // // MUTATIONS: none chosen by the author. Per AGENTS.md, mutations on a gate // are picked by someone who did not write it, and this one has not had that // pass yet. Recorded as owed rather than decorated with three the author // already had in mind. func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) { const addr = "someone@example.test" dbPath := filepath.Join(t.TempDir(), "app.db") sendLink := func(t *testing.T, h http.Handler) { t.Helper() form := url.Values{"address": {addr}} req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) h.ServeHTTP(httptest.NewRecorder(), req) } follow := func(t *testing.T, h http.Handler, link string) *httptest.ResponseRecorder { t.Helper() u, err := url.Parse(link) if err != nil { t.Fatalf("parse magic link: %v", err) } // Since rastrillo #64 the GET is the confirm door and decides // nothing; admission runs at the POST its Sign in button makes. return newClient(t, h).openLink(u.RequestURI()) } // The address is an owner when the link is sent, and is not one when // it is followed. Same database, same instance key, so the pending // state carries across. h1, mailer := admissionApp(t, dbPath, []string{addr}) sendLink(t, h1) link := mailer.link() if link == "" { t.Fatalf("no magic link was sent, so this gate is testing nothing: body=%q", mailer.text()) } h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) rr := follow(t, h2, link) if rr.Code != http.StatusForbidden { t.Errorf("a verified non-owner got status %d, want %d — admission is "+ "checked at verification and this address is no longer on the list", rr.Code, http.StatusForbidden) } for _, c := range rr.Result().Cookies() { if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" { t.Errorf("a session cookie %q was set for a non-owner", c.Name) } } // The refusal is the door (auth's Refused hook, app.refused), not // rastrillo's bare text: Memoria's door with the refusal in its // negative callout, and the address form as the way on. body := html.UnescapeString(rr.Body.String()) if !strings.Contains(body, "That address can’t sign in to Memoria.") || !strings.Contains(body, `rst-tone="negative"`) || !strings.Contains(body, `name="address"`) || !strings.Contains(body, "estilo-signin") { t.Errorf("the refusal is not the door with the refusal on it:\n%s", body) } if strings.Contains(body, "verified but not admitted") { t.Error("the refusal is still rastrillo's bare text") } if cc := rr.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("the refusal door's Cache-Control = %q, want no-store", cc) } // The other side: the same link under the owners list it was sent // under must actually mint a session. Without this, every assertion // above is satisfied by a link that never worked. dbPath2 := filepath.Join(t.TempDir(), "app2.db") h3, mailer2 := admissionApp(t, dbPath2, []string{addr}) sendLink(t, h3) link2 := mailer2.link() if link2 == "" { t.Fatalf("no magic link on the admitted path either: body=%q", mailer2.text()) } h4, _ := admissionApp(t, dbPath2, []string{addr}) rr2 := follow(t, h4, link2) minted := false for _, c := range rr2.Result().Cookies() { if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" { minted = true } } if rr2.Code != http.StatusSeeOther || !minted { t.Errorf("an owner was not signed in at verification (status %d, session %v): "+ "the refusal above proves nothing, because this flow refuses everybody", rr2.Code, minted) } }