Files
fd23832c90 feat(bootnode): Go port foundation as a Base plugin (5 modules end-to-end) (#15)
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 <z@zeekay.io>
2026-06-18 17:16:15 -07:00

73 lines
2.1 KiB
Go

package workers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDeliverSignsAndSucceeds(t *testing.T) {
secret := "whsec_test"
var gotSig string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSig = r.Header.Get(SignatureHeader)
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
d := NewDispatcher(5 * time.Second)
res, err := d.Deliver(context.Background(), srv.URL, secret, map[string]any{"event": "block", "number": 42})
if err != nil {
t.Fatalf("Deliver: %v", err)
}
if !res.Success || res.StatusCode != http.StatusOK {
t.Fatalf("expected success 200, got %+v", res)
}
// Verify the signature the receiver got is a valid HMAC of the body.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(gotBody)
want := hex.EncodeToString(mac.Sum(nil))
if gotSig != want {
t.Fatalf("signature mismatch: got %q want %q", gotSig, want)
}
}
func TestDeliverNon2xxIsFailureNotError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
d := NewDispatcher(5 * time.Second)
res, err := d.Deliver(context.Background(), srv.URL, "s", map[string]any{"x": 1})
if err != nil {
t.Fatalf("non-2xx must not be a Go error: %v", err)
}
if res.Success {
t.Fatal("500 must be recorded as Success=false")
}
if res.StatusCode != http.StatusInternalServerError || res.ResponseBody != "boom" {
t.Fatalf("delivery not recorded correctly: %+v", res)
}
}
func TestDeliverBadURLIsFailure(t *testing.T) {
d := NewDispatcher(time.Second)
res, err := d.Deliver(context.Background(), "http://127.0.0.1:0/never", "s", map[string]any{})
if err != nil {
t.Fatalf("unreachable host must be a Delivery failure, not an error: %v", err)
}
if res.Success || res.Error == "" {
t.Fatalf("expected failure with error message, got %+v", res)
}
}