Files
base/network/integration_test.go
zeekay e7df8c856e base: complete pocketbase→base rename; Base = pure IAM client
Rename / namespace:
- Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep
  across all Go sources; restore the renamed root launcher (Base, base.go).
- Drop the upstream-only surfaces ours doesn't ship (admin ui/, the
  password/OTP/oauth2/impersonate auth files, extensions) re-added by the
  upstream merge.
- Decomplect the on-disk layout to one base_/ namespace: data/, migrations/,
  hooks/, types.d.ts are siblings under base_/ (default data dir
  base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's
  identical JS/Go branches to one path.
- Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and
  gitignore the runtime namespace dirs.

Deps / build:
- kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module
  (the root kms module zip exceeds Go's download limit); cloud/pkg/base
  Mount removed (lives in the fused daemon).
- replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto);
  network/metrics.go import fix; auth gitlab/github tidy.

Tests / behavior:
- Wire registerNotifyWatcherHooks; debounce notify files past the kqueue
  rescan window so cross-instance settings/collections reload fires.
- migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate
  Go+JS automigration goldens from canonical output.
- Convert the two stale archive red-probes to assert the landed defenses
  (backlog cap bounds memory; forged-segment signature is rejected).

IAM-native (Base = pure IAM client):
- Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the
  iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers
  rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused
  daemon) is required; Base validates IAM JWTs via JWKS only — superuser is
  the IAM admin claim, resolved to an ephemeral unpersisted record.
- Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was
  mis-colocated in the embedded onboarding file and is independent of IAM.
- Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a
  JWKS stub (no hardcoded record tokens, no persisted rows).
2026-06-10 21:29:14 -07:00

237 lines
5.8 KiB
Go

package network
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
metric "github.com/luxfi/metric"
)
// memoryHub is a one-process transport mesh. Every node that joins the hub
// receives every frame that any other node publishes. This is the
// in-process stand-in for a real QUIC/gRPC mesh; consensus behaviour is
// identical.
type memoryHub struct {
mu sync.RWMutex
nodes map[NodeID]*memoryTransport
}
func newMemoryHub() *memoryHub {
return &memoryHub{nodes: make(map[NodeID]*memoryTransport)}
}
func (h *memoryHub) connect(id NodeID) *memoryTransport {
t := &memoryTransport{hub: h, self: id}
h.mu.Lock()
h.nodes[id] = t
h.mu.Unlock()
return t
}
// memoryTransport satisfies Transport by fanning out Publish via the hub.
type memoryTransport struct {
hub *memoryHub
self NodeID
mu sync.Mutex
recv func(Envelope)
closed bool
}
func (t *memoryTransport) Start(_ context.Context, recv func(Envelope)) error {
t.mu.Lock()
t.recv = recv
t.closed = false
t.mu.Unlock()
return nil
}
func (t *memoryTransport) Publish(env Envelope) error {
t.hub.mu.RLock()
peers := make([]*memoryTransport, 0, len(t.hub.nodes))
for id, peer := range t.hub.nodes {
if id == t.self {
continue
}
peers = append(peers, peer)
}
t.hub.mu.RUnlock()
for _, p := range peers {
p.mu.Lock()
cb := p.recv
closed := p.closed
p.mu.Unlock()
if cb == nil || closed {
continue
}
// Detach frame bytes — decodeFrame in receiver should allocate its
// own buffer, but defensive copy avoids any shared-slice bugs.
cb(env)
}
return nil
}
func (t *memoryTransport) Stop(_ context.Context) error {
t.mu.Lock()
t.closed = true
t.recv = nil
t.mu.Unlock()
return nil
}
// countingSource pretends to be the SQLite WAL tap. It returns a payload
// that includes a monotonic counter so we can verify ordering at apply.
type countingSource struct {
n atomic.Uint64
}
func (c *countingSource) CapturePayload(_ string) ([]byte, error) {
v := c.n.Add(1)
out := make([]byte, 8)
for i := 0; i < 8; i++ {
out[7-i] = byte(v >> (i * 8))
}
return out, nil
}
// TestThreeNodeCrossShardFinalize spins up three nodes in-process sharing
// a memory transport. Node A writes to shardA, node B writes to shardB.
// After Quasar finalises, every node's shard must have applied both
// frames.
func TestThreeNodeCrossShardFinalize(t *testing.T) {
hub := newMemoryHub()
peers := []string{"a", "b", "c"}
nodes := make([]*node, 0, 3)
sources := make([]*countingSource, 0, 3)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, id := range peers {
cfg := Config{
Enabled: true,
ShardKey: "user_id",
Replication: 3,
Peers: filter(peers, id),
NodeID: id,
Role: RoleValidator,
Archive: "off",
ListenHTTP: ":0",
ListenP2P: ":0",
}
nn, err := newNodeWithTransport(cfg, hub.connect(NodeID(id)))
if err != nil {
t.Fatalf("node %s: %v", id, err)
}
src := &countingSource{}
nn.walSrc = src
if err := nn.Start(ctx); err != nil {
t.Fatalf("node %s start: %v", id, err)
}
t.Cleanup(func() { _ = nn.Stop(context.Background()) })
nodes = append(nodes, nn)
sources = append(sources, src)
}
// Writer A submits on shard-alpha; writer B submits on shard-beta.
if err := submitLocalOn(nodes[0], "shard-alpha"); err != nil {
t.Fatalf("node a submit: %v", err)
}
if err := submitLocalOn(nodes[1], "shard-beta"); err != nil {
t.Fatalf("node b submit: %v", err)
}
_ = sources // only exists to ensure distinct payloads per node
// Poll until every node has applied one frame on each of the two
// shards, or the deadline trips.
deadline := time.Now().Add(5 * time.Second)
for {
ok := true
for _, n := range nodes {
sa, err := n.shard("shard-alpha")
if err != nil || sa.LocalSeq() < 1 {
ok = false
break
}
sb, err := n.shard("shard-beta")
if err != nil || sb.LocalSeq() < 1 {
ok = false
break
}
}
if ok {
break
}
if time.Now().After(deadline) {
for i, n := range nodes {
sa, _ := n.shard("shard-alpha")
sb, _ := n.shard("shard-beta")
t.Logf("node %d (%s): alpha.localSeq=%d, beta.localSeq=%d",
i, n.id, sa.LocalSeq(), sb.LocalSeq())
}
t.Fatal("timeout: not every node finalised both shards")
}
time.Sleep(25 * time.Millisecond)
}
// Sanity: every node finalised both frames.
for i, n := range nodes {
m := n.Metrics()
if got := counterVal(t, m.FramesFinalized); got < 2 {
t.Errorf("node %d: FramesFinalized = %v, want >= 2", i, got)
}
}
// The two writer nodes (A, B) produced one local frame each; the
// third saw both via transport.
if got := counterVal(t, nodes[0].Metrics().FramesSubmitted); got < 1 {
t.Errorf("node a: FramesSubmitted = %v, want >= 1", got)
}
if got := counterVal(t, nodes[1].Metrics().FramesSubmitted); got < 1 {
t.Errorf("node b: FramesSubmitted = %v, want >= 1", got)
}
if got := counterVal(t, nodes[2].Metrics().FramesIngested); got < 2 {
t.Errorf("node c: FramesIngested = %v, want >= 2", got)
}
}
// submitLocalOn fires a synthetic commit on n for shard. This exercises
// the same path InstallWALHook's callback uses so the integration test
// covers the full produce → Quasar → apply loop without a live SQLite.
func submitLocalOn(n *node, shard string) error {
s, err := n.shard(shard)
if err != nil {
return err
}
w := &shardWriter{shardID: shard, src: n.walSrc}
f, err := w.buildFrame()
if err != nil {
return err
}
if err := s.submitLocal(f); err != nil {
return err
}
return n.transport.Publish(Envelope{ShardID: shard, Frame: f})
}
func filter(all []string, drop string) []string {
out := make([]string, 0, len(all)-1)
for _, s := range all {
if s == drop {
continue
}
out = append(out, s)
}
return out
}
func counterVal(t *testing.T, c metric.Counter) float64 {
t.Helper()
return c.Get()
}