carlos / platform Public

Fix mounted simulated Keymail approval and cover demo journey

claude agent, operated by paul@keymail.dev 9d9d54524273b7b28368cb21829d9276a6c9109a parent 404e699
2 files changed, +398 −20
  • internal/console/billingflow_demo_journey_test.go +348 −0
  • internal/console/billingflow_demo_test.go +50 −20

CI passed — run details

diff --git a/internal/console/billingflow_demo_journey_test.go b/internal/console/billingflow_demo_journey_test.go
new file mode 100644
index 000000000..8bc9d3898
--- /dev/null
+++ b/internal/console/billingflow_demo_journey_test.go
@@ -0,0 +1,348 @@
+package console
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ xhtml "golang.org/x/net/html"
+)
+
+func newDemoJourney(t *testing.T) (*httptest.Server, *http.Client, *Server) {
+ t.Helper()
+ ts := httptest.NewUnstartedServer(nil)
+ origin := "http://" + ts.Listener.Addr().String()
+ handler, srv := newBillingFlowDemo(t, origin)
+ ts.Config.Handler = handler
+ tr := &http.Transport{Proxy: nil}
+ t.Cleanup(tr.CloseIdleConnections)
+ local := demoOriginTransport{origin: origin, inner: tr}
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ browser := &http.Client{Transport: local, Jar: jar, Timeout: 5 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error {
+ if r.URL.Scheme+"://"+r.URL.Host != origin || r.URL.User != nil || len(via) > 10 {
+ return fmt.Errorf("demo redirect refused: %s", r.URL)
+ }
+ return nil
+ }}
+ ts.Start()
+ t.Cleanup(ts.Close)
+ return ts, browser, srv
+}
+
+func demoGetPage(t *testing.T, c *http.Client, address string) *http.Response {
+ t.Helper()
+ resp, err := c.Get(address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ t.Fatalf("GET %s = %d: %s", address, resp.StatusCode, b)
+ }
+ return resp
+}
+
+func demoAttr(n *xhtml.Node, key string) string {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val
+ }
+ }
+ return ""
+}
+func demoNodeText(n *xhtml.Node) string {
+ if n.Type == xhtml.TextNode {
+ return n.Data
+ }
+ var out strings.Builder
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ out.WriteString(demoNodeText(c))
+ }
+ return out.String()
+}
+
+func demoSubmitForm(t *testing.T, c *http.Client, page *http.Response, button string, edits url.Values) *http.Response {
+ t.Helper()
+ doc, err := xhtml.Parse(page.Body)
+ page.Body.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var form, submit *xhtml.Node
+ var visit func(*xhtml.Node, *xhtml.Node)
+ visit = func(n, current *xhtml.Node) {
+ if n.Type == xhtml.ElementNode && n.Data == "form" {
+ current = n
+ }
+ if n.Type == xhtml.ElementNode && n.Data == "button" && strings.TrimSpace(demoNodeText(n)) == button {
+ if form != nil {
+ t.Fatalf("duplicate button %q", button)
+ }
+ form, submit = current, n
+ }
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ visit(child, current)
+ }
+ }
+ visit(doc, nil)
+ if form == nil {
+ t.Fatalf("button %q not found at %s", button, page.Request.URL)
+ }
+ fields := url.Values{}
+ var controls func(*xhtml.Node)
+ controls = func(n *xhtml.Node) {
+ name := demoAttr(n, "name")
+ if n.Type == xhtml.ElementNode && n.Data == "input" && name != "" {
+ typ := demoAttr(n, "type")
+ if typ != "checkbox" && typ != "radio" && typ != "submit" {
+ fields.Add(name, demoAttr(n, "value"))
+ }
+ }
+ if n.Type == xhtml.ElementNode && n.Data == "select" && name != "" {
+ first := true
+ for o := n.FirstChild; o != nil; o = o.NextSibling {
+ if o.Type == xhtml.ElementNode && o.Data == "option" {
+ selected := false
+ for _, a := range o.Attr {
+ selected = selected || a.Key == "selected"
+ }
+ if first || selected {
+ fields.Set(name, demoAttr(o, "value"))
+ }
+ first = false
+ }
+ }
+ }
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ controls(child)
+ }
+ }
+ controls(form)
+ if name := demoAttr(submit, "name"); name != "" {
+ fields.Set(name, demoAttr(submit, "value"))
+ }
+ for k, v := range edits {
+ fields[k] = v
+ }
+ action := demoAttr(form, "action")
+ if override := demoAttr(submit, "formaction"); override != "" {
+ action = override
+ }
+ target, err := page.Request.URL.Parse(action)
+ if err != nil {
+ t.Fatal(err)
+ }
+ method := strings.ToUpper(demoAttr(form, "method"))
+ if method == "" {
+ method = http.MethodGet
+ }
+ var body io.Reader
+ if method == http.MethodGet {
+ target.RawQuery = fields.Encode()
+ } else {
+ body = strings.NewReader(fields.Encode())
+ }
+ req, err := http.NewRequest(method, target.String(), body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if method == http.MethodPost {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", page.Request.URL.Scheme+"://"+page.Request.URL.Host)
+ }
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp
+}
+
+func demoSignIn(t *testing.T, ts *httptest.Server, browser *http.Client) *http.Response {
+ t.Helper()
+ page := demoGetPage(t, browser, ts.URL+"/")
+ approval := demoSubmitForm(t, browser, page, "Sign in with Keymail", nil)
+ if approval.StatusCode != http.StatusOK || approval.Request.URL.Path != "/__keymail/oauth/authorize" {
+ t.Fatalf("approval %d %s", approval.StatusCode, approval.Request.URL)
+ }
+ for _, key := range []string{"state", "code_challenge", "code_challenge_method", "redirect_uri"} {
+ if approval.Request.URL.Query().Get(key) == "" {
+ t.Fatalf("approval missing %s", key)
+ }
+ }
+ signed := demoSubmitForm(t, browser, approval, "Approve", url.Values{"address": {"journey@example.test"}})
+ if signed.StatusCode != http.StatusOK || !strings.HasPrefix(signed.Request.URL.Path, "/a/") {
+ b, _ := io.ReadAll(signed.Body)
+ signed.Body.Close()
+ t.Fatalf("rendered Approve = %d %s: %s", signed.StatusCode, signed.Request.URL, b)
+ }
+ return signed
+}
+
+func TestBillingDemoRenderedSignin(t *testing.T) {
+ ts, browser, _ := newDemoJourney(t)
+ signed := demoSignIn(t, ts, browser)
+ signed.Body.Close()
+}
+
+func TestBillingDemoRenderedAccountCheckoutPortal(t *testing.T) {
+ ts, browser, _ := newDemoJourney(t)
+ signed := demoSignIn(t, ts, browser)
+ signed.Body.Close()
+ create := demoGetPage(t, browser, ts.URL+"/accounts/new")
+ denied := demoSubmitForm(t, browser, create, "Create account", url.Values{"name": {"demo-project"}, "csrf": {"wrong"}})
+ denied.Body.Close()
+ if denied.StatusCode != http.StatusForbidden {
+ t.Fatalf("bad account CSRF = %d", denied.StatusCode)
+ }
+ create = demoGetPage(t, browser, ts.URL+"/accounts/new")
+ account := demoSubmitForm(t, browser, create, "Create account", url.Values{"name": {"demo-project"}})
+ if account.StatusCode != http.StatusOK || !strings.HasPrefix(account.Request.URL.Path, "/a/") {
+ t.Fatalf("account create = %d %s", account.StatusCode, account.Request.URL)
+ }
+ body, _ := io.ReadAll(account.Body)
+ account.Body.Close()
+ if !strings.Contains(string(body), "demo-project") {
+ t.Fatal("created account missing from landing")
+ }
+ parts := strings.Split(account.Request.URL.Path, "/")
+ base := "/a/" + parts[2]
+ billing := demoGetPage(t, browser, ts.URL+base+"/billing?country=IE&currency=eur")
+ checkout := demoSubmitForm(t, browser, billing, "Continue to payment", url.Values{"plan": {plans.PlanPersonal}, "consent": {plans.CurrentTermsVersion}})
+ if checkout.Request.URL.Path == "/__keymail/oauth/authorize" {
+ if checkout.Request.URL.Query().Get("redirect_uri") != ts.URL+"/auth/stepup/callback" {
+ t.Fatal("checkout used wrong callback")
+ }
+ checkout = demoSubmitForm(t, browser, checkout, "Approve", url.Values{"address": {"journey@example.test"}})
+ checkout = demoSubmitForm(t, browser, checkout, "Continue to payment", url.Values{"plan": {plans.PlanPersonal}, "consent": {plans.CurrentTermsVersion}})
+ }
+ if checkout.StatusCode != http.StatusOK || !strings.HasPrefix(checkout.Request.URL.Path, "/__stripe/checkout/") {
+ b, _ := io.ReadAll(checkout.Body)
+ checkout.Body.Close()
+ t.Fatalf("checkout = %d %s: %s", checkout.StatusCode, checkout.Request.URL, b)
+ }
+ cancelled := demoSubmitForm(t, browser, checkout, "Cancel", nil)
+ if cancelled.StatusCode != http.StatusOK || cancelled.Request.URL.Path != base+"/billing" || cancelled.Request.URL.RawQuery != "" {
+ t.Fatalf("checkout cancel = %d %s", cancelled.StatusCode, cancelled.Request.URL)
+ }
+ checkout = demoSubmitForm(t, browser, cancelled, "Continue to payment", url.Values{"plan": {plans.PlanPersonal}, "consent": {plans.CurrentTermsVersion}})
+ if checkout.StatusCode != http.StatusOK || !strings.HasPrefix(checkout.Request.URL.Path, "/__stripe/checkout/") {
+ t.Fatalf("reopened checkout = %d %s", checkout.StatusCode, checkout.Request.URL)
+ }
+ returned := demoSubmitForm(t, browser, checkout, "Pay (simulated)", nil)
+ if returned.StatusCode != http.StatusOK || returned.Request.URL.Path != base+"/billing" || returned.Request.URL.Query().Get("pending") != "1" {
+ t.Fatalf("checkout return = %d %s", returned.StatusCode, returned.Request.URL)
+ }
+ body, _ = io.ReadAll(returned.Body)
+ returned.Body.Close()
+ if !strings.Contains(string(body), "Personal, 1 members") {
+ t.Fatalf("paid plan not rendered: %s", body)
+ }
+ for _, operation := range []struct{ button, status string }{
+ {"Simulate a failed renewal payment", "past_due"},
+ {"Simulate a recovered payment", "active"},
+ {"Cancel the subscription", "canceled"},
+ } {
+ billing = demoGetPage(t, browser, ts.URL+base+"/billing")
+ portal := demoSubmitForm(t, browser, billing, "Open billing at Stripe", nil)
+ if portal.StatusCode != http.StatusOK || !strings.HasPrefix(portal.Request.URL.Path, "/__stripe/portal/") {
+ t.Fatalf("portal = %d %s", portal.StatusCode, portal.Request.URL)
+ }
+ updated := demoSubmitForm(t, browser, portal, operation.button, nil)
+ if updated.StatusCode != http.StatusOK || !strings.HasPrefix(updated.Request.URL.Path, "/__stripe/portal/") {
+ t.Fatalf("portal post = %d %s", updated.StatusCode, updated.Request.URL)
+ }
+ body, _ = io.ReadAll(updated.Body)
+ updated.Body.Close()
+ if !strings.Contains(string(body), "Your subscription: "+operation.status) {
+ t.Fatalf("portal status %q absent: %s", operation.status, body)
+ }
+ current := demoGetPage(t, browser, ts.URL+base+"/billing")
+ body, _ = io.ReadAll(current.Body)
+ current.Body.Close()
+ if strings.Contains(string(body), "The last payment did not go through.") != (operation.status == "past_due") ||
+ strings.Contains(string(body), "Personal, 1 members") != (operation.status != "canceled") {
+ t.Fatalf("console did not reflect portal status %q: %s", operation.status, body)
+ }
+ }
+}
+
+func TestBillingDemoCallbackSafety(t *testing.T) {
+ ts, browser, _ := newDemoJourney(t)
+ noFollow := *browser
+ noFollow.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+ page := demoGetPage(t, browser, ts.URL+"/")
+ approval := demoSubmitForm(t, browser, page, "Sign in with Keymail", nil)
+ authorize := approval.Request.URL.String()
+ approval.Body.Close()
+ for _, target := range []string{"https://outside.example.test/auth/callback", "//outside.example.test/auth/callback", ts.URL + "/other", ts.URL + "/auth/callback?next=https://outside.example.test", ts.URL + "/auth/callback#fragment"} {
+ u, _ := url.Parse(authorize)
+ q := u.Query()
+ q.Set("redirect_uri", target)
+ q.Set("approve", "1")
+ u.RawQuery = q.Encode()
+ resp, err := noFollow.Get(u.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" {
+ t.Fatalf("unsafe callback %q = %d %s", target, resp.StatusCode, resp.Header.Get("Location"))
+ }
+ }
+ wrongClient, _ := url.Parse(authorize)
+ query := wrongClient.Query()
+ query.Set("client_id", "https://outside.example.test")
+ query.Set("approve", "1")
+ wrongClient.RawQuery = query.Encode()
+ refused, err := noFollow.Get(wrongClient.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ refused.Body.Close()
+ if refused.StatusCode != http.StatusBadRequest || refused.Header.Get("Location") != "" {
+ t.Fatalf("wrong client accepted: %d %s", refused.StatusCode, refused.Header.Get("Location"))
+ }
+ approval = demoGetPage(t, browser, authorize)
+ callback := demoSubmitForm(t, &noFollow, approval, "Approve", url.Values{"address": {"journey@example.test"}})
+ callback.Body.Close()
+ if callback.StatusCode != http.StatusSeeOther {
+ t.Fatalf("approval = %d", callback.StatusCode)
+ }
+ location := callback.Header.Get("Location")
+ u, _ := url.Parse(location)
+ if u.Scheme+"://"+u.Host != ts.URL || u.Path != "/auth/callback" || u.Query().Get("state") == "" || u.Query().Get("code") == "" {
+ t.Fatalf("callback %s", location)
+ }
+ stranger := noFollow
+ stranger.Jar, _ = cookiejar.New(nil)
+ denied, err := stranger.Get(location)
+ if err != nil {
+ t.Fatal(err)
+ }
+ denied.Body.Close()
+ if denied.StatusCode != http.StatusBadRequest {
+ t.Fatalf("callback without state cookie = %d", denied.StatusCode)
+ }
+ q := u.Query()
+ q.Set("state", "wrong")
+ u.RawQuery = q.Encode()
+ denied, err = noFollow.Get(u.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ denied.Body.Close()
+ if denied.StatusCode != http.StatusBadRequest {
+ t.Fatalf("callback with wrong state = %d", denied.StatusCode)
+ }
+}
diff --git a/internal/console/billingflow_demo_test.go b/internal/console/billingflow_demo_test.go
index 4085c3a34..028900df2 100644
--- a/internal/console/billingflow_demo_test.go
+++ b/internal/console/billingflow_demo_test.go
@@ -46,13 +46,46 @@ func TestBillingFlowDemo(t *testing.T) {
t.Skip("set CARLOS_BILLING_DEMO_ADDR, CARLOS_BILLING_DEMO_ORIGIN and CARLOS_BILLING_DEMO_STOP to serve the demo")
}
origin = strings.TrimRight(origin, "/")
+ mux, _ := newBillingFlowDemo(t, origin)
+
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+ go hs.Serve(ln) //nolint:errcheck
+ t.Logf("billing flow demo serving on %s as %s", addr, origin)
+ deadline := time.Now().Add(14 * time.Hour)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat(stop); err == nil {
+ break
+ }
+ time.Sleep(5 * time.Second)
+ }
+ hs.Close()
+}
+
+type demoOriginTransport struct {
+ origin string
+ inner http.RoundTripper
+}
+
+func (d demoOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+ if r.URL.Scheme+"://"+r.URL.Host != d.origin || r.URL.User != nil {
+ return nil, fmt.Errorf("demo request outside local origin: %s", r.URL)
+ }
+ return d.inner.RoundTrip(r)
+}
+
+func newBillingFlowDemo(t *testing.T, origin string) (http.Handler, *Server) {
+ t.Helper()
regPath, relStore, priv, pub := buildFixtures(t)
dir := t.TempDir()
st, err := Open(filepath.Join(dir, "console.db"))
if err != nil {
t.Fatal(err)
}
- defer st.Close()
+ t.Cleanup(func() { st.Close() })
fk := newFakeKeymail(t, "paul@keymail.dev")
fk.mu.Lock()
@@ -60,7 +93,7 @@ func TestBillingFlowDemo(t *testing.T) {
fk.mu.Unlock()
stripe := newDemoStripe(origin)
stripeAPI := httptest.NewServer(http.HandlerFunc(stripe.serveAPI))
- defer stripeAPI.Close()
+ t.Cleanup(stripeAPI.Close)
srv, err := NewServer(Config{
Origin: origin, Keymail: origin + "/__keymail", Registry: regPath,
@@ -70,6 +103,13 @@ func TestBillingFlowDemo(t *testing.T) {
t.Fatal(err)
}
srv.instances.timeout = 100 * time.Millisecond
+ keymailTransport := &http.Transport{Proxy: nil}
+ t.Cleanup(keymailTransport.CloseIdleConnections)
+ srv.km.httpc = &http.Client{
+ Transport: demoOriginTransport{origin: origin, inner: keymailTransport},
+ Timeout: 15 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }
client, err := billingstripe.NewClient("rk_test_demo", billingstripe.WithBaseURL(stripeAPI.URL))
if err != nil {
t.Fatal(err)
@@ -100,26 +140,12 @@ func TestBillingFlowDemo(t *testing.T) {
km.HandleFunc("POST /api/oauth/revoke", fk.handleRevoke)
km.HandleFunc("GET /api/federation/lookup", fk.handleLookup)
km.HandleFunc("POST /api/app/send", fk.handleSend)
- km.HandleFunc("GET /oauth/authorize", func(w http.ResponseWriter, r *http.Request) { demoKeymailAuthorize(w, r, fk) })
+ km.HandleFunc("GET /oauth/authorize", func(w http.ResponseWriter, r *http.Request) { demoKeymailAuthorize(w, r, fk, origin) })
mux.Handle("/__keymail/", http.StripPrefix("/__keymail", km))
mux.HandleFunc("/__stripe/", stripe.servePages)
mux.Handle("/", demoRewriteHostedURLs(origin, srv))
- ln, err := net.Listen("tcp", addr)
- if err != nil {
- t.Fatal(err)
- }
- hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
- go hs.Serve(ln) //nolint:errcheck
- t.Logf("billing flow demo serving on %s as %s", addr, origin)
- deadline := time.Now().Add(14 * time.Hour)
- for time.Now().Before(deadline) {
- if _, err := os.Stat(stop); err == nil {
- break
- }
- time.Sleep(5 * time.Second)
- }
- hs.Close()
+ return mux, srv
}
const demoWebhookSecret = "whsec_demo_not_a_secret"
@@ -128,8 +154,12 @@ const demoWebhookSecret = "whsec_demo_not_a_secret"
// for keymail.dev's passkey ceremony: the reviewer types an address and
// approves, and the fake mints a single-use code for the console to
// exchange. It serves both the sign-in and the step-up redirect.
-func demoKeymailAuthorize(w http.ResponseWriter, r *http.Request, fk *fakeKeymail) {
+func demoKeymailAuthorize(w http.ResponseWriter, r *http.Request, fk *fakeKeymail, origin string) {
q := r.URL.Query()
+ if q.Get("client_id") != origin || (q.Get("redirect_uri") != origin+"/auth/callback" && q.Get("redirect_uri") != origin+"/auth/stepup/callback") {
+ http.Error(w, "bad redirect_uri", http.StatusBadRequest)
+ return
+ }
if r.URL.Query().Get("approve") == "1" {
address := strings.TrimSpace(q.Get("address"))
if address == "" || !strings.Contains(address, "@") {
@@ -162,7 +192,7 @@ func demoKeymailAuthorize(w http.ResponseWriter, r *http.Request, fk *fakeKeymai
}
fmt.Fprintf(w, demoPage, "Simulated Keymail", `<p class="sim">SIMULATION. This stands in for the passkey sign-in at keymail.dev. No real Keymail account is touched. Type any address; it becomes the console user.</p>
<h1>`+step+` with Keymail</h1>
-<form method="get" action="`+html.EscapeString(r.URL.Path)+`">
+<form method="get" action="">
`+demoHidden(q, "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "response_type")+`
<input type="hidden" name="approve" value="1">
<label>Address <input type="email" name="address" value="paul@keymail.dev"></label>