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)
}
}