package main import ( "strings" "testing" "github.com/enmanuel/unibus/pkg/membership" ) // TestAudit_FailOpenTLSWithoutAuth ports the auditor's H2 vector. Before the // guard, booting with TLS on but the authenticator off ("--bind 0.0.0.0 // --tls-cert … " without enforce) produced an encrypted data plane that an // unregistered, nkey-less client could still connect to — a fail-open config // wearing the appearance of security. validateBootConfig now refuses it, so the // insecure server never starts (the client therefore has nothing to connect to). func TestAudit_FailOpenTLSWithoutAuth(t *testing.T) { // The exact auditor configuration: public bind, TLS provided, auth off. err := validateBootConfig("0.0.0.0", membership.AuthOff, "server.crt", "server.key") if err == nil { t.Fatalf("TLS without enforce on a public bind must be refused at startup") } if !strings.Contains(err.Error(), "enforce") { t.Fatalf("error should point the operator at --bus-auth enforce, got: %v", err) } // And TLS without enforce is rejected even on loopback: TLS implies a // security posture, so authenticating no one is always a misconfiguration. if err := validateBootConfig("127.0.0.1", membership.AuthOff, "server.crt", "server.key"); err == nil { t.Fatalf("TLS flags without enforce must be refused regardless of bind") } } // TestGap_PublicEnforceNoTLS ports the re-auditor's N4 gap: the H2 guard refused // "public without enforce" and "TLS without enforce", but ALLOWED a public bind // with enforce and NO --tls-cert, so the control plane served metadata over // plaintext HTTP publicly (H5 reappearing). The guard now refuses it. func TestGap_PublicEnforceNoTLS(t *testing.T) { // The exact auditor configuration: public bind, enforce on, no TLS cert/key. err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "", "") if err == nil { t.Fatalf("public bind + enforce + NO --tls-cert must be refused: the control plane would serve plaintext HTTP publicly (audit N4)") } if !strings.Contains(err.Error(), "tls-cert") { t.Fatalf("error should point the operator at --tls-cert/--tls-key, got: %v", err) } // Golden: the same public+enforce config WITH a cert/key is allowed. if err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "server.crt", "server.key"); err != nil { t.Fatalf("public + enforce + TLS is the intended production config, got: %v", err) } // Edge: loopback without TLS stays allowed (local dev is not a public exposure). if err := validateBootConfig("127.0.0.1", membership.AuthOff, "", ""); err != nil { t.Fatalf("loopback dev without TLS must remain allowed, got: %v", err) } } // TestBootConfigPolicy is the full table: the golden secure-public config is // allowed, dev loopback is allowed, and every fail-open shape is refused. func TestBootConfigPolicy(t *testing.T) { cases := []struct { name string bind string mode membership.AuthMode cert string key string wantErr bool }{ // Golden: the intended public production config — enforce AND TLS. {"public+enforce+tls", "0.0.0.0", membership.AuthEnforce, "s.crt", "s.key", false}, // Edge: local dev on loopback may stay open (no auth, no TLS). {"loopback+off", "127.0.0.1", membership.AuthOff, "", "", false}, {"loopback-ipv6+off", "::1", membership.AuthOff, "", "", false}, {"localhost+off", "localhost", membership.AuthOff, "", "", false}, {"loopback+soft", "127.0.0.1", membership.AuthSoft, "", "", false}, // Edge: loopback with full enforce+TLS is also fine. {"loopback+enforce+tls", "127.0.0.1", membership.AuthEnforce, "s.crt", "s.key", false}, // Error: public bind without enforce. {"public+off", "0.0.0.0", membership.AuthOff, "", "", true}, {"public+soft", "0.0.0.0", membership.AuthSoft, "", "", true}, {"lan-ip+off", "192.168.1.10", membership.AuthOff, "", "", true}, {"empty-bind+off", "", membership.AuthOff, "", "", true}, // Error (N4): public bind + enforce but NO TLS -> plaintext control plane. {"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", true}, {"public+enforce+certonly", "0.0.0.0", membership.AuthEnforce, "s.crt", "", true}, {"public+enforce+keyonly", "0.0.0.0", membership.AuthEnforce, "", "s.key", true}, {"lan-ip+enforce+notls", "192.168.1.10", membership.AuthEnforce, "", "", true}, // Error: TLS flags without enforce (cert or key alone is enough to trip it). {"loopback+tlscert+off", "127.0.0.1", membership.AuthOff, "s.crt", "", true}, {"loopback+tlskey+soft", "127.0.0.1", membership.AuthSoft, "", "s.key", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := validateBootConfig(c.bind, c.mode, c.cert, c.key) if c.wantErr && err == nil { t.Fatalf("config %+v should be refused", c) } if !c.wantErr && err != nil { t.Fatalf("config %+v should be allowed, got: %v", c, err) } }) } } // TestClusterConfigPolicy is the cluster route guard (issue 0003a): a standalone // server is always fine; a loopback cluster is dev-only and unguarded; a public // cluster demands both a route secret and complete mutual route TLS; and the // route-TLS flags are all-or-nothing regardless of bind. func TestClusterConfigPolicy(t *testing.T) { const c, k, ca = "node.crt", "node.key", "ca.crt" en := membership.AuthEnforce off := membership.AuthOff soft := membership.AuthSoft cases := []struct { name string clusterName, bind string user, pass string rtCert, rtKey, rtCA string mode membership.AuthMode wantErr bool }{ // Standalone (no cluster name) is always allowed, even on a public bind and // without enforce — the cluster posture rule does not apply to a single node. {"standalone-public-off", "", "0.0.0.0", "", "", "", "", "", off, false}, // Loopback dev cluster WITH enforce: allowed (unreachable from outside). {"loopback-cluster-enforce", "unibus", "127.0.0.1", "", "", "", "", "", en, false}, // Golden: full public HA config under enforce. {"public-full-enforce", "unibus", "0.0.0.0", "u", "p", c, k, ca, en, false}, // N1 (audit 0008): a clustered node WITHOUT enforce is refused — even on // loopback — so no weak node can join the cluster. {"cluster-off-refused", "unibus", "127.0.0.1", "", "", "", "", "", off, true}, {"cluster-soft-refused", "unibus", "0.0.0.0", "u", "p", c, k, ca, soft, true}, // Error: public cluster without a route secret (enforce on, fails on secret). {"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, en, true}, {"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, en, true}, // Error: public cluster without mutual route TLS. {"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", en, true}, // Error: partial route-TLS flags trip regardless of bind/mode. {"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", en, true}, {"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", off, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA, tc.mode) if tc.wantErr && err == nil { t.Fatalf("cluster config %+v should be refused", tc) } if !tc.wantErr && err != nil { t.Fatalf("cluster config %+v should be allowed, got: %v", tc, err) } }) } } // TestAttack0008_N1 is the regression for audit 0008 N1 scenario 2: a node // configured to join a cluster while NOT enforcing auth (the weak node that lets // an unauthenticated peer harvest the cluster's forwarded traffic) must be refused // at startup. The homogeneous-posture rule makes this binary unable to BE that // weak node. func TestAttack0008_N1(t *testing.T) { // Weak node: clustered but --bus-auth off -> refused. if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthOff); err == nil { t.Fatalf("a clustered node without enforce must be refused (audit 0008 N1)") } // Same node WITH enforce + full route security -> allowed. if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthEnforce); err != nil { t.Fatalf("a clustered enforce node with full route security must be allowed, got: %v", err) } } func TestSplitRoutes(t *testing.T) { cases := []struct { in string want int }{ {"", 0}, {"nats://a:1", 1}, {"nats://a:1,nats://b:2", 2}, {" nats://a:1 , nats://b:2 ", 2}, // spaces trimmed {"nats://a:1,,", 1}, // empty entries dropped {",", 0}, } for _, c := range cases { if got := splitRoutes(c.in); len(got) != c.want { t.Fatalf("splitRoutes(%q) = %v (len %d), want len %d", c.in, got, len(got), c.want) } } }