mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
447 lines
18 KiB
Go
447 lines
18 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
)
|
|
|
|
// runEdgeAuth mounts EdgeAuth ahead of a probe and drives req through the real
|
|
// zip chain. EdgeAuth mutates the underlying fasthttp request in place (strips /
|
|
// mints identity headers); the probe — running AFTER EdgeAuth via c.Next() —
|
|
// observes exactly what the downstream chain sees: the captured request headers,
|
|
// and whether it was reached at all (EdgeAuth
|
|
// never aborts a non-JWT bearer). The *Ctx is pooled and reset once the request
|
|
// returns, so every observation is taken inside the probe, never after.
|
|
func runEdgeAuth(t *testing.T, req *http.Request, captureHeaders ...string) (reached bool, headers map[string]string) {
|
|
t.Helper()
|
|
headers = map[string]string{}
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Use(EdgeAuth())
|
|
app.Raw(zip.MethodAll, "/*", func(c *zip.Ctx) error {
|
|
reached = true
|
|
for _, k := range captureHeaders {
|
|
headers[k] = c.Header(k)
|
|
}
|
|
return c.NoContent(http.StatusOK)
|
|
})
|
|
if _, err := app.Test(req); err != nil {
|
|
t.Fatalf("edgeauth request: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// TestEdgeAuth_StripsSpoofedIdentity proves the core security fix: at a
|
|
// directly-exposed edge a client-supplied X-Org-Id (and friends) must be
|
|
// removed so it can't impersonate an org via the header-trust path.
|
|
func TestEdgeAuth_StripsSpoofedIdentity(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "true")
|
|
|
|
req := httptest.NewRequest("GET", "/v1/billing/balance?user=hanzo¤cy=usd", nil)
|
|
req.Header.Set("X-Org-Id", "hanzo")
|
|
req.Header.Set("X-User-Permissions", "16") // spoofed admin bit
|
|
req.Header.Set("X-User-IsAdmin", "true")
|
|
|
|
keys := []string{"X-Org-Id", "X-User-Permissions", "X-User-IsAdmin"}
|
|
_, headers := runEdgeAuth(t, req, keys...)
|
|
for _, k := range keys {
|
|
if got := headers[k]; got != "" {
|
|
t.Fatalf("EdgeAuth(enabled) must strip spoofed %s, got %q", k, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeAuth_StripsSuperAdminSpoof proves the platform-sudo headers are
|
|
// stripped at the edge. GetIAMClaims derives SuperAdmin from the HOME org
|
|
// (X-User-Owner, owner=="admin") — so if EdgeAuth did NOT strip X-User-Owner, an
|
|
// in-cluster caller could forge `X-User-Owner: admin` and pass every cross-org
|
|
// gate (the /_/commerce/tenants escalation). The retired X-User-IsGlobalAdmin
|
|
// boolean is also still stripped so a stale client copy never survives. Both — plus
|
|
// X-Org-Id and X-User-IsAdmin — MUST be in the strip set (there is no JWT here, so
|
|
// nothing is re-minted; every identity header must come out empty).
|
|
func TestEdgeAuth_StripsSuperAdminSpoof(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "true")
|
|
|
|
req := httptest.NewRequest("POST", "/_/commerce/tenants", nil)
|
|
req.Header.Set("X-Org-Id", "admin")
|
|
req.Header.Set("X-User-Owner", "admin") // forged HOME org → sudo (the new escalation)
|
|
req.Header.Set("X-User-IsGlobalAdmin", "true") // forged retired boolean
|
|
req.Header.Set("X-User-IsAdmin", "true")
|
|
|
|
keys := []string{"X-Org-Id", "X-User-Owner", "X-User-IsGlobalAdmin", "X-User-IsAdmin"}
|
|
_, headers := runEdgeAuth(t, req, keys...)
|
|
for _, k := range keys {
|
|
if got := headers[k]; got != "" {
|
|
t.Fatalf("EdgeAuth must strip spoofed %s, got %q", k, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeAuth_OpaqueBearerStripsOrg: an opaque (non-JWT) bearer never gets its
|
|
// client-supplied X-Org-Id back. Restoring it let IAMTokenRequired treat an
|
|
// unvalidated token as a verified identity (opaque bearer + X-Org-Id ⇒ forged
|
|
// principal for any org). There used to be a private stash of that selector for
|
|
// a shared service token to read once verified; the token is gone, so the
|
|
// selector is simply dropped and an opaque bearer resolves no org.
|
|
//
|
|
// EdgeAuth never aborts an opaque bearer (not a JWT ⇒ minting skipped).
|
|
func TestEdgeAuth_OpaqueBearerStripsOrg(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "true")
|
|
|
|
req := httptest.NewRequest("POST", "/v1/billing/usage", nil)
|
|
req.Header.Set("Authorization", "Bearer st_opaque_service_token_not_a_jwt")
|
|
req.Header.Set("X-Org-Id", "maxpower")
|
|
|
|
reached, headers := runEdgeAuth(t, req, "X-Org-Id")
|
|
|
|
if !reached {
|
|
t.Fatal("EdgeAuth must NOT abort an opaque bearer (money path)")
|
|
}
|
|
// X-Org-Id MUST be stripped — never re-exposed to the trust boundary.
|
|
if got := headers["X-Org-Id"]; got != "" {
|
|
t.Fatalf("opaque bearer: X-Org-Id must stay stripped (bypass fix), got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestEdgeAuth_ClosesOpaqueBearerBypass is the explicit RED regression: the
|
|
// live-proved bypass was `Authorization: Bearer <opaque>` + `X-Org-Id: <target>`
|
|
// → EdgeAuth restored the client X-Org-Id → IAMTokenRequired trusted it (no
|
|
// token check) → the checkout money surface minted for the attacker's chosen
|
|
// org. After the fix, X-Org-Id is stripped and NEVER put back on the header for
|
|
// a non-service opaque bearer, so IAMTokenRequired resolves no org and the
|
|
// request cannot forge an IAM principal.
|
|
func TestEdgeAuth_ClosesOpaqueBearerBypass(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "true")
|
|
|
|
for _, path := range []string{
|
|
"/v1/checkout/sessions", "/v1/checkout/charge",
|
|
"/v1/checkout/authorize", "/v1/checkout/capture",
|
|
} {
|
|
req := httptest.NewRequest("POST", path, nil)
|
|
req.Header.Set("Authorization", "Bearer redteamprobe")
|
|
req.Header.Set("X-Org-Id", "hanzo")
|
|
|
|
_, headers := runEdgeAuth(t, req, "X-Org-Id")
|
|
|
|
if got := headers["X-Org-Id"]; got != "" {
|
|
t.Fatalf("%s: bypass vector X-Org-Id must be stripped, got %q", path, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEdgeAuth_DisabledIsNoOp proves gateway-fronted deployments are
|
|
// unaffected: with the flag off, identity headers pass through untouched.
|
|
func TestEdgeAuth_DisabledIsNoOp(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "")
|
|
|
|
req := httptest.NewRequest("GET", "/v1/billing/balance", nil)
|
|
req.Header.Set("X-Org-Id", "hanzo")
|
|
|
|
_, headers := runEdgeAuth(t, req, "X-Org-Id")
|
|
|
|
if got := headers["X-Org-Id"]; got != "hanzo" {
|
|
t.Fatalf("EdgeAuth(disabled) must be a no-op, X-Org-Id=%q", got)
|
|
}
|
|
}
|
|
|
|
// TestEdgeAuth_NilClientNeverMints proves fail-closed: a JWT-shaped token
|
|
// with no verifier (nil client) yields NO minted identity — the spoofed
|
|
// header is stripped and nothing is trusted in its place.
|
|
func TestEdgeAuth_NilClientNeverMints(t *testing.T) {
|
|
t.Setenv("COMMERCE_EDGE_AUTH", "true")
|
|
|
|
req := httptest.NewRequest("GET", "/v1/billing/balance", nil)
|
|
req.Header.Set("Authorization", "Bearer a.b.c")
|
|
req.Header.Set("X-Org-Id", "evil")
|
|
|
|
_, headers := runEdgeAuth(t, req, "X-Org-Id")
|
|
|
|
if got := headers["X-Org-Id"]; got != "" {
|
|
t.Fatalf("nil verifier must not mint identity, X-Org-Id=%q", got)
|
|
}
|
|
}
|
|
|
|
// billingReq builds a fasthttp request (method + URI + optional JSON body) for
|
|
// the header/body-locking unit tests — the direct callees now take the
|
|
// fasthttp request EdgeAuth mutates, not a net/http one.
|
|
func billingReq(method, uri, body string) *fasthttp.Request {
|
|
req := &fasthttp.Request{}
|
|
req.Header.SetMethod(method)
|
|
req.SetRequestURI(uri)
|
|
if body != "" {
|
|
req.Header.SetContentType("application/json")
|
|
req.SetBody([]byte(body))
|
|
}
|
|
return req
|
|
}
|
|
|
|
// TestLockBillingSubject proves per-org isolation: the billing subject is
|
|
// pinned to the caller's own org slug, other query params are preserved.
|
|
func TestLockBillingSubject(t *testing.T) {
|
|
req := billingReq("GET",
|
|
"/v1/billing/transactions?user=victim/other&userId=x&customerId=y&limit=5", "")
|
|
lockBillingSubject(req, "hanzo")
|
|
|
|
q, _ := url.ParseQuery(string(req.URI().QueryString()))
|
|
for _, k := range []string{"user", "userId", "customerId"} {
|
|
if q.Get(k) != "hanzo" {
|
|
t.Fatalf("%s must be locked to org slug, got %q", k, q.Get(k))
|
|
}
|
|
}
|
|
if q.Get("limit") != "5" {
|
|
t.Fatalf("unrelated query param clobbered, limit=%q", q.Get("limit"))
|
|
}
|
|
}
|
|
|
|
// TestLockBillingSubjectBody is the write-side counterpart to
|
|
// TestLockBillingSubject: a /billing/ write whose JSON body names a FOREIGN
|
|
// billing subject must have it pinned to the caller's own slug before the
|
|
// handler binds it — the exact gap that let a body customerId of "hanzo/z"
|
|
// orphan a payment method that every (query-locked) read could never see.
|
|
func TestLockBillingSubjectBody(t *testing.T) {
|
|
// amount is 2^53+1: a value float64 cannot hold exactly, so a clean
|
|
// round-trip proves the rewrite preserves numeric precision (json.Number).
|
|
const body = `{"customerId":"victim/other","amount":9007199254740993,"currency":"usd","brand":"visa"}`
|
|
req := billingReq("POST", "/v1/billing/methods", body)
|
|
|
|
lockBillingSubjectBody(req, "hanzo")
|
|
|
|
raw := req.Body()
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var m map[string]any
|
|
if err := dec.Decode(&m); err != nil {
|
|
t.Fatalf("rewritten body must stay valid JSON: %v (%s)", err, raw)
|
|
}
|
|
if m["customerId"] != "hanzo" {
|
|
t.Fatalf("body customerId must be pinned to caller slug, got %v", m["customerId"])
|
|
}
|
|
if n, _ := m["amount"].(json.Number); n.String() != "9007199254740993" {
|
|
t.Fatalf("numeric precision lost in rewrite, amount=%v", m["amount"])
|
|
}
|
|
if m["currency"] != "usd" || m["brand"] != "visa" {
|
|
t.Fatalf("unrelated fields clobbered: %v", m)
|
|
}
|
|
if got := req.Header.ContentLength(); got != len(raw) {
|
|
t.Fatalf("Content-Length must track rewritten body (%d), got %d", len(raw), got)
|
|
}
|
|
}
|
|
|
|
// TestLockBillingSubjectBody_PinsAllSubjectKeys: every billing-subject field in
|
|
// the body (user/userId/customerId) is pinned, mirroring the query lock's set.
|
|
func TestLockBillingSubjectBody_PinsAllSubjectKeys(t *testing.T) {
|
|
const body = `{"user":"a/b","userId":"c/d","customerId":"e/f","note":"keep"}`
|
|
req := billingReq("PUT", "/v1/billing/methods/123", body)
|
|
|
|
lockBillingSubjectBody(req, "hanzo")
|
|
|
|
var m map[string]any
|
|
if err := json.NewDecoder(bytes.NewReader(req.Body())).Decode(&m); err != nil {
|
|
t.Fatalf("rewritten body must stay valid JSON: %v", err)
|
|
}
|
|
for _, k := range []string{"user", "userId", "customerId"} {
|
|
if m[k] != "hanzo" {
|
|
t.Fatalf("body %s must be pinned to caller slug, got %v", k, m[k])
|
|
}
|
|
}
|
|
if m["note"] != "keep" {
|
|
t.Fatalf("unrelated field clobbered, note=%v", m["note"])
|
|
}
|
|
}
|
|
|
|
// TestLockBillingSubjectBody_ShapeAgnostic proves the rewrite is surgical: a
|
|
// body that is not a JSON object, carries none of the subject keys, or is
|
|
// unparseable passes through byte-for-byte — EdgeAuth never rejects or mangles
|
|
// an unexpected shape, it only ever pins the three known keys when present.
|
|
func TestLockBillingSubjectBody_ShapeAgnostic(t *testing.T) {
|
|
for _, body := range []string{
|
|
`["a","b"]`, // JSON array, not an object
|
|
`{"amount":100,"currency":"usd"}`, // object without any subject key
|
|
`not json at all`, // unparseable
|
|
``, // empty
|
|
} {
|
|
req := billingReq("POST", "/v1/billing/topup", body)
|
|
// billingReq skips Content-Type for an empty body; set it so the
|
|
// content-type gate is exercised for every shape.
|
|
req.Header.SetContentType("application/json")
|
|
lockBillingSubjectBody(req, "hanzo")
|
|
if got := string(req.Body()); got != body {
|
|
t.Fatalf("body %q must pass through untouched, got %q", body, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLockBillingSubjectBody_NonWriteMethodIgnored: reads (GET/DELETE) carry the
|
|
// subject in the query (locked by lockBillingSubject), so the body lock — which
|
|
// would consume the stream — must leave non-write methods untouched.
|
|
func TestLockBillingSubjectBody_NonWriteMethodIgnored(t *testing.T) {
|
|
const body = `{"customerId":"victim/other"}`
|
|
req := billingReq("GET", "/v1/billing/balance", body)
|
|
lockBillingSubjectBody(req, "hanzo")
|
|
if got := string(req.Body()); got != body {
|
|
t.Fatalf("GET body must be untouched, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestLockBillingSubjectBody_NonJSONIgnored: a non-JSON content type carries no
|
|
// billing-subject fields to pin, so the body is left untouched (and unread).
|
|
func TestLockBillingSubjectBody_NonJSONIgnored(t *testing.T) {
|
|
const body = `customerId=victim%2Fother&amount=100`
|
|
req := &fasthttp.Request{}
|
|
req.Header.SetMethod("POST")
|
|
req.SetRequestURI("/v1/billing/methods")
|
|
req.Header.SetContentType("application/x-www-form-urlencoded")
|
|
req.SetBody([]byte(body))
|
|
lockBillingSubjectBody(req, "hanzo")
|
|
if got := string(req.Body()); got != body {
|
|
t.Fatalf("non-JSON body must be untouched, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeJWTAndBearer(t *testing.T) {
|
|
cases := map[string]bool{"a.b.c": true, "sk-abc123": false, "": false, "a.b": false}
|
|
for tok, want := range cases {
|
|
if got := looksLikeJWT(tok); got != want {
|
|
t.Fatalf("looksLikeJWT(%q)=%v want %v", tok, got, want)
|
|
}
|
|
}
|
|
if got := bearerToken("Bearer xyz"); got != "xyz" {
|
|
t.Fatalf("bearerToken Bearer parse = %q", got)
|
|
}
|
|
if got := bearerToken("xyz"); got != "" {
|
|
t.Fatalf("bearerToken non-bearer must be empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestPermsHeader_AdminBitIsSuperAdminOnly is the money-hole regression. It pins the
|
|
// exact X-User-Permissions bit.Field permsHeader mints for the three principal
|
|
// classes, in the wire units commerce parses (permission.Live=4, Admin=16). The
|
|
// invariant: the Admin (money/admin) bit is minted ONLY for a SuperAdmin (home owner=="admin"); an
|
|
// org-level admin (an org owner) gets Live but NOT Admin, so
|
|
// TokenRequired(permission.Admin) on the credit-creating / card-charging billing
|
|
// endpoints denies them. Before the fix an org owner minted Admin|Live=20 and
|
|
// satisfied every admin money gate → unlimited free balance + platform-wide card
|
|
// charging (live-proven as Dave/maxpower).
|
|
func TestPermsHeader_AdminBitIsSuperAdminOnly(t *testing.T) {
|
|
const (
|
|
live = 4 // permission.Live (1 << 2)
|
|
admin = 16 // permission.Admin (1 << 4)
|
|
adminLive = admin | live
|
|
)
|
|
cases := []struct {
|
|
name string
|
|
claims *auth.IAMClaims
|
|
want int64
|
|
}{
|
|
// Plain authenticated user: no admin authority, no live mode. Zero bits.
|
|
{"plain-user", &auth.IAMClaims{Owner: "hanzo"}, 0},
|
|
// Org owner (maxpower): live mode YES (real Square top-up must work),
|
|
// Admin money bit NO. This is the exact principal that exploited the hole.
|
|
{"org-admin-live-not-admin", &auth.IAMClaims{Owner: "maxpower", IsAdmin: true}, live},
|
|
// SuperAdmin via the admin org (owner==home==admin): full Admin+Live.
|
|
{"global-admin-org", &auth.IAMClaims{Owner: "admin", IsAdmin: true}, adminLive},
|
|
// SuperAdmin via the HOME org (X-User-Owner==admin), any effective org: full Admin+Live.
|
|
{"superadmin-by-homeorg", &auth.IAMClaims{Owner: "hanzo", HomeOrg: "admin", IsAdmin: true}, adminLive},
|
|
// SuperAdmin (home org) WITHOUT org-level IsAdmin still carries Admin (the
|
|
// money authority), and also Live is absent (IsAdmin gates Live) —
|
|
// documents the orthogonality: authority (Admin) and mode (Live) are
|
|
// independent signals.
|
|
{"superadmin-homeorg-no-orgadmin", &auth.IAMClaims{Owner: "hanzo", HomeOrg: "admin"}, admin},
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := strconv.ParseInt(permsHeader(tc.claims), 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("%s: permsHeader returned non-integer: %v", tc.name, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("%s: permsHeader=%d want %d (admin bit set iff global admin)", tc.name, got, tc.want)
|
|
}
|
|
// Belt-and-suspenders: the Admin bit is present iff the caller is a global admin.
|
|
hasAdmin := got&admin != 0
|
|
if hasAdmin != tc.claims.IsSuperAdmin() {
|
|
t.Fatalf("%s: Admin bit=%v but IsSuperAdmin=%v — must match exactly", tc.name, hasAdmin, tc.claims.IsSuperAdmin())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolveBillingSubject_AdminOverride: a global admin may retarget the
|
|
// billing view to another org via ?org=, and the namespace follows it.
|
|
func TestResolveBillingSubject_AdminOverride(t *testing.T) {
|
|
req := billingReq("GET", "/v1/billing/balance?user=admin/z&org=hanzo¤cy=usd", "")
|
|
subject, override := resolveBillingSubject(req, &auth.IAMClaims{Owner: "admin"})
|
|
if subject != "hanzo" || !override {
|
|
t.Fatalf("admin ?org=hanzo => subject=%q override=%v, want hanzo,true", subject, override)
|
|
}
|
|
q, _ := url.ParseQuery(string(req.URI().QueryString()))
|
|
if q.Has("org") {
|
|
t.Fatalf("?org must be stripped from the query, got %q", req.URI().QueryString())
|
|
}
|
|
if q.Get("currency") != "usd" {
|
|
t.Fatalf("unrelated query param clobbered: %q", req.URI().QueryString())
|
|
}
|
|
}
|
|
|
|
// TestResolveBillingSubject_NonAdminIgnoresOverride is the isolation proof:
|
|
// a non-global-admin cannot use ?org= to read another org. The override is
|
|
// stripped and the subject stays pinned to the caller's own org.
|
|
func TestResolveBillingSubject_NonAdminIgnoresOverride(t *testing.T) {
|
|
// Dave: org owner with IsAdmin=true (org-level), NOT a global admin.
|
|
req := billingReq("GET", "/v1/billing/balance?org=hanzo¤cy=usd", "")
|
|
subject, override := resolveBillingSubject(req, &auth.IAMClaims{Owner: "maxpower", IsAdmin: true})
|
|
if subject != "maxpower" || override {
|
|
t.Fatalf("non-admin ?org=hanzo => subject=%q override=%v, want maxpower,false (isolation)", subject, override)
|
|
}
|
|
q, _ := url.ParseQuery(string(req.URI().QueryString()))
|
|
if q.Has("org") {
|
|
t.Fatalf("?org must be stripped even for non-admins, got %q", req.URI().QueryString())
|
|
}
|
|
}
|
|
|
|
// TestResolveBillingSubject_NoOverride: without ?org, every caller (incl. a
|
|
// global admin) defaults to their own org and the namespace is untouched.
|
|
func TestResolveBillingSubject_NoOverride(t *testing.T) {
|
|
req := billingReq("GET", "/v1/billing/balance?currency=usd", "")
|
|
subject, override := resolveBillingSubject(req, &auth.IAMClaims{Owner: "admin"})
|
|
if subject != "admin" || override {
|
|
t.Fatalf("no ?org => subject=%q override=%v, want admin,false", subject, override)
|
|
}
|
|
}
|
|
|
|
// TestResolveBillingSubject_RejectsBadSlug: a malformed ?org is discarded
|
|
// (treated as absent), so even an admin stays on their own org rather than a
|
|
// smuggled value.
|
|
func TestResolveBillingSubject_RejectsBadSlug(t *testing.T) {
|
|
req := billingReq("GET", "/v1/billing/balance?org=hanzo%2Fevil", "")
|
|
subject, override := resolveBillingSubject(req, &auth.IAMClaims{Owner: "admin"})
|
|
if subject != "admin" || override {
|
|
t.Fatalf("bad ?org slug => subject=%q override=%v, want admin,false", subject, override)
|
|
}
|
|
}
|
|
|
|
func TestValidOrgSlug(t *testing.T) {
|
|
good := []string{"hanzo", "admin", "max-power", "a", "org123"}
|
|
bad := []string{"", "-lead", "Caps", "has space", "slash/x", "under_score", "dot.org"}
|
|
for _, s := range good {
|
|
if !validOrgSlug(s) {
|
|
t.Fatalf("validOrgSlug(%q)=false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range bad {
|
|
if validOrgSlug(s) {
|
|
t.Fatalf("validOrgSlug(%q)=true, want false", s)
|
|
}
|
|
}
|
|
}
|