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 "encoding/binary"
6 "encoding/json"
7 "fmt"
8 "html"
9 "io"
10 "log/slog"
11 "net"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "path/filepath"
16 "regexp"
17 "strings"
18 stdsync "sync"
19 "testing"
20 "time"
21
22 "amadan.net/rastrillo/rastrillo/db"
23
24 "amadan.net/oficina/memoria/internal/memoria"
25)
26
27// fakeKeymail answers the two probes signin's classifier makes of a
28// Keymail server — /.well-known/keymail and /api/federation/lookup — for
29// one host, and the token exchange for the codes approve hands out, and
30// 404s everything else. It stands in for http.DefaultTransport, which the
31// classifier's and the callback's clients fall back to.
32type fakeKeymail struct {
33 host string
34 mu stdsync.Mutex
35 hits []string
36 codes map[string]string
37}
38
39// approve is Keymail's consent screen saying yes for address: the code
40// the browser carries back to /auth/callback.
41func (f *fakeKeymail) approve(address string) string {
42 f.mu.Lock()
43 defer f.mu.Unlock()
44 if f.codes == nil {
45 f.codes = map[string]string{}
46 }
47 code := fmt.Sprintf("code-%d", len(f.codes)+1)
48 f.codes[code] = address
49 return code
50}
51
52func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) {
53 f.mu.Lock()
54 f.hits = append(f.hits, r.URL.Host+r.URL.Path)
55 f.mu.Unlock()
56 body, code := `{}`, http.StatusNotFound
57 if r.URL.Host == f.host {
58 switch r.URL.Path {
59 case "/.well-known/keymail":
60 body, code = `{"version":"1"}`, http.StatusOK
61 case "/api/federation/lookup":
62 code = http.StatusOK
63 case "/api/oauth/token":
64 var in map[string]string
65 _ = json.NewDecoder(r.Body).Decode(&in)
66 f.mu.Lock()
67 address, ok := f.codes[in["code"]]
68 delete(f.codes, in["code"])
69 f.mu.Unlock()
70 body, code = `{"error":"invalid_grant"}`, http.StatusBadRequest
71 if ok && r.Method == http.MethodPost {
72 out, _ := json.Marshal(map[string]any{
73 "access_token": "synthetic", "token_type": "bearer",
74 "address": address, "auth_time": time.Now().Unix(),
75 })
76 body, code = string(out), http.StatusOK
77 }
78 }
79 }
80 return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)),
81 Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
82}
83
84// nxdomainResolver answers every DNS query NXDOMAIN — "this domain
85// publishes no _keymail delegation" — which signin's classifier reads as
86// "the domain itself is the Keymail candidate". Go's resolver speaks
87// length-prefixed DNS over any conn that is not a PacketConn, which a
88// net.Pipe is not.
89func nxdomainResolver() *net.Resolver {
90 return &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) {
91 client, server := net.Pipe()
92 go func() {
93 defer server.Close()
94 for {
95 var n [2]byte
96 if _, err := io.ReadFull(server, n[:]); err != nil {
97 return
98 }
99 q := make([]byte, binary.BigEndian.Uint16(n[:]))
100 if _, err := io.ReadFull(server, q); err != nil || len(q) < 12 {
101 return
102 }
103 // Header plus the question, and nothing after it.
104 i := 12
105 for i < len(q) && q[i] != 0 {
106 i += int(q[i]) + 1
107 }
108 i += 5 // the root label, QTYPE, QCLASS
109 if i > len(q) {
110 return
111 }
112 resp := append([]byte(nil), q[:i]...)
113 rd := binary.BigEndian.Uint16(q[2:4]) & 0x0100
114 binary.BigEndian.PutUint16(resp[2:4], 0x8000|0x0400|rd|0x0080|3) // QR AA RD RA, NXDOMAIN
115 binary.BigEndian.PutUint16(resp[4:6], 1)
116 for _, off := range []int{6, 8, 10} {
117 binary.BigEndian.PutUint16(resp[off:off+2], 0)
118 }
119 binary.BigEndian.PutUint16(n[:], uint16(len(resp)))
120 if _, err := server.Write(append(n[:], resp...)); err != nil {
121 return
122 }
123 }
124 }()
125 return client, nil
126 }}
127}
128
129// keymailSigninApp is the real app with the real sign-in wired, whose
130// owner's domain resolves — through the fakes above — to a Keymail server.
131func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) {
132 t.Helper()
133 km := &fakeKeymail{host: "example.test"}
134 return keymailSigninAppWith(t, filepath.Join(t.TempDir(), "app.db"), []string{dialTestOwner}, km), km
135}
136
137// keymailSigninAppWith is keymailSigninApp over a given database and
138// owners list and Keymail fake: two apps over one database share the
139// pending state, so a sign-in begun under one owners list can come back
140// under another.
141func keymailSigninAppWith(t *testing.T, dbPath string, owners []string, km *fakeKeymail) http.Handler {
142 t.Helper()
143 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
144 d, err := db.Open(dbPath, logger)
145 if err != nil {
146 t.Fatal(err)
147 }
148 t.Cleanup(func() { d.Close() })
149 mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
150 if err != nil {
151 t.Fatal(err)
152 }
153 au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
154 Origin: testOrigin, InstanceKey: "signin-keymail-instance-key",
155 Owners: owners, Mailer: &stubMailer{},
156 }, logger, a)
157 if err != nil {
158 t.Fatal(err)
159 }
160 a.UseAuth(au)
161 a.SetOwners(owners)
162 a.MountAuth(mux, testOrigin)
163
164 prevT, prevR := http.DefaultTransport, net.DefaultResolver
165 http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver()
166 t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR })
167 return mux
168}
169
170// **Signing in with a Keymail address must be able to leave the page.**
171//
172// Begin answers a Keymail address with 303 to the server's /oauth/authorize,
173// and memoria's CSP says form-action 'self' — which browsers enforce across
174// a form submission's WHOLE redirect chain. Safari refused the redirect and
175// the sign-in button span forever (2026-09-29, memoria.oficina.works). The
176// POST now ends in a 200 continuation page whose navigation is not a form
177// submission.
178//
179// Through rastrillo's REAL Begin and the REAL mounted route: the classifier
180// is only faked at the network, so what is asserted is what production
181// sends.
182func TestAKeymailSigninCanLeaveThePage(t *testing.T) {
183 h, km := keymailSigninApp(t)
184 rr := beginSignin(t, h, dialTestOwner)
185
186 // Presence: the classifier really took the Keymail path. Without this,
187 // a 200 below could be the magic-link page and prove nothing.
188 if !contains(km.hits, "example.test/api/federation/lookup") {
189 t.Fatalf("the classifier never reached the Keymail probes (hits %v); this proves nothing", km.hits)
190 }
191
192 if rr.Code == http.StatusSeeOther {
193 t.Fatalf("POST /auth/begin still redirects (Location %q): form-action 'self' refuses this in the browser", rr.Header().Get("Location"))
194 }
195 if rr.Code != http.StatusOK {
196 t.Fatalf("status %d, want 200", rr.Code)
197 }
198 body := rr.Body.String()
199
200 // The continuation navigates to the authorize URL Begin chose, for THIS
201 // origin and callback.
202 i := strings.Index(body, `http-equiv="refresh"`)
203 if i < 0 {
204 t.Fatalf("no meta refresh in the continuation page:\n%s", body)
205 }
206 for _, want := range []string{
207 "https://example.test/oauth/authorize",
208 "client_id=" + url.QueryEscape(testOrigin),
209 "redirect_uri=" + url.QueryEscape(testOrigin+"/auth/callback"),
210 "code_challenge_method=S256",
211 } {
212 if !strings.Contains(strings.ReplaceAll(body, "&amp;", "&"), want) {
213 t.Errorf("continuation page lacks %q", want)
214 }
215 }
216 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
217 t.Errorf("Cache-Control = %q, want no-store: the page carries a one-time PKCE state", got)
218 }
219 if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
220 t.Errorf("Referrer-Policy = %q, want no-referrer", got)
221 }
222 // Begin's pending cookie must survive the wrapping, or the callback
223 // cannot match the flow it started.
224 if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") {
225 t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie"))
226 }
227 // Rendered as a Memoria page: the layout, and its bar. (No footer: it
228 // is a sign-in page, and the footer is said only to somebody signed
229 // in.)
230 if !strings.Contains(body, "<header estilo-bar") || strings.Contains(body, "<footer") {
231 t.Error("the continuation page is not inside Memoria's layout")
232 }
233}
234
235// And the paths that must NOT change: a non-owner still gets the guard's
236// same-origin redirect with no dial (gate 33's ground), and an address with
237// no Keymail still gets the magic link's same-origin redirect, untouched.
238func TestSigninPathsThatDoNotLeaveAreUnchanged(t *testing.T) {
239 h, km := keymailSigninApp(t)
240
241 rr := beginSignin(t, h, "stranger@elsewhere.test")
242 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/signin?sent=1" {
243 t.Errorf("non-owner: %d %q, want 303 /signin?sent=1", rr.Code, rr.Header().Get("Location"))
244 }
245 if len(km.hits) != 0 {
246 t.Errorf("a non-owner's sign-in reached the network: %v", km.hits)
247 }
248
249 // An owner whose domain has no Keymail (the fake 404s other hosts).
250 km.host = "nowhere.invalid"
251 rr = beginSignin(t, h, dialTestOwner)
252 if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/") {
253 t.Errorf("magic-link path: %d %q, want a same-origin 303", rr.Code, rr.Header().Get("Location"))
254 }
255}
256
257// authorizeHref is the authorize URL a continuation page carries.
258var authorizeHref = regexp.MustCompile(`href="(https://example\.test/oauth/authorize\?[^"]+)"`)
259
260// A Keymail sign-in that comes back verified for an address Admit refuses
261// is the door, in auth's own 403, through the Refused hook (app.refused):
262// not rastrillo's bare text, not a redirect to a query anybody could
263// type, and no session. beginGuard refuses a non-owner before Begin
264// (gate 33), so the refusal is reached the way the magic link's is: the
265// address is an owner when the sign-in starts and is not one when it
266// comes back. The admitted half runs first, through the same continuation
267// page, and its session opens the index — or the refusal proves nothing.
268func TestKeymailRefusalIsTheDoor(t *testing.T) {
269 km := &fakeKeymail{host: "example.test"}
270 dbPath := filepath.Join(t.TempDir(), "app.db")
271 h := keymailSigninAppWith(t, dbPath, []string{dialTestOwner}, km)
272
273 // begin starts a Keymail sign-in on h and returns the browser that
274 // carries it and the callback Keymail would send it to.
275 begin := func() (*client, string) {
276 t.Helper()
277 c := newClient(t, h)
278 rr := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {dialTestOwner}})
279 m := authorizeHref.FindStringSubmatch(rr.Body.String())
280 if rr.Code != http.StatusOK || m == nil {
281 t.Fatalf("POST /auth/begin = %d with no Keymail continuation:\n%s", rr.Code, rr.Body.String())
282 }
283 u, err := url.Parse(html.UnescapeString(m[1]))
284 if err != nil {
285 t.Fatal(err)
286 }
287 return c, testOrigin + "/auth/callback?" + url.Values{"state": {u.Query().Get("state")}, "code": {km.approve(dialTestOwner)}}.Encode()
288 }
289
290 c, callback := begin()
291 cb := c.get(callback)
292 if cb.Code != http.StatusSeeOther || !sessionSet(cb) {
293 t.Fatalf("admitted Keymail callback = %d (session %v), want 303 and a session:\n%s", cb.Code, sessionSet(cb), cb.Body.String())
294 }
295 dest := cb.Header().Get("Location")
296 for i := 0; i < 3 && dest != "/"; i++ {
297 dest = location(t, c.get(testOrigin+dest))
298 }
299 if got := c.get(testOrigin + "/"); dest != "/" || got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") {
300 t.Fatalf("the Keymail session did not open the index (landed %q, GET / = %d)", dest, got.Code)
301 }
302
303 // Begun as an owner; back after the owner was removed.
304 c, callback = begin()
305 c.h = keymailSigninAppWith(t, dbPath, []string{"someone-else@example.test"}, km)
306 cb = c.get(callback)
307 if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" || sessionSet(cb) {
308 t.Fatalf("refused Keymail callback = %d %q (session %v), want 403 and the door itself", cb.Code, cb.Header().Get("Location"), sessionSet(cb))
309 }
310 door := html.UnescapeString(cb.Body.String())
311 for _, want := range []string{"estilo-door", `name="address"`, `rst-tone="negative"`, "That address can’t sign in to Memoria."} {
312 if !strings.Contains(door, want) {
313 t.Errorf("the refusal door lacks %q:\n%s", want, door)
314 }
315 }
316 if strings.Contains(door, "verified but not admitted") {
317 t.Error("the refusal is still rastrillo's bare text")
318 }
319 // The sentence means "you proved this address and it is not
320 // admitted": nobody may summon it by typing a URL.
321 if spoof := html.UnescapeString(newClient(t, h).get(testOrigin + "/signin?err=refused").Body.String()); strings.Contains(spoof, "can’t sign in to Memoria") {
322 t.Error("anybody can show the refusal by visiting /signin?err=refused")
323 }
324}
325
326func contains(xs []string, want string) bool {
327 for _, x := range xs {
328 if x == want {
329 return true
330 }
331 }
332 return false
333}
334
335var _ = httptest.NewRecorder
336