package memoriatest import ( "context" "encoding/binary" "encoding/json" "fmt" "html" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" stdsync "sync" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/oficina/memoria/internal/memoria" ) // fakeKeymail answers the two probes signin's classifier makes of a // Keymail server — /.well-known/keymail and /api/federation/lookup — for // one host, and the token exchange for the codes approve hands out, and // 404s everything else. It stands in for http.DefaultTransport, which the // classifier's and the callback's clients fall back to. type fakeKeymail struct { host string mu stdsync.Mutex hits []string codes map[string]string } // approve is Keymail's consent screen saying yes for address: the code // the browser carries back to /auth/callback. func (f *fakeKeymail) approve(address string) string { f.mu.Lock() defer f.mu.Unlock() if f.codes == nil { f.codes = map[string]string{} } code := fmt.Sprintf("code-%d", len(f.codes)+1) f.codes[code] = address return code } func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) { f.mu.Lock() f.hits = append(f.hits, r.URL.Host+r.URL.Path) f.mu.Unlock() body, code := `{}`, http.StatusNotFound if r.URL.Host == f.host { switch r.URL.Path { case "/.well-known/keymail": body, code = `{"version":"1"}`, http.StatusOK case "/api/federation/lookup": code = http.StatusOK case "/api/oauth/token": var in map[string]string _ = json.NewDecoder(r.Body).Decode(&in) f.mu.Lock() address, ok := f.codes[in["code"]] delete(f.codes, in["code"]) f.mu.Unlock() body, code = `{"error":"invalid_grant"}`, http.StatusBadRequest if ok && r.Method == http.MethodPost { out, _ := json.Marshal(map[string]any{ "access_token": "synthetic", "token_type": "bearer", "address": address, "auth_time": time.Now().Unix(), }) body, code = string(out), http.StatusOK } } } return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil } // nxdomainResolver answers every DNS query NXDOMAIN — "this domain // publishes no _keymail delegation" — which signin's classifier reads as // "the domain itself is the Keymail candidate". Go's resolver speaks // length-prefixed DNS over any conn that is not a PacketConn, which a // net.Pipe is not. func nxdomainResolver() *net.Resolver { return &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) { client, server := net.Pipe() go func() { defer server.Close() for { var n [2]byte if _, err := io.ReadFull(server, n[:]); err != nil { return } q := make([]byte, binary.BigEndian.Uint16(n[:])) if _, err := io.ReadFull(server, q); err != nil || len(q) < 12 { return } // Header plus the question, and nothing after it. i := 12 for i < len(q) && q[i] != 0 { i += int(q[i]) + 1 } i += 5 // the root label, QTYPE, QCLASS if i > len(q) { return } resp := append([]byte(nil), q[:i]...) rd := binary.BigEndian.Uint16(q[2:4]) & 0x0100 binary.BigEndian.PutUint16(resp[2:4], 0x8000|0x0400|rd|0x0080|3) // QR AA RD RA, NXDOMAIN binary.BigEndian.PutUint16(resp[4:6], 1) for _, off := range []int{6, 8, 10} { binary.BigEndian.PutUint16(resp[off:off+2], 0) } binary.BigEndian.PutUint16(n[:], uint16(len(resp))) if _, err := server.Write(append(n[:], resp...)); err != nil { return } } }() return client, nil }} } // keymailSigninApp is the real app with the real sign-in wired, whose // owner's domain resolves — through the fakes above — to a Keymail server. func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) { t.Helper() km := &fakeKeymail{host: "example.test"} return keymailSigninAppWith(t, filepath.Join(t.TempDir(), "app.db"), []string{dialTestOwner}, km), km } // keymailSigninAppWith is keymailSigninApp over a given database and // owners list and Keymail fake: two apps over one database share the // pending state, so a sign-in begun under one owners list can come back // under another. func keymailSigninAppWith(t *testing.T, dbPath string, owners []string, km *fakeKeymail) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(dbPath, logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatal(err) } au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ Origin: testOrigin, InstanceKey: "signin-keymail-instance-key", Owners: owners, Mailer: &stubMailer{}, }, logger, a) if err != nil { t.Fatal(err) } a.UseAuth(au) a.SetOwners(owners) a.MountAuth(mux, testOrigin) prevT, prevR := http.DefaultTransport, net.DefaultResolver http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver() t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR }) return mux } // **Signing in with a Keymail address must be able to leave the page.** // // Begin answers a Keymail address with 303 to the server's /oauth/authorize, // and memoria's CSP says form-action 'self' — which browsers enforce across // a form submission's WHOLE redirect chain. Safari refused the redirect and // the sign-in button span forever (2026-09-29, memoria.oficina.works). The // POST now ends in a 200 continuation page whose navigation is not a form // submission. // // Through rastrillo's REAL Begin and the REAL mounted route: the classifier // is only faked at the network, so what is asserted is what production // sends. func TestAKeymailSigninCanLeaveThePage(t *testing.T) { h, km := keymailSigninApp(t) rr := beginSignin(t, h, dialTestOwner) // Presence: the classifier really took the Keymail path. Without this, // a 200 below could be the magic-link page and prove nothing. if !contains(km.hits, "example.test/api/federation/lookup") { t.Fatalf("the classifier never reached the Keymail probes (hits %v); this proves nothing", km.hits) } if rr.Code == http.StatusSeeOther { t.Fatalf("POST /auth/begin still redirects (Location %q): form-action 'self' refuses this in the browser", rr.Header().Get("Location")) } if rr.Code != http.StatusOK { t.Fatalf("status %d, want 200", rr.Code) } body := rr.Body.String() // The continuation navigates to the authorize URL Begin chose, for THIS // origin and callback. i := strings.Index(body, `http-equiv="refresh"`) if i < 0 { t.Fatalf("no meta refresh in the continuation page:\n%s", body) } for _, want := range []string{ "https://example.test/oauth/authorize", "client_id=" + url.QueryEscape(testOrigin), "redirect_uri=" + url.QueryEscape(testOrigin+"/auth/callback"), "code_challenge_method=S256", } { if !strings.Contains(strings.ReplaceAll(body, "&", "&"), want) { t.Errorf("continuation page lacks %q", want) } } if got := rr.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("Cache-Control = %q, want no-store: the page carries a one-time PKCE state", got) } if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { t.Errorf("Referrer-Policy = %q, want no-referrer", got) } // Begin's pending cookie must survive the wrapping, or the callback // cannot match the flow it started. if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") { t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie")) } // Rendered as a Memoria page: the layout, and its bar. (No footer: it // is a sign-in page, and the footer is said only to somebody signed // in.) if !strings.Contains(body, "