mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
* test+fix: clear pre-existing base test failures (iam /api→/v1, kube ns, pool timing) These failed on main already — surfaced once the zip-dep failure (#24) stopped masking the test run. None relate to zip; all are real drift/bugs: - iam userinfo path drift (5 failures): the client correctly requests the canonical /v1/iam/oauth/userinfo (HIP-0111), but the test fake-servers still registered the legacy /api/userinfo → 404. Updated iam_test.go (×4 + comment), platform_test.go, bootnode_test.go to the /v1 path. /api/get-user(s)/add-user are left as-is (client + tests agree there; a separate migration). - kube namespace bug (real): Client.New(defaultNamespace) let the in-cluster serviceaccount namespace file OVERRIDE an explicit namespace — so an ARC runner (arc-system) silently clobbered the caller's 'bootnode'. Now the pod namespace is adopted ONLY when the caller passed "". Explicit config wins. - db pool idle-eviction flake: IdleTimeout 50ms was shorter than the 3-pool setup loop on a loaded runner, so the sweeper evicted pool 0 before the Len()==3 assertion. Bumped to 300ms (+ wait 150→400ms). * build: goreleaser before-hook go mod download, not tidy (fixes release SECURITY ERROR) The release workflow runs GoReleaser on every PR; its before-hook ran `go mod tidy`, which RE-RESOLVES the module graph and trips the authenticated-git-only private module set (SECURITY ERROR / go.sum mismatch on the luxfi deps) — the Test and build steps pass because they verify against the committed go.sum via the runner's git auth instead of re-resolving. Release must never re-tidy (it mutates committed deps at release time). Swap to `go mod download`, which just populates the cache against the existing go.sum — same resolution path as the passing `go test ./...` step. * ci(release): GOPRIVATE + git-auth so goreleaser resolves luxfi from git The goreleaser go-mod hook fetched luxfi/keys from the public proxy (re-pushed bits) instead of authenticated git, so go.sum verification failed (SECURITY ERROR). Add GOPRIVATE + the same git insteadOf auth the Docker gate uses, at the job level, so test + the download hook + the builds all fetch hanzoai/* + luxfi/* from git — matching the committed go.sum. * feat(admin): @hanzo/ui true-black design system + buildable Vite baseline The ui-react admin was un-buildable WIP: package.json listed wouter/react-query while src used @tanstack/react-router + @luxfi/ui (Tamagui) with no Tailwind pipeline, a broken "/base" SDK import in all 19 routes, and a duplicate getRecord export. Nothing compiled. This lands a buildable baseline on the true-black @hanzo/ui shadcn design system. Verdict on @hanzo/ui consumability (settled empirically): the published @hanzo/ui@8 (gui/Tamagui, raw-TS source) and the @hanzo/ui barrel are NOT Vite-consumable — the barrel's index.mjs hard-imports next/image, next/link, cmdk, sonner, vaul, react-resizable-panels (unresolvable in Vite). @hanzo/ui@5 granular subpaths resolve but several components are coupled to bespoke theme classes. So we adopt @hanzo/ui's token table + true-black override and vendor the shadcn primitives (the pattern @hanzo/ui's own templates/vite-app uses). Verified in a browser: bodyBg #000, card #0a0a0a, hairline #171717, near-white primary, Basel + Geist Mono, .dark active. - package.json: real deps (@hanzo/[email protected], react-router, tailwind v3, radix, react-hook-form); drop wouter/@luxfi/ui - tailwind.config.cjs + postcss.config.cjs + src/index.css: shadcn HSL token scheme, true-black .dark (#000 canvas, gui surface/border ladder), Basel (self-hosted) + Geist Mono fonts - src/components/ui/{button,input,label}.tsx: vendored shadcn primitives on the shared tokens; src/lib/cn.ts - src/lib/api.ts: fix duplicate getRecord export (-> getRecordById), add getFullRecords paging + real /v1/realtime SSE client, export request - src/lib/base-client.ts: BaseClient object API over the /v1 fetch layer — unbreaks all 19 routes with zero route rewrites; base.ts drops broken "/base" - main.tsx: QueryClientProvider, import css, pin .dark, router basepath bound to BASE_ADMIN_UI_PATH (fixes /_/ mount routing); vite.config proxies /v1 - login.tsx converged onto @hanzo/ui primitives (first true-black route) - delete dead src/pages/* (old wouter generation, imported nowhere) - embed.go: correct the stale build comment (source is ./src, pnpm build) - rebuild committed dist/ (embedded by //go:embed) * feat(admin): Twenty-grade editable record grid on @hanzo/ui Replace the read-only records table with an inline-editable data grid — the heart of "edit everything". Cells own a display/edit split with a single commit/cancel protocol; the grid owns a two-tier keyboard cursor. - components/grid/RecordGrid: sortable columns (per-field sort toggle), sticky header, row selection + select-all, per-row actions (open/duplicate/delete), two-tier cursor — arrows move the active cell, Enter/F2/type-to-edit enters edit mode, Tab advances. True-black, hairline rows. - components/grid/EditableCell: display mode → floating Popover editor per type (text/number/date/textarea/json/select/relation); bool toggles in place; file/password/autodate render read-only. json validates on commit. - lib/fields: field-type → editor-kind mapping + display formatting + API value coercion, orthogonal to the grid. - routes/collections.$id.records: optimistic single-field persist (react-query onMutate cache patch + rollback on error), create (→ detail panel), duplicate, single + bulk delete behind a confirm Dialog, filter, pagination. - vendored primitives: checkbox, select, popover, dropdown-menu, dialog, textarea, badge (shadcn on the shared tokens); add the radix deps. - rebuild committed dist/ * feat(admin): record detail/edit panel on @hanzo/ui with per-type editors Rewrite the record editor on @hanzo/ui primitives + lib/fields. Each field renders the right editor by type — Input (text/number/date/url/email), Textarea (editor/json, json validated on submit), Select (single select), Checkbox (bool), file input (upload), relation as id csv. Auth collections get a password field; id + autodate render read-only. Controlled form state, optimistic invalidate, create/save/delete. True-black card, Basel type. Drops react-hook-form in favor of a small controlled form so @hanzo/ui Select/Checkbox bind cleanly. * feat(admin): converge root sidebar + collections list to true-black @hanzo/ui Phase 1 of the view convergence. The always-visible chrome and the entry point to the grid now match the true-black system. - __root: sidebar on bg-background/border-border with lucide icons + accent active state (was bg-neutral-950 + indigo) - collections: clickable collection rows -> /collections/$id/records, type/system badges, @hanzo/ui Input filter + Button delete (was raw table + indigo button) * fix(admin): flat route escapes so record grid/editor are reachable + safe default sort Two correctness fixes surfaced by end-to-end browser verification against a real Base server: 1. Routing: collections.$id[.records[.$recordId]] nested as TanStack layouts, but the parent components render content (not <Outlet/>), so /collections/$id/records rendered the collections list instead of the grid. Rename to flat escaped routes (collections_.$id_.records...) so each is a standalone route under root — URLs and all Link targets unchanged. 2. Default sort '-created' 400s on this fork's base collections (no `created` field). Default to '' (API insertion order, valid for any schema); sorting stays opt-in via column headers. Verified: grid renders true-black against /v1, inline text edit (floating editor) and bool toggle both persist to the real Base API (confirmed server-side). * docs(llm): admin @hanzo/ui rebuild — consumability verdict, IAM-native auth finding, phased plan --------- Co-authored-by: Hanzo Dev <[email protected]>
316 lines
10 KiB
Go
316 lines
10 KiB
Go
package bootnode
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/iam"
|
|
"github.com/hanzoai/base/plugins/bootnode/kube"
|
|
"github.com/hanzoai/base/plugins/bootnode/models"
|
|
"github.com/hanzoai/base/plugins/commerce"
|
|
"github.com/hanzoai/base/tests"
|
|
luxlog "github.com/luxfi/log"
|
|
)
|
|
|
|
// fakeIAM stands in for Hanzo IAM. It validates exactly one token and resolves
|
|
// it to a fixed user in the "lux" org.
|
|
func fakeIAM(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/iam/oauth/userinfo":
|
|
if r.Header.Get("Authorization") != "Bearer valid-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "user-123", "email": "[email protected]", "name": "Z", "orgIds": []string{"lux"},
|
|
})
|
|
case "/api/get-users": // LookupByAttribute (team invite)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "data": []any{}})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
}
|
|
|
|
// fakeAPIServer stands in for the Kubernetes apiserver. It records applied CRs
|
|
// in-memory and serves them back on GET (named + list).
|
|
func fakeAPIServer(t *testing.T) (*httptest.Server, map[string]map[string]any) {
|
|
store := map[string]map[string]any{} // path -> object
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPatch: // server-side apply
|
|
var obj map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&obj)
|
|
store[r.URL.Path] = obj
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(obj)
|
|
case http.MethodGet:
|
|
if obj, ok := store[r.URL.Path]; ok {
|
|
_ = json.NewEncoder(w).Encode(obj)
|
|
return
|
|
}
|
|
// List endpoint (no trailing name): return all matching items.
|
|
items := []map[string]any{}
|
|
for p, o := range store {
|
|
if strings.HasPrefix(p, r.URL.Path+"/") {
|
|
items = append(items, o)
|
|
}
|
|
}
|
|
if strings.HasSuffix(r.URL.Path, "/networks") ||
|
|
strings.HasSuffix(r.URL.Path, "/nodefleets") ||
|
|
strings.HasSuffix(r.URL.Path, "/kmssecrets") {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
case http.MethodDelete:
|
|
delete(store, r.URL.Path)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
return srv, store
|
|
}
|
|
|
|
// newTestPlugin wires a bootnode plugin against fake IAM + apiserver and an
|
|
// initialized test app with all collections created.
|
|
func newTestPlugin(t *testing.T) (*plugin, *tests.TestApp, func()) {
|
|
t.Helper()
|
|
app, err := tests.NewTestApp()
|
|
if err != nil {
|
|
t.Fatalf("NewTestApp: %v", err)
|
|
}
|
|
if err := models.EnsureAll(app); err != nil {
|
|
app.Cleanup()
|
|
t.Fatalf("EnsureAll: %v", err)
|
|
}
|
|
|
|
iamSrv := fakeIAM(t)
|
|
apiSrv, _ := fakeAPIServer(t)
|
|
|
|
k := kube.New("bootnode")
|
|
// Point the kube client at the fake apiserver (out-of-cluster shape).
|
|
kube.SetTarget(k, apiSrv.URL, "test-token")
|
|
|
|
cfg := Config{Enabled: true, IAMEndpoint: iamSrv.URL, APIKeySalt: "test-salt", KubeNamespace: "bootnode"}
|
|
cfg.resolve()
|
|
|
|
p := &plugin{
|
|
app: app,
|
|
config: cfg,
|
|
logger: luxlog.New("component", "bootnode-test"),
|
|
iam: iam.NewClient(iamSrv.URL),
|
|
kube: k,
|
|
commerce: commerce.New(commerce.Config{}),
|
|
}
|
|
|
|
cleanup := func() {
|
|
iamSrv.Close()
|
|
apiSrv.Close()
|
|
app.Cleanup()
|
|
}
|
|
return p, app, cleanup
|
|
}
|
|
|
|
// serve builds an httptest server over the plugin's mounted routes.
|
|
func serve(t *testing.T, p *plugin, app *tests.TestApp) *httptest.Server {
|
|
t.Helper()
|
|
r, err := apis.NewRouter(app)
|
|
if err != nil {
|
|
t.Fatalf("NewRouter: %v", err)
|
|
}
|
|
p.registerRoutes(r)
|
|
mux, err := r.BuildMux()
|
|
if err != nil {
|
|
t.Fatalf("BuildMux: %v", err)
|
|
}
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
func do(t *testing.T, method, url, token string, body any) (*http.Response, map[string]any) {
|
|
t.Helper()
|
|
var reader *bytes.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
reader = bytes.NewReader(b)
|
|
} else {
|
|
reader = bytes.NewReader(nil)
|
|
}
|
|
req, err := http.NewRequest(method, url, reader)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, url, err)
|
|
}
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
return resp, out
|
|
}
|
|
|
|
func TestEndToEnd_AuthTeamNetworksNodesKeys(t *testing.T) {
|
|
p, app, cleanup := newTestPlugin(t)
|
|
defer cleanup()
|
|
srv := serve(t, p, app)
|
|
defer srv.Close()
|
|
|
|
const tok = "valid-token"
|
|
|
|
// --- auth: unauthenticated is rejected ---
|
|
resp, _ := do(t, http.MethodGet, srv.URL+"/v1/auth/me", "", nil)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("unauth /me: want 401, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// --- auth: /me with a valid IAM token ---
|
|
resp, me := do(t, http.MethodGet, srv.URL+"/v1/auth/me", tok, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("/me: want 200, got %d", resp.StatusCode)
|
|
}
|
|
if me["id"] != "user-123" || me["org"] != "lux" {
|
|
t.Fatalf("/me identity wrong: %v", me)
|
|
}
|
|
|
|
// --- auth: create a project ---
|
|
resp, proj := do(t, http.MethodPost, srv.URL+"/v1/auth/projects", tok, map[string]any{"name": "My App"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create project: want 201, got %d (%v)", resp.StatusCode, proj)
|
|
}
|
|
projectID, _ := proj["id"].(string)
|
|
if projectID == "" || proj["orgId"] != "lux" {
|
|
t.Fatalf("project response wrong: %v", proj)
|
|
}
|
|
|
|
// --- auth: mint a bn_ API key, ensure raw key returned once + bn_ prefix ---
|
|
resp, keyResp := do(t, http.MethodPost, srv.URL+"/v1/auth/keys", tok, map[string]any{
|
|
"projectId": projectID, "name": "prod",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create key: want 201, got %d (%v)", resp.StatusCode, keyResp)
|
|
}
|
|
rawKey, _ := keyResp["key"].(string)
|
|
if !strings.HasPrefix(rawKey, "bn_") {
|
|
t.Fatalf("raw key must start with bn_, got %q", rawKey)
|
|
}
|
|
|
|
// --- auth: list keys never exposes the raw key ---
|
|
resp, _ = do(t, http.MethodGet, srv.URL+"/v1/auth/keys?projectId="+projectID, tok, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list keys: want 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// --- team: invite a member (pending, since fake IAM returns no user) ---
|
|
resp, member := do(t, http.MethodPost, srv.URL+"/v1/team", tok, map[string]any{
|
|
"email": "[email protected]", "role": "member",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("invite member: want 201, got %d (%v)", resp.StatusCode, member)
|
|
}
|
|
if member["status"] != "pending" || member["role"] != "member" {
|
|
t.Fatalf("member should be pending/member: %v", member)
|
|
}
|
|
|
|
// --- team: list members ---
|
|
resp, team := do(t, http.MethodGet, srv.URL+"/v1/team", tok, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list team: want 200, got %d", resp.StatusCode)
|
|
}
|
|
if total, _ := team["total"].(float64); total != 1 {
|
|
t.Fatalf("team total: want 1, got %v", team["total"])
|
|
}
|
|
|
|
// --- networks: create a Network CR ---
|
|
resp, net := do(t, http.MethodPost, srv.URL+"/v1/networks", tok, map[string]any{
|
|
"name": "acme", "tier": "pro", "brand": map[string]any{"domain": "acme.network"},
|
|
"deployValidators": true, "validatorCount": 5,
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create network: want 201, got %d (%v)", resp.StatusCode, net)
|
|
}
|
|
if net["id"] != "acme" || net["status"] != "provisioning" {
|
|
t.Fatalf("network response wrong: %v", net)
|
|
}
|
|
|
|
// --- networks: get it back from the (fake) cluster ---
|
|
resp, gotNet := do(t, http.MethodGet, srv.URL+"/v1/networks/acme", tok, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("get network: want 200, got %d", resp.StatusCode)
|
|
}
|
|
spec, _ := gotNet["spec"].(map[string]any)
|
|
if spec == nil || spec["tier"] != "pro" {
|
|
t.Fatalf("network CR spec wrong: %v", gotNet)
|
|
}
|
|
|
|
// --- nodes: create a NodeFleet CR via simple preset ---
|
|
resp, fleet := do(t, http.MethodPost, srv.URL+"/v1/nodes", tok, map[string]any{
|
|
"name": "eth-fleet", "chain": "ethereum", "mode": "simple", "preset": "full",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create fleet: want 201, got %d (%v)", resp.StatusCode, fleet)
|
|
}
|
|
if fleet["executionClient"] != "geth" {
|
|
t.Fatalf("preset 'full' must select geth: %v", fleet)
|
|
}
|
|
|
|
// --- keys: provisioning must reject plaintext key material ---
|
|
resp, _ = do(t, http.MethodPost, srv.URL+"/v1/keys", tok, map[string]any{
|
|
"name": "signer", "chain": "lux", "privateKey": "0xdeadbeef",
|
|
})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("keys must reject plaintext private key, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// --- keys: KMSSecret CR provisioning by path (no material) ---
|
|
resp, key := do(t, http.MethodPost, srv.URL+"/v1/keys", tok, map[string]any{
|
|
"name": "signer", "chain": "lux", "kmsPath": "providers/lux/signer", "keyType": "secp256k1",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create KMS key: want 201, got %d (%v)", resp.StatusCode, key)
|
|
}
|
|
if _, leaked := key["privateKey"]; leaked {
|
|
t.Fatal("key response must NEVER contain private key material")
|
|
}
|
|
|
|
// --- keys: status reports configured=true now that a KMSSecret exists ---
|
|
resp, status := do(t, http.MethodGet, srv.URL+"/v1/keys/status", tok, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("key status: want 200, got %d", resp.StatusCode)
|
|
}
|
|
if status["configured"] != true {
|
|
t.Fatalf("key status should be configured after KMSSecret apply: %v", status)
|
|
}
|
|
|
|
// --- chains: public, unauthenticated ---
|
|
resp, chains := do(t, http.MethodGet, srv.URL+"/v1/chains", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("chains: want 200, got %d", resp.StatusCode)
|
|
}
|
|
if _, ok := chains["chains"].(map[string]any)["lux"]; !ok {
|
|
t.Fatalf("chains must include lux: %v", chains)
|
|
}
|
|
}
|
|
|
|
func TestRegisterRejectsInsecureSaltInProduction(t *testing.T) {
|
|
app, err := tests.NewTestApp()
|
|
if err != nil {
|
|
t.Fatalf("NewTestApp: %v", err)
|
|
}
|
|
defer app.Cleanup()
|
|
// Non-local IAM + default salt must fail fast.
|
|
err = Register(app, Config{Enabled: true, IAMEndpoint: "https://hanzo.id"})
|
|
if err == nil {
|
|
t.Fatal("Register must refuse the insecure default salt against production IAM")
|
|
}
|
|
}
|