mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
THE LIVE ESCALATION, in the path that ships. `X-User-IsAdmin` is the fleet's PLATFORM-authority header — cloud gates its cross-tenant surfaces on it and the money gates read the bit-field beside it. This edge minted it from claims.IsAdmin, which is IAM's ORG-ROLE bit: an org owner carries it inside their own org. So every org owner reached the fleet as a platform admin. I reported this fixed earlier. It was not. I patched iamauth.InjectIdentity, which has ZERO non-test callers — the shipped KrakenD middleware and the ZAP relay each carried their own inline mint, and both still read the org bit. Two more statements of the same rule, and the one I fixed was the dead one. Fixed where they run: the HTTP edge (auth_middleware.go) now applies edge.Mint, and the relay (gate.go) sets f.IsAdmin — which forward re-emits as X-User-IsAdmin — from PlatformSudo. X-User-IsOrgAdmin is minted for the first time, so org-scoped self-service has its own header to read instead of borrowing platform authority. ONE DECISION. iamauth is DELETED (834 lines) along with the shim over it. What a claim means, which org a request acts in, who pays, which headers get minted — all of it is hanzoai/authz and hanzoai/authz/edge now, and net/http's own header type satisfies edge.Headers, so applying it here is a loop rather than a second implementation. What is left is the part genuinely this deployment's: package token holds the issuer and the AUDIENCE ALLOWLIST (authz.Verify deliberately does not check `aud`, because IAM sets it to the requesting CLIENT), and identity.go holds the money bit vocabulary and the vendor-prefix strip. ONE JWT LIBRARY on the identity path. Verification is golang-jwt/v5, the library IAM SIGNS with, through authz.Verify + the edge's JWKS reader. go-jose is gone from it, and the tests now mint with the same library they verify with — a test signing with a different one is testing a signer the estate does not run. It also makes `kid` mandatory, matching IAM (signClaims always stamps it). THREE HEADERS DROPPED, because IAM never emitted the claims behind them: `roles`, `permissions` and `phone` are absent from the signed claim set and from claims_supported, so the three-shape parsers for the first two read a claim that never arrived, and X-Roles / X-Phone-Number were always empty. All three names STAY in the strip set, so a forged copy still never survives. Removing the permissions parser also closes a latent grant: a token carrying that claim used to be handed the bits it named. The vendor-prefix backstop (X-IAM-*, X-HANZO-*) stays here rather than in the estate's edge, because sweeping prefixes must ENUMERATE header names — which is transport-specific, while edge.Headers is deliberately Get/Set/Del. It is folded into one stripClaimed call with the exact-name strip, because two calls would be an ordering trap that fails silently. Falsified both ways: restoring `if cl.IsAdmin` in edge.Mint turns TestOrgOwnerIsNotAPlatformAdmin and TestAdminOrgMachineGetsNeitherAdminHeader red; granting the admin bit from the org role turns TestOrgAdminNeverMintsPlatformAuthority and TestPermissions_OrgAdminGetsNoAdminBit red. Green on both build tags, including the `legacy` tag production ships. tests/ stays red on the KrakenD fixture suite — verified identical on a stashed tree, so it is not from this change. Co-authored-by: Hanzo Dev <[email protected]>
463 lines
15 KiB
Go
463 lines
15 KiB
Go
// Copyright © 2026 Hanzo AI. Apache-2.0 License.
|
|
|
|
package gateway
|
|
|
|
// Tests for the gateway relay gate (gate.go) and RegisterRelay (build_app.go).
|
|
//
|
|
// Two layers:
|
|
// - Direct gate tests: the forward.Gate is just func(ctx, *Forward) ->
|
|
// (*Response, error); we feed crafted Forwards (valid/invalid/missing
|
|
// JWT, API key, body-present) and assert identity injection or the deny
|
|
// Response. These reuse the JWKS harness from
|
|
// auth_middleware_security_test.go (newTestJWKS/signToken/validClaims).
|
|
// - End-to-end registration test: a real ingress→gateway→backend ZAP
|
|
// chain where the gateway is wired by RegisterRelay. Proves forward.Relay
|
|
// is registered and a valid JWT's identity reaches the backend.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/authz"
|
|
luxlog "github.com/luxfi/log"
|
|
zaplib "github.com/luxfi/zap"
|
|
"github.com/luxfi/zap/forward"
|
|
)
|
|
|
|
// gateConfig builds an AuthConfig pointed at a live test JWKS server.
|
|
func gateConfig(t *testing.T, tj *testJWKS, requireAuth bool) (AuthConfig, func()) {
|
|
t.Helper()
|
|
srv := tj.serveJWKS(t)
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: srv.URL,
|
|
Issuer: "https://hanzo.id",
|
|
Audiences: []string{"https://api.hanzo.ai"},
|
|
RequireAuth: requireAuth,
|
|
}
|
|
return cfg, srv.Close
|
|
}
|
|
|
|
// forwardWithHeaders builds a Forward whose Headers JSON carries the given
|
|
// header map and whose Body is non-empty (so tests can assert the gate
|
|
// never reads it).
|
|
func forwardWithHeaders(t *testing.T, path string, hdrs map[string][]string) forward.Forward {
|
|
t.Helper()
|
|
hj, err := json.Marshal(hdrs)
|
|
if err != nil {
|
|
t.Fatalf("marshal headers: %v", err)
|
|
}
|
|
return forward.Forward{
|
|
Method: http.MethodPost,
|
|
Path: path,
|
|
Headers: hj,
|
|
Body: []byte(`{"prompt":"do not read me"}`),
|
|
}
|
|
}
|
|
|
|
// --- valid JWT -> identity injected, allow -----------------------------
|
|
|
|
func TestGateValidJWTInjectsIdentity(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true)
|
|
defer closeJWKS()
|
|
|
|
// An ORG-level admin (validClaims owner="hanzo", IsAdmin=true — an org owner).
|
|
// The money/admin bit MUST NOT be granted; only Live (real-money mode).
|
|
claims := validClaims(cfg.Issuer, cfg.Audiences[0])
|
|
claims.IsAdmin = true
|
|
claims.Orgs = []authz.Membership{{Org: "hanzo", Role: "admin"}}
|
|
token := tj.signToken(t, claims)
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer " + token},
|
|
})
|
|
bodyBefore := append([]byte(nil), f.Body...)
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil {
|
|
t.Fatalf("gate returned error: %v", err)
|
|
}
|
|
if deny != nil {
|
|
t.Fatalf("valid JWT denied: status=%d body=%s", deny.Status, deny.Body)
|
|
}
|
|
if f.TenantID != "hanzo" {
|
|
t.Errorf("TenantID: got %q want hanzo", f.TenantID)
|
|
}
|
|
if f.UserID != "alice" {
|
|
t.Errorf("UserID: got %q want alice", f.UserID)
|
|
}
|
|
// forward re-emits f.IsAdmin as X-User-IsAdmin, the fleet's PLATFORM header. An
|
|
// ORG admin must not carry it: setting it from claims.IsAdmin — IAM's org-role
|
|
// bit — made every org owner a platform admin on the relay path too, and this
|
|
// test asserted that, which is why it survived.
|
|
if f.IsAdmin {
|
|
t.Error("SECURITY: an org admin was marked a PLATFORM admin on the relay")
|
|
}
|
|
// Org-level admin -> Live ONLY (the Admin money bit is global-admin-only).
|
|
if wantBits := permissionBits["live"]; f.Permissions != wantBits {
|
|
t.Errorf("org-admin Permissions: got %d want %d (Live only, no Admin bit)", f.Permissions, wantBits)
|
|
}
|
|
if f.Permissions&permissionBits["admin"] != 0 {
|
|
t.Errorf("org-admin was granted the Admin money bit (perms=%d) — free-money hole", f.Permissions)
|
|
}
|
|
// The gate must not have touched the body.
|
|
if string(f.Body) != string(bodyBefore) {
|
|
t.Errorf("gate mutated f.Body: got %q want %q", f.Body, bodyBefore)
|
|
}
|
|
}
|
|
|
|
// TestGatePlatformSudoGetsAdminBit proves the OTHER side of the money gate: a
|
|
// PLATFORM-sudo principal — owner=="admin" — DOES get the Admin|Live bits, so
|
|
// legitimate admin/service tooling keeps working. Gated on the org, no boolean.
|
|
func TestGatePlatformSudoGetsAdminBit(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true)
|
|
defer closeJWKS()
|
|
|
|
claims := validClaims(cfg.Issuer, cfg.Audiences[0])
|
|
claims.Owner = "admin" // the reserved platform org
|
|
claims.IsAdmin = true
|
|
// The home-org membership every user token carries. Without it this is a MACHINE,
|
|
// which holds no platform authority however its owner reads.
|
|
claims.Orgs = []authz.Membership{{Org: "admin", Role: "admin"}}
|
|
token := tj.signToken(t, claims)
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer " + token},
|
|
})
|
|
if deny, err := gate(context.Background(), &f); err != nil || deny != nil {
|
|
t.Fatalf("global-admin JWT unexpectedly denied: deny=%v err=%v", deny, err)
|
|
}
|
|
wantBits := permissionBits["admin"] | permissionBits["live"]
|
|
if f.Permissions != wantBits {
|
|
t.Errorf("platform-sudo Permissions: got %d want %d (Admin|Live)", f.Permissions, wantBits)
|
|
}
|
|
if !f.IsAdmin {
|
|
t.Error("the platform operator lost platform authority on the relay")
|
|
}
|
|
}
|
|
|
|
// --- missing token + RequireAuth -> 401 deny ---------------------------
|
|
|
|
func TestGateMissingTokenRequireAuthDenies(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true) // RequireAuth = true
|
|
defer closeJWKS()
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
})
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil {
|
|
t.Fatalf("gate returned error: %v", err)
|
|
}
|
|
if deny == nil {
|
|
t.Fatal("missing token with RequireAuth=true was allowed, want 401")
|
|
}
|
|
if deny.Status != http.StatusUnauthorized {
|
|
t.Errorf("status: got %d want 401", deny.Status)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(deny.Body, &body); err != nil {
|
|
t.Fatalf("deny body not JSON: %q (%v)", deny.Body, err)
|
|
}
|
|
if body["error"] != "unauthorized" {
|
|
t.Errorf("deny error: got %q want unauthorized", body["error"])
|
|
}
|
|
if f.TenantID != "" || f.UserID != "" {
|
|
t.Errorf("identity injected on deny: tenant=%q user=%q", f.TenantID, f.UserID)
|
|
}
|
|
}
|
|
|
|
// --- missing token, RequireAuth=false -> allow, no identity ------------
|
|
|
|
func TestGateMissingTokenOptionalAllows(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, false) // RequireAuth = false
|
|
defer closeJWKS()
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
})
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil {
|
|
t.Fatalf("gate returned error: %v", err)
|
|
}
|
|
if deny != nil {
|
|
t.Fatalf("tokenless optional request denied: status=%d", deny.Status)
|
|
}
|
|
if f.TenantID != "" || f.UserID != "" {
|
|
t.Errorf("identity injected without a token: tenant=%q user=%q", f.TenantID, f.UserID)
|
|
}
|
|
}
|
|
|
|
// --- invalid JWT (wrong issuer) -> 401 deny ----------------------------
|
|
|
|
func TestGateInvalidJWTDenies(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, false)
|
|
defer closeJWKS()
|
|
|
|
// Sign with a deliberately wrong issuer — validation must reject it.
|
|
claims := validClaims("https://evil.example", cfg.Audiences[0])
|
|
token := tj.signToken(t, claims)
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer " + token},
|
|
})
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil {
|
|
t.Fatalf("gate returned error: %v", err)
|
|
}
|
|
if deny == nil {
|
|
t.Fatal("invalid JWT was allowed, want 401")
|
|
}
|
|
if deny.Status != http.StatusUnauthorized {
|
|
t.Errorf("status: got %d want 401", deny.Status)
|
|
}
|
|
}
|
|
|
|
// --- opaque API key -> allow, no JWT identity --------------------------
|
|
|
|
func TestGateAPIKeyPassesThrough(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true) // even with RequireAuth, API keys pass
|
|
defer closeJWKS()
|
|
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer sk-live-abc123"},
|
|
})
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil {
|
|
t.Fatalf("gate returned error: %v", err)
|
|
}
|
|
if deny != nil {
|
|
t.Fatalf("API key denied: status=%d", deny.Status)
|
|
}
|
|
if f.TenantID != "" || f.UserID != "" {
|
|
t.Errorf("API key produced edge identity: tenant=%q user=%q", f.TenantID, f.UserID)
|
|
}
|
|
}
|
|
|
|
// --- disabled gate -> allow everything, no identity --------------------
|
|
|
|
func TestGateDisabledAllowsAll(t *testing.T) {
|
|
gate := newGate(AuthConfig{Enabled: false})
|
|
f := forwardWithHeaders(t, "/v1/anything", map[string][]string{
|
|
"Authorization": {"Bearer garbage"},
|
|
})
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil || deny != nil {
|
|
t.Fatalf("disabled gate did not allow: deny=%v err=%v", deny, err)
|
|
}
|
|
if f.TenantID != "" {
|
|
t.Errorf("disabled gate injected identity: %q", f.TenantID)
|
|
}
|
|
}
|
|
|
|
// --- trust boundary: forged envelope identity is stripped --------------
|
|
|
|
// TestGateStripsForgedIdentity proves a client cannot smuggle identity past
|
|
// the relay. forward.Forwarder lifts X-Org-Id/X-User-Id headers into the
|
|
// Forward's TenantID/UserID fields; the gate must clear them on every allow
|
|
// path that is not a validated JWT. We assert it on the API-key path (a
|
|
// non-JWT allow) and on the no-token optional path.
|
|
func TestGateStripsForgedIdentity(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
|
|
// API-key path: RequireAuth on, key passes, forged identity must die.
|
|
t.Run("api_key_path", func(t *testing.T) {
|
|
cfg, closeJWKS := gateConfig(t, tj, true)
|
|
defer closeJWKS()
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer sk-live-key"},
|
|
})
|
|
// Attacker pre-seeds the envelope identity (as forward.Forwarder
|
|
// would from forged X-Org-Id / X-User-Id client headers).
|
|
f.TenantID = "victim-org"
|
|
f.UserID = "victim-user"
|
|
f.IsAdmin = true
|
|
f.Permissions = 0x7fffffff
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil || deny != nil {
|
|
t.Fatalf("API key not allowed: deny=%v err=%v", deny, err)
|
|
}
|
|
if f.TenantID != "" || f.UserID != "" || f.IsAdmin || f.Permissions != 0 {
|
|
t.Errorf("forged identity survived API-key path: tenant=%q user=%q admin=%v perms=%d",
|
|
f.TenantID, f.UserID, f.IsAdmin, f.Permissions)
|
|
}
|
|
})
|
|
|
|
// No-token optional path: forged identity must die there too.
|
|
t.Run("no_token_path", func(t *testing.T) {
|
|
cfg, closeJWKS := gateConfig(t, tj, false)
|
|
defer closeJWKS()
|
|
gate := newGate(cfg)
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
})
|
|
f.TenantID = "victim-org"
|
|
f.IsAdmin = true
|
|
|
|
deny, err := gate(context.Background(), &f)
|
|
if err != nil || deny != nil {
|
|
t.Fatalf("tokenless optional not allowed: deny=%v err=%v", deny, err)
|
|
}
|
|
if f.TenantID != "" || f.IsAdmin {
|
|
t.Errorf("forged identity survived no-token path: tenant=%q admin=%v", f.TenantID, f.IsAdmin)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- pickPeer routing ---------------------------------------------------
|
|
|
|
func TestPickPeerRouting(t *testing.T) {
|
|
pick := pickPeer("base-node", "cloud-node")
|
|
cases := map[string]string{
|
|
"/v1/base/records": "base-node",
|
|
"/v1/base/": "base-node",
|
|
"/v1/chat/completions": "cloud-node",
|
|
"/v1/iam/users/123": "cloud-node",
|
|
"/": "cloud-node",
|
|
"/v1/basement/not-base": "cloud-node", // must not prefix-match /v1/base/
|
|
}
|
|
for path, want := range cases {
|
|
if got := pick(path); got != want {
|
|
t.Errorf("pick(%q): got %q want %q", path, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- end-to-end: RegisterRelay wires a working relay -------------------
|
|
|
|
// TestRegisterRelayEndToEnd stands up ingress -> gateway(RegisterRelay) ->
|
|
// backend over ZAP. It proves forward.Relay is registered on the gateway
|
|
// node and that a valid JWT's identity (X-Org-Id) reaches the backend.
|
|
func TestRegisterRelayEndToEnd(t *testing.T) {
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true)
|
|
defer closeJWKS()
|
|
|
|
gwPort := freePortInt(t)
|
|
bePort := freePortInt(t)
|
|
ingress := zaplib.NewNode(zaplib.NodeConfig{NodeID: "ingress", Port: freePortInt(t), NoDiscovery: true})
|
|
gw := zaplib.NewNode(zaplib.NodeConfig{NodeID: "gateway", Port: gwPort, NoDiscovery: true})
|
|
backend := zaplib.NewNode(zaplib.NodeConfig{NodeID: "backend", Port: bePort, NoDiscovery: true})
|
|
for _, n := range []*zaplib.Node{ingress, gw, backend} {
|
|
if err := n.Start(); err != nil {
|
|
t.Fatalf("%s start: %v", n.NodeID(), err)
|
|
}
|
|
defer n.Stop()
|
|
}
|
|
|
|
// Backend echoes the edge identity it received.
|
|
backendHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
out := map[string]string{
|
|
"org": r.Header.Get(forward.HeaderOrgID),
|
|
"user": r.Header.Get(forward.HeaderUserID),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(out)
|
|
})
|
|
forward.Serve(backend, backendHandler)
|
|
|
|
// Gateway dials the backend and wires the relay via the production path.
|
|
bePeerID, err := gw.ConnectDirectID("127.0.0.1:" + strconv.Itoa(bePort))
|
|
if err != nil {
|
|
t.Fatalf("gateway->backend: %v", err)
|
|
}
|
|
if err := RegisterRelay(RelayDeps{
|
|
Logger: luxlog.NewNoOpLogger(),
|
|
Node: gw,
|
|
BasePeerID: bePeerID, // single backend in this test: both route there
|
|
CloudPeerID: bePeerID,
|
|
Auth: cfg,
|
|
}); err != nil {
|
|
t.Fatalf("RegisterRelay: %v", err)
|
|
}
|
|
|
|
// Ingress dials the gateway.
|
|
if err := ingress.ConnectDirect("127.0.0.1:" + strconv.Itoa(gwPort)); err != nil {
|
|
t.Fatalf("ingress->gateway: %v", err)
|
|
}
|
|
waitPeers(t, ingress)
|
|
|
|
// Mint a valid JWT and drive a request through the relay.
|
|
token := tj.signToken(t, validClaims(cfg.Issuer, cfg.Audiences[0]))
|
|
req, _ := http.NewRequest(http.MethodGet, "http://gateway/v1/chat/completions", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
fwd := &forward.Forwarder{Node: ingress, Peer: "gateway"}
|
|
resp, err := fwd.RoundTrip(req.WithContext(ctx))
|
|
if err != nil {
|
|
t.Fatalf("RoundTrip through relay: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status: got %d want 200", resp.StatusCode)
|
|
}
|
|
var echoed map[string]string
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if err := json.Unmarshal(body, &echoed); err != nil {
|
|
t.Fatalf("decode backend echo %q: %v", body, err)
|
|
}
|
|
if echoed["org"] != "hanzo" {
|
|
t.Errorf("backend saw X-Org-Id=%q want hanzo (identity did not survive relay)", echoed["org"])
|
|
}
|
|
if echoed["user"] != "alice" {
|
|
t.Errorf("backend saw X-User-Id=%q want alice", echoed["user"])
|
|
}
|
|
}
|
|
|
|
// --- helpers ------------------------------------------------------------
|
|
|
|
// freePortInt reserves an ephemeral TCP port and returns it as an int (the
|
|
// existing freePort in zaphttp_listener_test.go returns a ":NNNN" string;
|
|
// the ZAP NodeConfig.Port wants an int). The listener is closed immediately;
|
|
// the small reuse window is acceptable for tests on local loopback.
|
|
func freePortInt(t *testing.T) int {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
_ = ln.Close()
|
|
return port
|
|
}
|
|
|
|
// waitPeers blocks until n has at least one connected peer or the deadline
|
|
// elapses.
|
|
func waitPeers(t *testing.T, n *zaplib.Node) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if len(n.Peers()) > 0 {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s saw no peers within deadline", n.NodeID())
|
|
}
|