From 6535400496c27f0ae1f303eace84fe6241b00d60 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Mon, 13 Jul 2026 17:27:15 +0300 Subject: [PATCH] fix(nightly): repair real test-suite regressions + daemon WSS auth skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly suite has been red since it was created (2026-05-28) and never green. Categorising every failure showed most were either real regressions or CPU-starvation artifacts of -parallel 4, not genuine env limits. Real fixes in this commit (all fail deterministically in isolation): - daemon WSS compat auth: pkg/daemon/transport/wss signed the pre-v0.2.6 challenge shape "compat_auth::", but beacon v0.2.6 binds the server timestamp and verifies "compat_auth:::". Real production skew — compat daemons could not authenticate to a v0.2.6 beacon. Parse the challenge `ts` and sign the new shape; update the fake-beacon unit test to match. Fixes TestCompatRegistryTLSPinned, TestCompatRegistryTrustSystemRejectsBadCert, TestCompatDaemonDialUDPPeerThroughWSS. - invite signer: common@v0.5.7's InviteToNetwork/RespondInvite/PollInvites now always sign, so every test issuing an invite must set a signer. Set the inviter's signer before each InviteToNetwork call across the invite, RBAC, audit, enterprise-gate, network and pilotctl suites. Several rejection-path tests were passing for the wrong reason (rejected on the missing signer, not the intended authz check) — they now sign so the registry performs the real authorization. - TestAdminKicksAdmin: registry policy PILOT-266 (2026-05-29) forbids an admin kicking another admin; assert the guard and that the owner can still kick an admin. - TestInviteRequiresAdmin: premise was stale (owner invites via signature without an admin token). Reworked to assert owner-by-signature succeeds and an unauthorized outsider is rejected. - dashboard /api/stats is now admin-gated (rich payload behind requireAdminToken; anonymous uses /api/public-stats). Set an admin token and authenticate. Fixes TestDashboard{HTTPEndpoints,APIShape,BannerEndpoint,NoIPLeak}. Co-Authored-By: Claude Fable 5 --- pkg/daemon/transport/wss/wss.go | 16 ++-- pkg/daemon/transport/wss/zz_wss_test.go | 8 +- tests/zz_audit_test.go | 4 +- tests/zz_dashboard_test.go | 28 +++++-- tests/zz_enterprise_gate_test.go | 37 +++++++-- tests/zz_invite_acceptance_test.go | 103 ++++++++++++++++++------ tests/zz_network_test.go | 7 +- tests/zz_pilotctl_network_test.go | 6 +- tests/zz_rbac_test.go | 8 +- 9 files changed, 160 insertions(+), 57 deletions(-) diff --git a/pkg/daemon/transport/wss/wss.go b/pkg/daemon/transport/wss/wss.go index 7bdcebf7..b45e9f9b 100644 --- a/pkg/daemon/transport/wss/wss.go +++ b/pkg/daemon/transport/wss/wss.go @@ -71,15 +71,16 @@ const DefaultRecvBuffer = 256 // channel; everything after auth_ok is binary frames carrying raw // Pilot packets. type authChallengeMsg struct { - Type string `json:"type"` // "auth_challenge" - Nonce string `json:"nonce"` // 32 random bytes, hex-encoded + Type string `json:"type"` // "auth_challenge" + Nonce string `json:"nonce"` // 32 random bytes, hex-encoded + Timestamp int64 `json:"ts"` // Unix epoch seconds, server-issued (replay window) } type authReplyMsg struct { Type string `json:"type"` // "auth_reply" NodeID uint32 `json:"node_id"` PublicKey string `json:"public_key"` // base64 Ed25519 pubkey - Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+nonce + Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+ts+":"+nonce } type authOKMsg struct { @@ -274,10 +275,11 @@ func (t *Transport) runAuth(ctx context.Context, conn *websocket.Conn) error { return fmt.Errorf("malformed challenge: type=%q nonce-len=%d", ch.Type, len(ch.Nonce)) } - // Sign "compat_auth::" — same shape the beacon - // verifies. Binding nodeID + nonce into the signed bytes prevents - // replay across different daemon identities. - msg := fmt.Sprintf("compat_auth:%d:%s", t.cfg.NodeID, ch.Nonce) + // Sign "compat_auth:::" — same shape the beacon + // verifies (beacon >= v0.2.6). Binding nodeID + server timestamp + + // nonce into the signed bytes prevents replay across identities and + // bounds the auth to the server's freshness window. + msg := fmt.Sprintf("compat_auth:%d:%d:%s", t.cfg.NodeID, ch.Timestamp, ch.Nonce) sig := t.cfg.Identity.Sign([]byte(msg)) reply := authReplyMsg{ diff --git a/pkg/daemon/transport/wss/zz_wss_test.go b/pkg/daemon/transport/wss/zz_wss_test.go index 4dda4e6d..d0b4573f 100644 --- a/pkg/daemon/transport/wss/zz_wss_test.go +++ b/pkg/daemon/transport/wss/zz_wss_test.go @@ -106,9 +106,11 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - // Send auth challenge with a fixed nonce so we can assert on it. + // Send auth challenge with a fixed nonce + timestamp so we can assert + // on the signed payload (beacon >= v0.2.6 binds ts into the signature). nonce := "deadbeef12345678deadbeef12345678" - ch := map[string]string{"type": "auth_challenge", "nonce": nonce} + const ts int64 = 1700000000 + ch := map[string]interface{}{"type": "auth_challenge", "nonce": nonce, "ts": ts} chBytes, _ := json.Marshal(ch) if err := conn.Write(ctx, websocket.MessageText, chBytes); err != nil { fb.t.Logf("fake beacon: write challenge: %v", err) @@ -154,7 +156,7 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) { conn.Close(websocket.StatusPolicyViolation, "bad sig b64") return } - signed := fmt.Sprintf("compat_auth:%d:%s", reply.NodeID, nonce) + signed := fmt.Sprintf("compat_auth:%d:%d:%s", reply.NodeID, ts, nonce) if !ed25519.Verify(ed25519.PublicKey(pubBytes), []byte(signed), sigBytes) { conn.Close(websocket.StatusPolicyViolation, "sig verify failed") return diff --git a/tests/zz_audit_test.go b/tests/zz_audit_test.go index ea07309d..72c0bb63 100644 --- a/tests/zz_audit_test.go +++ b/tests/zz_audit_test.go @@ -361,7 +361,7 @@ func TestAuditInviteActions(t *testing.T) { } defer rc.Close() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "audit-invite-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -370,6 +370,8 @@ func TestAuditInviteActions(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) diff --git a/tests/zz_dashboard_test.go b/tests/zz_dashboard_test.go index 310f57fa..39cd16e2 100644 --- a/tests/zz_dashboard_test.go +++ b/tests/zz_dashboard_test.go @@ -81,6 +81,10 @@ func TestDashboardHTTPEndpoints(t *testing.T) { r := registry.New("127.0.0.1:9001") defer r.Close() + // /api/stats is admin-gated (rich payload moved behind requireAdminToken; + // anonymous callers use /api/public-stats). Authenticate as an operator. + const adminToken = "dash-http-admin-token" + r.SetAdminToken(adminToken) // Find a free port for the dashboard ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -96,8 +100,9 @@ func TestDashboardHTTPEndpoints(t *testing.T) { var client http.Client client.Timeout = 2 * time.Second var resp *http.Response + statsURL := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken) for i := 0; i < 20; i++ { - resp, err = client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr)) + resp, err = client.Get(statsURL) if err == nil { break } @@ -163,6 +168,9 @@ func TestDashboardNoIPLeak(t *testing.T) { go r.ListenAndServe("127.0.0.1:0") <-r.Ready() defer r.Close() + // /api/stats is admin-gated; authenticate as an operator. + const adminToken = "dash-leak-admin-token" + r.SetAdminToken(adminToken) addr := r.Addr().String() dashRegisterNode(t, addr, "leak-test") @@ -180,8 +188,9 @@ func TestDashboardNoIPLeak(t *testing.T) { var client http.Client client.Timeout = 2 * time.Second var resp *http.Response + statsURL := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken) for i := 0; i < 20; i++ { - resp, err = client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr)) + resp, err = client.Get(statsURL) if err == nil { break } @@ -219,6 +228,11 @@ func TestDashboardAPIShape(t *testing.T) { defer r.Close() r.SetDashboardToken("shape-test-token") + // /api/stats is admin-gated; operators reach it with the admin token. + // The dashboard `token` param still toggles per-network (authenticated) + // fields on top of the admin gate. + const adminToken = "shape-admin-token" + r.SetAdminToken(adminToken) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -234,9 +248,9 @@ func TestDashboardAPIShape(t *testing.T) { fetch := func(token string) map[string]interface{} { t.Helper() - url := fmt.Sprintf("http://%s/api/stats", dashAddr) + url := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken) if token != "" { - url += "?token=" + token + url += "&token=" + token } var resp *http.Response for i := 0; i < 20; i++ { @@ -404,9 +418,9 @@ func TestDashboardBannerEndpoint(t *testing.T) { t.Fatalf("GET banner = %q, want %q", getResp.Banner, newBanner) } - // 6. The new banner must surface in the public /api/stats payload so - // the dashboard HTML renders it. - statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr)) + // 6. The new banner must surface in the /api/stats payload so the + // dashboard HTML renders it (admin-gated; authenticate as operator). + statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)) if err != nil { t.Fatalf("GET stats: %v", err) } diff --git a/tests/zz_enterprise_gate_test.go b/tests/zz_enterprise_gate_test.go index 72b19645..c9aea912 100644 --- a/tests/zz_enterprise_gate_test.go +++ b/tests/zz_enterprise_gate_test.go @@ -147,7 +147,7 @@ func TestEnterpriseGateInvite(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - ownerID, _ := registerTestNode(t, rc) + ownerID, ownerIdentity := registerTestNode(t, rc) targetID, _ := registerTestNode(t, rc) // Create an enterprise invite-only network (only way to get invite rule) @@ -157,7 +157,9 @@ func TestEnterpriseGateInvite(t *testing.T) { } netID := uint16(resp["network_id"].(float64)) - // This should succeed (enterprise network) + // This should succeed (enterprise network). InviteToNetwork always signs + // (common@v0.5.7); sign as the owner/inviter. + setClientSigner(rc, ownerIdentity) _, err = rc.InviteToNetwork(netID, ownerID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite on enterprise network should succeed: %v", err) @@ -638,7 +640,7 @@ func TestDeleteNetworkCleansInvites(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - owner, _ := registerTestNode(t, rc) + owner, ownerIdentity := registerTestNode(t, rc) target, targetID := registerTestNode(t, rc) // Create invite-only enterprise network @@ -648,7 +650,9 @@ func TestDeleteNetworkCleansInvites(t *testing.T) { } netID := uint16(resp["network_id"].(float64)) - // Send invite to target + // Send invite to target. InviteToNetwork always signs (common@v0.5.7); + // sign as the owner/inviter. + setClientSigner(rc, ownerIdentity) if _, err := rc.InviteToNetwork(netID, owner, target, TestAdminToken); err != nil { t.Fatalf("invite: %v", err) } @@ -1824,7 +1828,10 @@ func TestAuditEnrichedTagsAndPolicy(t *testing.T) { } } -// TestAdminKicksAdmin verifies that an admin can kick another admin. +// TestAdminKicksAdmin verifies the admin-kick privilege policy: an admin +// may NOT kick another admin (privilege-escalation guard, PILOT-266), but +// the owner may kick an admin. Admins may still be blocked from kicking the +// owner. func TestAdminKicksAdmin(t *testing.T) { t.Parallel() env := NewTestEnv(t) @@ -1897,13 +1904,26 @@ func TestAdminKicksAdmin(t *testing.T) { t.Fatalf("promote admin2: %v", err) } - // Admin1 kicks Admin2 (admin kicking admin — should succeed) + // Admin1 kicks Admin2 (admin kicking admin — must be REJECTED by the + // privilege-escalation guard added in PILOT-266). setClientSigner(rc, admin1Identity) _, err = rc.KickMember(netID, admin1ID, admin2ID, TestAdminToken) + if err == nil { + t.Fatal("expected error: an admin must not be able to kick another admin") + } + if !strings.Contains(err.Error(), "admins cannot kick other admins") { + t.Fatalf("expected 'admins cannot kick other admins' error, got: %v", err) + } + t.Logf("admin correctly blocked from kicking another admin: %v", err) + + // Owner kicks Admin2 (owner kicking admin — should succeed). This keeps + // the successful-kick code path under test. + setClientSigner(rc, ownerIdentity) + _, err = rc.KickMember(netID, ownerID, admin2ID, TestAdminToken) if err != nil { - t.Fatalf("admin1 kick admin2: %v", err) + t.Fatalf("owner kick admin2: %v", err) } - t.Log("admin successfully kicked another admin") + t.Log("owner successfully kicked an admin") // Verify admin2 is no longer in the network resp, err = rc.ListNodes(netID, TestAdminToken) @@ -1919,6 +1939,7 @@ func TestAdminKicksAdmin(t *testing.T) { } // Admin cannot kick owner + setClientSigner(rc, admin1Identity) _, err = rc.KickMember(netID, admin1ID, ownerID, TestAdminToken) if err == nil { t.Fatal("expected error kicking owner") diff --git a/tests/zz_invite_acceptance_test.go b/tests/zz_invite_acceptance_test.go index c6e22483..8622c200 100644 --- a/tests/zz_invite_acceptance_test.go +++ b/tests/zz_invite_acceptance_test.go @@ -25,7 +25,7 @@ func TestInviteRequiresAcceptance(t *testing.T) { defer cleanup() // Creator node - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "invite-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create invite network: %v", err) @@ -37,6 +37,8 @@ func TestInviteRequiresAcceptance(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) // Send invite + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite to network: %v", err) @@ -99,7 +101,7 @@ func TestInviteReject(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "reject-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -109,6 +111,8 @@ func TestInviteReject(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) // Invite + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) @@ -146,7 +150,7 @@ func TestInviteDedup(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "dedup-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -156,6 +160,8 @@ func TestInviteDedup(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) // Invite twice + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("first invite: %v", err) @@ -177,13 +183,16 @@ func TestInviteDedup(t *testing.T) { } } -// TestInviteRequiresAdmin verifies that invite_to_network requires admin token. +// TestInviteRequiresAdmin verifies the invite authorization contract when no +// admin token is supplied: the network owner may invite using only their +// signature (ownership authorizes the invite), but an unauthorized outsider +// (neither owner/admin nor bearing an admin token) is rejected. func TestInviteRequiresAdmin(t *testing.T) { t.Parallel() rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "admin-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -191,13 +200,24 @@ func TestInviteRequiresAdmin(t *testing.T) { netID := uint16(resp["network_id"].(float64)) targetID, _ := registerTestNode(t, rc) + outsiderID, outsiderIdentity := registerTestNode(t, rc) - // Try invite without admin token - _, err = rc.InviteToNetwork(netID, creatorID, targetID, "") - if err == nil { - t.Fatal("expected error when inviting without admin token, got nil") + // Owner invites via signature only (no admin token) — must succeed, + // ownership is sufficient authorization. InviteToNetwork always signs + // (common@v0.5.7); sign as the owner. + setClientSigner(rc, creatorIdentity) + if _, err = rc.InviteToNetwork(netID, creatorID, targetID, ""); err != nil { + t.Fatalf("owner invite via signature should succeed: %v", err) + } + + // Unauthorized outsider invites via signature only (no admin token) — + // must be rejected: not owner/admin and no admin token. + setClientSigner(rc, outsiderIdentity) + if _, err = rc.InviteToNetwork(netID, outsiderID, targetID, ""); err == nil { + t.Fatal("expected error when a non-authorized node invites without admin token, got nil") + } else { + t.Logf("outsider correctly rejected: %v", err) } - t.Logf("correctly rejected: %v", err) } // TestInviteNonMemberCantInvite verifies that a non-member cannot invite others. @@ -213,10 +233,13 @@ func TestInviteNonMemberCantInvite(t *testing.T) { } netID := uint16(resp["network_id"].(float64)) - outsiderID, _ := registerTestNode(t, rc) + outsiderID, outsiderIdentity := registerTestNode(t, rc) targetID, _ := registerTestNode(t, rc) // Try invite from non-member (without admin token — uses signature only) + // InviteToNetwork always signs (common@v0.5.7); sign as the outsider so the + // request reaches the registry and is rejected for non-membership. + setClientSigner(rc, outsiderIdentity) _, err = rc.InviteToNetwork(netID, outsiderID, targetID, "") if err == nil { t.Fatal("expected error when non-member invites via signature, got nil") @@ -250,7 +273,7 @@ func TestInvitePersistence(t *testing.T) { t.Fatalf("dial registry 1: %v", err) } - creatorID, _ := registerTestNode(t, rc1) + creatorID, creatorIdentity := registerTestNode(t, rc1) resp, err := rc1.CreateNetwork(creatorID, "persist-invite-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -259,6 +282,8 @@ func TestInvitePersistence(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc1) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc1, creatorIdentity) _, err = rc1.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) @@ -306,7 +331,7 @@ func TestInviteInboxClearedAfterPoll(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "inbox-clear-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -314,6 +339,8 @@ func TestInviteInboxClearedAfterPoll(t *testing.T) { netID := uint16(resp["network_id"].(float64)) targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) @@ -385,9 +412,12 @@ func TestInviteToNonExistentNetwork(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - inviterID, _ := registerTestNode(t, rc) + inviterID, inviterIdentity := registerTestNode(t, rc) targetID, _ := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter so the + // request reaches the registry and fails with network-not-found. + setClientSigner(rc, inviterIdentity) _, err := rc.InviteToNetwork(9999, inviterID, targetID, TestAdminToken) if err == nil { t.Fatal("expected error when inviting to non-existent network, got nil") @@ -401,7 +431,7 @@ func TestInviteTargetAlreadyMember(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "already-member-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -411,12 +441,16 @@ func TestInviteTargetAlreadyMember(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) // Invite and accept to make target a member + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) setClientSigner(rc, targetIdentity) rc.PollInvites(targetID) rc.RespondInvite(targetID, netID, true) - // Try to invite again — should fail since target is now a member + // Try to invite again — should fail since target is now a member. + // Restore the inviter's signer (the target's signer was set above). + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err == nil { t.Fatal("expected error when inviting node that is already a member, got nil") @@ -430,7 +464,7 @@ func TestInviteOpenNetworkRejected(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "open-invite-test", "open", "", TestAdminToken, true) if err != nil { t.Fatalf("create open network: %v", err) @@ -439,6 +473,9 @@ func TestInviteOpenNetworkRejected(t *testing.T) { targetID, _ := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter so the + // request reaches the registry and is rejected for a non-invite-only network. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err == nil { t.Fatal("expected error when inviting to non-invite-only network, got nil") @@ -452,9 +489,12 @@ func TestInviteMultipleNetworksAcceptAll(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) + const numNetworks = 3 netIDs := make([]uint16, numNetworks) for i := 0; i < numNetworks; i++ { @@ -515,13 +555,17 @@ func TestInviteConcurrentAccepts(t *testing.T) { regAddr := reg.Addr().String() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "concurrent-accept-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) } netID := uint16(resp["network_id"].(float64)) + // InviteToNetwork always signs (common@v0.5.7); the creator issues all invites + // on rc, so keep the creator's signer on rc for the duration of the loop. + setClientSigner(rc, creatorIdentity) + const n = 5 nodes := make([]struct { id uint32 @@ -582,9 +626,12 @@ func TestInviteInboxCapEnforced(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) targetID, _ := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) + const cap = 100 for i := 0; i < cap; i++ { netName := fmt.Sprintf("cap-net-%03d", i) @@ -618,7 +665,7 @@ func TestInviteChain(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "chain-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -629,6 +676,8 @@ func TestInviteChain(t *testing.T) { nodeB, identityB := registerTestNode(t, rc) // Creator → A + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter (creator). + setClientSigner(rc, creatorIdentity) rc.InviteToNetwork(netID, creatorID, nodeA, TestAdminToken) setClientSigner(rc, identityA) rc.PollInvites(nodeA) @@ -697,7 +746,7 @@ func TestInviteDoubleAcceptRace(t *testing.T) { regAddr := reg.Addr().String() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "double-accept-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -706,6 +755,8 @@ func TestInviteDoubleAcceptRace(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) @@ -772,7 +823,7 @@ func TestInviteAfterTargetDeregister(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "deregister-invite-net", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -781,6 +832,8 @@ func TestInviteAfterTargetDeregister(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) @@ -814,7 +867,7 @@ func TestInviteNetworkDeletedWhilePending(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) resp, err := rc.CreateNetwork(creatorID, "delete-while-pending", "invite", "", TestAdminToken, true) if err != nil { t.Fatalf("create network: %v", err) @@ -823,6 +876,8 @@ func TestInviteNetworkDeletedWhilePending(t *testing.T) { targetID, targetIdentity := registerTestNode(t, rc) + // InviteToNetwork always signs (common@v0.5.7); sign as the inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken) if err != nil { t.Fatalf("invite: %v", err) diff --git a/tests/zz_network_test.go b/tests/zz_network_test.go index eb27ae98..026e08e1 100644 --- a/tests/zz_network_test.go +++ b/tests/zz_network_test.go @@ -253,7 +253,7 @@ func TestNetworkInviteJoinRule(t *testing.T) { rc, _, cleanup := startTestRegistryWithAdmin(t) defer cleanup() - nodeA, _ := registerTestNode(t, rc) + nodeA, idA := registerTestNode(t, rc) nodeB, idB := registerTestNode(t, rc) nodeC, idC := registerTestNode(t, rc) @@ -276,9 +276,10 @@ func TestNetworkInviteJoinRule(t *testing.T) { if err == nil { t.Fatal("expected error with non-member inviter, got nil") } - rc.SetSigner(nil) - // A (member) invites B via new consent flow + // A (member) invites B via new consent flow. InviteToNetwork always + // signs (common@v0.5.7); sign as the inviter (A). + setClientSigner(rc, idA) _, err = rc.InviteToNetwork(netID, nodeA, nodeB, TestAdminToken) if err != nil { t.Fatalf("invite B: %v", err) diff --git a/tests/zz_pilotctl_network_test.go b/tests/zz_pilotctl_network_test.go index 7864fdec..496cee64 100644 --- a/tests/zz_pilotctl_network_test.go +++ b/tests/zz_pilotctl_network_test.go @@ -507,7 +507,7 @@ func TestPhase2IntegrationInviteAuditWebhook(t *testing.T) { defer rc.Close() // Shared creator node - creatorID, _ := registerTestNode(t, rc) + creatorID, creatorIdentity := registerTestNode(t, rc) // Open network for auto-join (Unit 4) openResp, err := rc.CreateNetwork(creatorID, "integration-open-net", "open", "", TestAdminToken, false) @@ -535,7 +535,9 @@ func TestPhase2IntegrationInviteAuditWebhook(t *testing.T) { cfg.AdminToken = TestAdminToken }) - // Invite B to invite-only network (Unit 3) + // Invite B to invite-only network (Unit 3). InviteToNetwork always signs + // (common@v0.5.7); sign as the creator/inviter. + setClientSigner(rc, creatorIdentity) _, err = rc.InviteToNetwork(invNetID, creatorID, infoB.Daemon.NodeID(), TestAdminToken) if err != nil { t.Fatalf("invite B: %v", err) diff --git a/tests/zz_rbac_test.go b/tests/zz_rbac_test.go index c3a5f255..38b57803 100644 --- a/tests/zz_rbac_test.go +++ b/tests/zz_rbac_test.go @@ -238,7 +238,9 @@ func TestRBACAdminCanInvite(t *testing.T) { } netID := uint16(netResp["network_id"].(float64)) - // Use admin token to invite admin-to-be and member-to-be (bootstrapping) + // Use admin token to invite admin-to-be and member-to-be (bootstrapping). + // InviteToNetwork always signs (common@v0.5.7); sign as the owner/inviter. + setClientSigner(rc, id1) _, err = rc.InviteToNetwork(netID, ownerID, adminID, env.AdminToken) if err != nil { t.Fatalf("invite admin: %v", err) @@ -627,7 +629,9 @@ func TestRBACInviteAcceptGetsRole(t *testing.T) { } netID := uint16(netResp["network_id"].(float64)) - // Owner invites target (admin token for bootstrap) + // Owner invites target (admin token for bootstrap). InviteToNetwork + // always signs (common@v0.5.7); sign as the owner/inviter. + setClientSigner(rc, id1) _, err = rc.InviteToNetwork(netID, ownerID, targetID, env.AdminToken) if err != nil { t.Fatalf("invite: %v", err)