Fix mounted simulated Keymail approval and cover demo journey
2 files changed,
+398
−20
internal/console/billingflow_demo_journey_test.go+348 −0internal/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¤cy=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> |