server: enterprise-edition checkout — public Stripe mint for /enterprise/ (#1174)
The Eleven Enterprise Edition page (elevenmessenger.com/enterprise/) gets a working till: POST /api/enterprise/checkout on the cloud service mints a Stripe Checkout Session for year one — base €979,000 (€1,100,000 minus the €121,000 only-for-you offer), €11 × seats, and the €1 intern when the blessed 1010.09 headcount asks for one. Amounts are compiled in; the client sends only seats/intern/currency (eur·usd·gbp, same numerals — no exchange rates). Unlike the caller-token billing routes this one is public (the static marketing site's browser JS calls it), so: CORS pinned to the marketing origin, fixed server-side redirect URLs, a global mint rate limit, and a seat cap at 1,909 — Stripe's 8-digit per-charge maximum, which the only-for-you offer is what squeezes the invoice under. Its key is LCHAT_STRIPE_ENTERPRISE_KEY, separate from the live billing key with no fallback: unset = feature off (501), sandbox first, and the live flip is an explicit operator swap of that one value. docs/secrets.md gains the entry.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+444
−0
cloud.go+17 −0docs/secrets.md+8 −0enterprise.go+246 −0enterprise_test.go+173 −0
diff --git a/cloud.go b/cloud.go| index 2787224..c9c22a0 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -102,6 +102,10 @@ type cloudServer struct { |
| reapDryRun bool // log what the reaper would reclaim, delete nothing |
| reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited) |
| billing *stripeBilling // optional space upgrades (billing.go); nil = not offered |
| + // enterprise is the Enterprise Edition page's public checkout |
| + // (enterprise.go) — its own Stripe key, deliberately separate from |
| + // billing's so it can run against the sandbox; nil = off. |
| + enterprise *enterpriseCheckout |
| storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| // replicationClasses maps tier keys (+ "free") to durability-ladder classes |
| // (cloudreplication.go); empty = classes never pushed, every space streams. |
| @@ -138,6 +142,11 @@ func (s *cloudServer) handler() http.Handler { |
| mux.HandleFunc("GET /api/instances/{host}/demo-status", s.callerAuth(s.demoStatusHandler)) |
| mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler)) |
| mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler) |
| + // The Enterprise Edition page's checkout (enterprise.go): public but |
| + // CORS-scoped to the marketing origin — the static site's browser JS is |
| + // the caller, so there is no token to gate with; amounts are compiled in. |
| + mux.HandleFunc("POST /api/enterprise/checkout", s.enterpriseCheckoutHandler) |
| + mux.HandleFunc("OPTIONS /api/enterprise/checkout", s.enterprisePreflightHandler) |
| // Usage ingest (#857): meter-token-gated, not caller-gated — an instance |
| // can only ever report as itself. |
| mux.HandleFunc("POST /api/usage", s.usageHandler) |
| @@ -635,6 +644,13 @@ func runCloud(args []string) { |
| // rollout (every live Price must resolve a tax_behavior first). |
| billing.automaticTax = os.Getenv("LCHAT_STRIPE_AUTOMATIC_TAX") == "1" |
| } |
| + // The Enterprise Edition page's checkout: off until its own key is set |
| + // (sandbox first — the live flip is swapping that one value; enterprise.go). |
| + enterprise := newEnterpriseCheckout( |
| + os.Getenv("LCHAT_STRIPE_ENTERPRISE_KEY"), |
| + envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), |
| + envOr("LCHAT_ENTERPRISE_ORIGIN", "https://elevenmessenger.com"), |
| + ) |
| poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS")) |
| if err != nil { |
| die("cloud: LCHAT_POOL_COSTS: %v", err) |
| @@ -653,6 +669,7 @@ func runCloud(args []string) { |
| reapDryRun: *reapDryRun, |
| reapMax: *reapMax, |
| billing: billing, |
| + enterprise: enterprise, |
| storageCaps: storageCaps, |
| replicationClasses: replicationClasses, |
| createBudget: *createBudget, |
diff --git a/docs/secrets.md b/docs/secrets.md| index 94f350b..0b52436 100644 |
| --- a/docs/secrets.md |
| +++ b/docs/secrets.md |
| @@ -162,6 +162,14 @@ private — is `docs/secrets-vault.md`. |
| connected accounts (sellers' Stripe accounts) within the platform's |
| permissions; the webhook secret forges membership entitlements on that |
| instance. *Rotate*: same shapes as the cloud pair above. |
| +- **Enterprise Edition checkout key** (`LCHAT_STRIPE_ENTERPRISE_KEY`) — the |
| + cloud env's *separate* Stripe key for the elevenmessenger.com/enterprise/ |
| + page (enterprise.go). Deliberately independent of |
| + `LCHAT_STRIPE_SECRET_KEY` with no fallback: it holds the **sandbox** key |
| + while real billing stays live, and the live flip is swapping this one |
| + value. *Blast radius*: whatever account the key belongs to — sandbox ≈ |
| + none; if flipped live, same as the live key above. *Rotate*: roll in the |
| + matching Stripe mode's dashboard, swap the cloud env, restart. |
| - Kept alongside but **not secrets**: `LCHAT_STRIPE_PRICES` (the price book — |
| price ids are public by the time Checkout renders) and |
| `LCHAT_STRIPE_PORTAL_CONFIG` (a portal configuration id). |
diff --git a/enterprise.go b/enterprise.go| new file mode 100644 |
| index 0000000..f4b279e |
| --- /dev/null |
| +++ b/enterprise.go |
| @@ -0,0 +1,246 @@ |
| +package main |
| + |
| +// 🤖 enterprise.go — checkout for the Eleven Enterprise Edition page |
| +// (elevenmessenger.com/enterprise/): the satirical-but-real €1,100,000/year |
| +// invoice. A public, CORS-scoped endpoint that mints a Stripe Checkout |
| +// Session for year one; the page redirects the buyer to Stripe's hosted |
| +// payment page and celebrates on return. |
| +// |
| +// Deliberate shape: |
| +// - Its Stripe key is LCHAT_STRIPE_ENTERPRISE_KEY, separate from the space |
| +// billing key and with NO fallback to it — so the page runs against the |
| +// sandbox while real billing stays live, and going live is an explicit |
| +// operator act (swapping that one value), never a config accident. |
| +// - The browser calls this directly (the marketing site is static GitHub |
| +// Pages with no backend), so unlike every other billing route this one is |
| +// public: CORS pinned to the marketing origin, fixed server-side redirect |
| +// URLs (the client picks nothing), and a global mint rate limit. |
| +// - Amounts are compiled in, not client-supplied: the client sends only a |
| +// seat count, the intern flag, and a currency. The numerals are identical |
| +// in every currency — the elevens are load-bearing, we don't do exchange |
| +// rates. |
| +// - mode=payment (year one, one invoice). The +11%/year escalator stays |
| +// contractual comedy until a subscription-schedule slice earns its keep. |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// The price list, in minor units (cents/pence — EUR, USD and GBP all ×100). |
| +// Base €1,100,000 minus the €121,000 only-for-you offer (11% of base, 11² |
| +// thousand); €11 a seat; the intern — one eleventh of an employee — exactly 1. |
| +const ( |
| + enterpriseBaseMinor = (1_100_000 - 121_000) * 100 |
| + enterpriseSeatMinor = 11 * 100 |
| + enterpriseInternMinor = 1 * 100 |
| + // The intern is only ever the .09 of the blessed 1010.09 headcount. |
| + enterpriseInternSeats = 1010 |
| + // Stripe caps a single payment at 8 digits of the minor unit |
| + // (99,999,999 ≈ €999,999.99). The only-for-you offer is what carries the |
| + // invoice under that cap at all; past this many seats even the offer |
| + // can't save it, and Sales will call you. |
| + enterpriseMaxSeats = (99_999_999 - enterpriseBaseMinor) / enterpriseSeatMinor |
| +) |
| + |
| +var enterpriseCurrencies = map[string]bool{"eur": true, "usd": true, "gbp": true} |
| + |
| +// enterpriseCheckout holds the page's Stripe config. nil on cloudServer = |
| +// the feature is off and the endpoint answers 501. |
| +type enterpriseCheckout struct { |
| + key string // LCHAT_STRIPE_ENTERPRISE_KEY — sandbox until the deliberate live flip |
| + apiBase string // https://api.stripe.com; tests point it at a fake |
| + origin string // the marketing origin: CORS allow + redirect base |
| + client *http.Client |
| + limiter mintLimiter |
| +} |
| + |
| +func newEnterpriseCheckout(key, apiBase, origin string) *enterpriseCheckout { |
| + key = strings.TrimSpace(key) |
| + if key == "" { |
| + return nil |
| + } |
| + return &enterpriseCheckout{ |
| + key: key, |
| + apiBase: apiBase, |
| + origin: strings.TrimRight(origin, "/"), |
| + client: &http.Client{Timeout: 20 * time.Second}, |
| + limiter: mintLimiter{max: 30, window: time.Minute}, |
| + } |
| +} |
| + |
| +// mintLimiter is a global rolling-window cap on session mints — this endpoint |
| +// is public, and each hit costs a Stripe API call. Per-IP bookkeeping isn't |
| +// worth carrying for a page whose realistic traffic is one CFO at a time. |
| +type mintLimiter struct { |
| + mu sync.Mutex |
| + stamps []time.Time |
| + max int |
| + window time.Duration |
| +} |
| + |
| +func (l *mintLimiter) allow(now time.Time) bool { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + keep := l.stamps[:0] |
| + for _, t := range l.stamps { |
| + if now.Sub(t) < l.window { |
| + keep = append(keep, t) |
| + } |
| + } |
| + l.stamps = keep |
| + if len(l.stamps) >= l.max { |
| + return false |
| + } |
| + l.stamps = append(l.stamps, now) |
| + return true |
| +} |
| + |
| +// enterpriseCORS stamps the CORS headers on every response from the endpoint. |
| +// The allow-origin is the single marketing origin, never *: the page is the |
| +// only legitimate caller, and scoping it keeps random sites from spending our |
| +// Stripe rate limit from their visitors' browsers. |
| +func (s *cloudServer) enterpriseCORS(w http.ResponseWriter) bool { |
| + if s.enterprise == nil { |
| + return false |
| + } |
| + h := w.Header() |
| + h.Set("Access-Control-Allow-Origin", s.enterprise.origin) |
| + h.Set("Vary", "Origin") |
| + return true |
| +} |
| + |
| +// enterprisePreflightHandler: OPTIONS /api/enterprise/checkout — the CORS |
| +// preflight for the cross-origin JSON POST below. |
| +func (s *cloudServer) enterprisePreflightHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.enterpriseCORS(w) { |
| + w.WriteHeader(http.StatusNotImplemented) |
| + return |
| + } |
| + h := w.Header() |
| + h.Set("Access-Control-Allow-Methods", "POST") |
| + h.Set("Access-Control-Allow-Headers", "Content-Type") |
| + h.Set("Access-Control-Max-Age", "86400") |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// enterpriseCheckoutHandler: POST /api/enterprise/checkout — mint the year-one |
| +// Checkout Session. Body: {"seats": n, "intern": bool, "currency": "eur"}. |
| +func (s *cloudServer) enterpriseCheckoutHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.enterpriseCORS(w) { |
| + writeControlErr(w, http.StatusNotImplemented, "unavailable", "enterprise checkout is not configured") |
| + return |
| + } |
| + e := s.enterprise |
| + if !e.limiter.allow(time.Now()) { |
| + writeControlErr(w, http.StatusTooManyRequests, "slow_down", "the till is busy — try again in a minute") |
| + return |
| + } |
| + var req struct { |
| + Seats int `json:"seats"` |
| + Intern bool `json:"intern"` |
| + Currency string `json:"currency"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| + return |
| + } |
| + cur := strings.ToLower(strings.TrimSpace(req.Currency)) |
| + if !enterpriseCurrencies[cur] { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "currency must be eur, usd or gbp") |
| + return |
| + } |
| + if req.Seats < 1 { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "at least one employee required") |
| + return |
| + } |
| + // The intern is exactly the .09 of 1010.09 — any other fractional |
| + // headcount was already rounded to a person by the page. |
| + if req.Intern && req.Seats != enterpriseInternSeats { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "the intern only joins a 1,010-person team") |
| + return |
| + } |
| + if req.Seats > enterpriseMaxSeats { |
| + writeControlErr(w, http.StatusBadRequest, "over_stripe_cap", |
| + fmt.Sprintf("Stripe cannot carry this much money in one payment — %s employees is the most it can hold. Sales will call you.", |
| + formatCount(enterpriseMaxSeats))) |
| + return |
| + } |
| + checkoutURL, err := e.createSession(cur, req.Seats, req.Intern) |
| + if err != nil { |
| + log.Printf("cloud: enterprise checkout (%s, %d seats, intern=%v): %v", cur, req.Seats, req.Intern, err) |
| + writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| +} |
| + |
| +// createSession builds the hosted Checkout page: three price_data line items |
| +// (no dashboard Prices to manage), fixed redirect URLs on the marketing |
| +// origin, and no customer identity — Stripe collects what it needs. |
| +func (e *enterpriseCheckout) createSession(currency string, seats int, intern bool) (string, error) { |
| + form := url.Values{} |
| + form.Set("mode", "payment") |
| + form.Set("line_items[0][price_data][currency]", currency) |
| + form.Set("line_items[0][price_data][product_data][name]", "Eleven Enterprise Edition — year one (only-for-you offer applied)") |
| + form.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(enterpriseBaseMinor)) |
| + form.Set("line_items[0][quantity]", "1") |
| + form.Set("line_items[1][price_data][currency]", currency) |
| + form.Set("line_items[1][price_data][product_data][name]", "Seat — one employee, one year") |
| + form.Set("line_items[1][price_data][unit_amount]", strconv.Itoa(enterpriseSeatMinor)) |
| + form.Set("line_items[1][quantity]", strconv.Itoa(seats)) |
| + if intern { |
| + form.Set("line_items[2][price_data][currency]", currency) |
| + form.Set("line_items[2][price_data][product_data][name]", "The intern (one eleventh of an employee)") |
| + form.Set("line_items[2][price_data][unit_amount]", strconv.Itoa(enterpriseInternMinor)) |
| + form.Set("line_items[2][quantity]", "1") |
| + } |
| + form.Set("metadata[enterprise]", "1") |
| + form.Set("metadata[seats]", strconv.Itoa(seats)) |
| + form.Set("metadata[intern]", strconv.FormatBool(intern)) |
| + form.Set("success_url", e.origin+"/enterprise/?paid=1") |
| + form.Set("cancel_url", e.origin+"/enterprise/") |
| + |
| + req, err := http.NewRequest("POST", e.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+e.key) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + resp, err := e.client.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + URL string `json:"url"` |
| + Error struct { |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| + return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + } |
| + if resp.StatusCode != http.StatusOK || body.URL == "" { |
| + return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| + } |
| + return body.URL, nil |
| +} |
| + |
| +// formatCount renders a count with locale-style thousands separators for the |
| +// one English error message that carries a number ("1,909 employees…"). |
| +func formatCount(n int) string { |
| + s := strconv.Itoa(n) |
| + for i := len(s) - 3; i > 0; i -= 3 { |
| + s = s[:i] + "," + s[i:] |
| + } |
| + return s |
| +} |
diff --git a/enterprise_test.go b/enterprise_test.go| new file mode 100644 |
| index 0000000..d8ac7e0 |
| --- /dev/null |
| +++ b/enterprise_test.go |
| @@ -0,0 +1,173 @@ |
| +package main |
| + |
| +// 🤖 enterprise_test.go — the Enterprise Edition checkout (enterprise.go): |
| +// the mint path against a fake Stripe, the compiled-in amounts, the Stripe |
| +// 8-digit cap, the blessed-intern rule, and the CORS scoping that makes a |
| +// public billing endpoint tolerable. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// enterpriseTestCloud is newTestCloud plus an enterpriseCheckout pointed at |
| +// the fake Stripe (reused from cloud_billing_test.go — same sessions path). |
| +func enterpriseTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values) { |
| + t.Helper() |
| + stripe, captured := fakeStripe(t) |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.enterprise = newEnterpriseCheckout("sk_test_enterprise", stripe.URL, "https://elevenmessenger.com") |
| + return srv, s, captured |
| +} |
| + |
| +func postEnterprise(t *testing.T, srv *httptest.Server, body map[string]any) *http.Response { |
| + t.Helper() |
| + raw, _ := json.Marshal(body) |
| + resp, err := http.Post(srv.URL+"/api/enterprise/checkout", "application/json", bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func TestEnterpriseCheckoutMintsSession(t *testing.T) { |
| + srv, _, captured := enterpriseTestCloud(t) |
| + |
| + resp := postEnterprise(t, srv, map[string]any{"seats": 1010, "intern": true, "currency": "eur"}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "checkout.stripe.com") { |
| + t.Errorf("url = %q, want the hosted checkout page", u) |
| + } |
| + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://elevenmessenger.com" { |
| + t.Errorf("Access-Control-Allow-Origin = %q", got) |
| + } |
| + |
| + // The compiled-in invoice: €979,000 base (after the −€121,000 offer), |
| + // €11 × 1,010 seats, and the €1 intern — €990,111, all in minor units, |
| + // under Stripe's 8-digit per-charge cap. |
| + f := *captured |
| + if f.Get("mode") != "payment" { |
| + t.Errorf("mode = %q, want payment", f.Get("mode")) |
| + } |
| + if f.Get("line_items[0][price_data][unit_amount]") != "97900000" || |
| + f.Get("line_items[1][price_data][unit_amount]") != "1100" || |
| + f.Get("line_items[1][quantity]") != "1010" || |
| + f.Get("line_items[2][price_data][unit_amount]") != "100" { |
| + t.Errorf("amounts: %v", f) |
| + } |
| + for i := 0; i < 3; i++ { |
| + if got := f.Get("line_items[" + string(rune('0'+i)) + "][price_data][currency]"); got != "eur" { |
| + t.Errorf("line %d currency = %q, want eur", i, got) |
| + } |
| + } |
| + // Redirects are ours, fixed server-side; the client picked nothing. |
| + if !strings.HasPrefix(f.Get("success_url"), "https://elevenmessenger.com/enterprise/") || |
| + !strings.HasPrefix(f.Get("cancel_url"), "https://elevenmessenger.com/enterprise/") { |
| + t.Errorf("redirects: success=%q cancel=%q", f.Get("success_url"), f.Get("cancel_url")) |
| + } |
| + for k := range f { |
| + if strings.Contains(k, "customer") || strings.Contains(k, "email") { |
| + t.Errorf("checkout form leaks a customer field: %s", k) |
| + } |
| + } |
| +} |
| + |
| +func TestEnterpriseCheckoutWithoutIntern(t *testing.T) { |
| + srv, _, captured := enterpriseTestCloud(t) |
| + if resp := postEnterprise(t, srv, map[string]any{"seats": 3, "currency": "usd"}); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + f := *captured |
| + if f.Get("line_items[1][quantity]") != "3" || f.Get("line_items[1][price_data][currency]") != "usd" { |
| + t.Errorf("seat line: %v", f) |
| + } |
| + if f.Has("line_items[2][price_data][unit_amount]") { |
| + t.Errorf("no intern was ordered, but one is on the invoice: %v", f) |
| + } |
| +} |
| + |
| +func TestEnterpriseCheckoutValidation(t *testing.T) { |
| + srv, _, _ := enterpriseTestCloud(t) |
| + cases := []struct { |
| + name string |
| + body map[string]any |
| + code string |
| + }{ |
| + {"bad currency", map[string]any{"seats": 10, "currency": "chf"}, "bad_request"}, |
| + {"zero seats", map[string]any{"seats": 0, "currency": "eur"}, "bad_request"}, |
| + {"intern needs 1010", map[string]any{"seats": 11, "intern": true, "currency": "eur"}, "bad_request"}, |
| + // 1,910 seats pushes the invoice past Stripe's 99,999,999-minor-unit |
| + // per-charge maximum; 1,909 (asserted below) is the last legal count. |
| + {"over the Stripe cap", map[string]any{"seats": 1910, "currency": "eur"}, "over_stripe_cap"}, |
| + } |
| + for _, tc := range cases { |
| + resp := postEnterprise(t, srv, tc.body) |
| + if resp.StatusCode != http.StatusBadRequest { |
| + t.Errorf("%s: status = %d, want 400", tc.name, resp.StatusCode) |
| + continue |
| + } |
| + if got, _ := decodeBody(t, resp)["error"].(string); got != tc.code { |
| + t.Errorf("%s: error = %q, want %q", tc.name, got, tc.code) |
| + } |
| + } |
| + if enterpriseMaxSeats != 1909 { |
| + t.Errorf("enterpriseMaxSeats = %d, want 1909 ((99,999,999 − 97,900,000) / 1,100)", enterpriseMaxSeats) |
| + } |
| + if resp := postEnterprise(t, srv, map[string]any{"seats": 1909, "currency": "gbp"}); resp.StatusCode != http.StatusOK { |
| + t.Errorf("1,909 seats (the cap) = %d, want 200", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestEnterpriseCheckoutUnconfigured(t *testing.T) { |
| + srv, s, _ := enterpriseTestCloud(t) |
| + s.enterprise = nil |
| + if resp := postEnterprise(t, srv, map[string]any{"seats": 10, "currency": "eur"}); resp.StatusCode != http.StatusNotImplemented { |
| + t.Errorf("status = %d, want 501", resp.StatusCode) |
| + } |
| + // And the empty-key constructor is how "off" happens in production. |
| + if newEnterpriseCheckout("", "https://api.stripe.com", "https://elevenmessenger.com") != nil { |
| + t.Error("empty key should disable enterprise checkout") |
| + } |
| +} |
| + |
| +func TestEnterprisePreflight(t *testing.T) { |
| + srv, _, _ := enterpriseTestCloud(t) |
| + req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/enterprise/checkout", nil) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusNoContent { |
| + t.Fatalf("status = %d, want 204", resp.StatusCode) |
| + } |
| + h := resp.Header |
| + if h.Get("Access-Control-Allow-Origin") != "https://elevenmessenger.com" || |
| + !strings.Contains(h.Get("Access-Control-Allow-Methods"), "POST") || |
| + !strings.Contains(strings.ToLower(h.Get("Access-Control-Allow-Headers")), "content-type") { |
| + t.Errorf("preflight headers: %v", h) |
| + } |
| +} |
| + |
| +func TestEnterpriseMintLimiter(t *testing.T) { |
| + l := mintLimiter{max: 2, window: time.Minute} |
| + now := time.Now() |
| + if !l.allow(now) || !l.allow(now) { |
| + t.Fatal("first two mints should pass") |
| + } |
| + if l.allow(now) { |
| + t.Error("third mint inside the window should be refused") |
| + } |
| + if !l.allow(now.Add(2 * time.Minute)) { |
| + t.Error("a fresh window should allow again") |
| + } |
| +} |