oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
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.
30type capturingMailer struct {
31 body atomic.Value // string
32}
33
34func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error {
35 m.body.Store(body)
36 return nil
37}
38
39func (m *capturingMailer) text() string {
40 if s, ok := m.body.Load().(string); ok {
41 return s
42 }
43 return ""
44}
45
46var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`)
47
48func (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.
54func 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.
89func 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.
130func 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