mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Why: consolidate the Python bootnode backend (bootnode/api/, ~100 .py files)
onto Hanzo Base, leveraging Base's IAM client and per-org/per-user tenant
infrastructure instead of reimplementing auth, sessions, and multi-tenancy.
This is the structural foundation + the 5 most-important modules, not the whole
port.
What landed
- plugins/bootnode/: blockchain developer platform mounted under /v1.
Five modules ported end-to-end:
1. auth — multi-network OAuth2 callback (lux/pars/zoo/hanzo share the
lux-web3 IAM app; client id derived from redirect_uri) + bn_
project API keys (salted SHA-256, raw key shown once, verified
in constant time). Accepts IAM JWTs and pk-/sk-/hk- keys by
reusing github.com/hanzoai/base/iam — no IAM logic duplicated.
2. team — org/member CRUD scoped to the caller's project; invited emails
resolved against IAM (active) or held pending with an invite
token.
3. networks — applies bootno.de/v1 Network CRs (white-label brand, tier,
region, validator fleet). Replaces the Python's kubectl + raw
nginx-Ingress templating with a declarative CR for the
bootno.de operator to reconcile.
4. nodes — applies bootno.de/v1 NodeFleet CRs (CRD-driven cloud path;
the Python docker provider was a local-dev concern).
5. keys — applies bootno.de/v1 KMSSecret CRs by KMS path. NO plaintext
key material ever touches this service; the request and
response carry none, and a guard rejects any private-key field.
- plugins/bootnode/kube/: dependency-free Kubernetes REST client (net/http
server-side apply). No client-go, no CGO. In-cluster SA or KUBE_APISERVER.
- plugins/commerce/: typed Hanzo Commerce (Square billing) client behind a
Client interface. bootnode depends on the interface; commerce never depends
on bootnode/iam.
- 11 SQLAlchemy models -> Base collections (models/collections.go). No `users`
collection: IAM owns identity; bootnode references IAM user ids as text.
OrgCluster is the canonical org->k8s-cluster mapping.
- examples/base/main.go: platform plugin now runs PrincipalIsolation="sqlite"
(per-org + per-user encrypted SQLite); bootnode registered (BOOTNODE_ENABLED).
Modules pending (15, tracked in PR body): chat, zap, billing-http, bundler,
fleets, gas, infra, launch, lux, mpc, nfts, observability, rpc, tokens,
transfers, wallets, webhooks-http. (chains is also ported as a bonus 6th.)
Tests (20 functions, all green incl -race)
- auth: token classification, key gen/hash/verify (tamper + wrong-salt),
redirect->clientId derivation.
- kube: server-side-apply shape (PATCH + apply-patch+yaml + fieldManager),
error propagation, 404 get/delete idempotency.
- commerce: disabled no-op, get-or-create (create + existing), usage error
propagation, immediate cancel.
- workers: HMAC-signed delivery, non-2xx-is-failure, unreachable-is-failure.
- bootnode: full end-to-end against a fake IAM + fake apiserver — /me 401 then
200, project + bn_ key, team invite/list, Network + NodeFleet CR apply/get,
KMSSecret plaintext-rejection + apply + status, public chains. Plus
fail-fast on the insecure default salt against production IAM.
Verified: `go build ./...` exits 0; `go test -race ./plugins/bootnode/...
./plugins/commerce/...` passes; live binary serves /v1/chains (200) and gates
/v1/auth/me, /v1/networks (401); per-org SQLite isolation logged active; all
10 _bootnode_ collections created on boot.
Co-authored-by: zeekay <[email protected]>
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
package commerce
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestDisabledClientIsNoOp(t *testing.T) {
|
|
c := New(Config{}) // no API key
|
|
if c.Enabled() {
|
|
t.Fatal("client with no API key must be disabled")
|
|
}
|
|
err := c.ReportUsage(context.Background(), "sub_1", 10, "idem")
|
|
cerr, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("want *Error, got %T: %v", err, err)
|
|
}
|
|
if cerr.Status != http.StatusServiceUnavailable {
|
|
t.Fatalf("disabled client must return 503, got %d", cerr.Status)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateCustomer_CreatesWhenAbsent(t *testing.T) {
|
|
var sawLookup, sawCreate bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
|
t.Errorf("missing/incorrect auth header: %q", r.Header.Get("Authorization"))
|
|
}
|
|
if r.Header.Get("X-Source") != "bootnode" {
|
|
t.Errorf("missing X-Source header")
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/v1/user":
|
|
sawLookup = true
|
|
// empty data => not found
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
|
|
case r.Method == http.MethodPost && r.URL.Path == "/v1/user":
|
|
sawCreate = true
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
meta, _ := body["metadata"].(map[string]any)
|
|
if meta["iam_id"] != "iam-123" {
|
|
t.Errorf("iam_id not propagated to customer metadata: %v", meta)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(Customer{ID: "cust_1", Email: "[email protected]", Name: "Z", Org: "hanzo"})
|
|
default:
|
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(Config{BaseURL: srv.URL, APIKey: "test-key"})
|
|
cust, err := c.GetOrCreateCustomer(context.Background(), User{ID: "iam-123", Email: "[email protected]", Name: "Z", Org: "hanzo"})
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateCustomer: %v", err)
|
|
}
|
|
if cust.ID != "cust_1" {
|
|
t.Fatalf("want customer cust_1, got %q", cust.ID)
|
|
}
|
|
if !sawLookup || !sawCreate {
|
|
t.Fatalf("expected lookup then create; lookup=%v create=%v", sawLookup, sawCreate)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateCustomer_ReturnsExisting(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
t.Errorf("must not POST when customer already exists")
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"data": []Customer{{ID: "cust_existing", Email: "[email protected]"}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(Config{BaseURL: srv.URL, APIKey: "k"})
|
|
cust, err := c.GetOrCreateCustomer(context.Background(), User{ID: "x", Email: "[email protected]"})
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateCustomer: %v", err)
|
|
}
|
|
if cust.ID != "cust_existing" {
|
|
t.Fatalf("want cust_existing, got %q", cust.ID)
|
|
}
|
|
}
|
|
|
|
func TestReportUsage_PropagatesError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusPaymentRequired)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"message": "card declined"})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(Config{BaseURL: srv.URL, APIKey: "k"})
|
|
err := c.ReportUsage(context.Background(), "sub_1", 5, "idem-1")
|
|
cerr, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("want *Error, got %T", err)
|
|
}
|
|
if cerr.Status != http.StatusPaymentRequired || cerr.Message != "card declined" {
|
|
t.Fatalf("error not propagated: %+v", cerr)
|
|
}
|
|
}
|
|
|
|
func TestCancelSubscriptionImmediate(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
t.Errorf("cancel must use DELETE, got %s", r.Method)
|
|
}
|
|
if r.URL.Query().Get("immediate") != "true" {
|
|
t.Errorf("immediate flag not set")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(Config{BaseURL: srv.URL, APIKey: "k"})
|
|
if err := c.CancelSubscription(context.Background(), "sub_9", true); err != nil {
|
|
t.Fatalf("CancelSubscription: %v", err)
|
|
}
|
|
}
|