| new file mode 100644 |
| index 0000000..304100c |
| --- /dev/null |
| +++ b/internal/memoriatest/admission_test.go |
| @@ -0,0 +1,183 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/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. |
| +type capturingMailer struct { |
| + mu sync.Mutex |
| + body string |
| +} |
| + |
| +func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.body = body |
| + return nil |
| +} |
| + |
| +var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`) |
| + |
| +func (m *capturingMailer) link() string { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return linkRe.FindString(m.body) |
| +} |
| + |
| +// 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, memoria.Admits(owners)) |
| + if err != nil { |
| + t.Fatalf("NewAuth: %v", err) |
| + } |
| + a.UseAuth(au, memoria.Admits(owners)) |
| + a.MountAuth(mux, testOrigin) |
| + |
| + // No test may reach 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 this gate needs. |
| + 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 }) |
| + return mux, mailer |
| +} |
| + |
| +// 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) |
| + } |
| + req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil) |
| + rr := httptest.NewRecorder() |
| + h.ServeHTTP(rr, req) |
| + return rr |
| + } |
| + |
| + // 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.body) |
| + } |
| + |
| + 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 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.body) |
| + } |
| + h4, _ := admissionApp(t, dbPath2, []string{addr}) |
| + rr2 := follow(t, h4, link2) |
| + if rr2.Code == http.StatusForbidden { |
| + t.Error("an owner was refused at verification: the refusal above proves " + |
| + "nothing, because this flow refuses everybody") |
| + } |
| +} |