@@ -438,11 +438,20 @@ pub(crate) mod test_support {
438438 /// tests can assert on validation errors.
439439 pub fn try_validate (
440440 zk : & v1alpha1:: ZookeeperCluster ,
441+ ) -> Result < ValidatedCluster , super :: validate:: Error > {
442+ try_validate_with_auth ( zk, DereferencedAuthenticationClasses :: new_for_tests ( ) )
443+ }
444+
445+ /// Runs the real validate step with caller-supplied (dereferenced) AuthenticationClasses, so
446+ /// tests can exercise the client-mTLS matrix.
447+ pub fn try_validate_with_auth (
448+ zk : & v1alpha1:: ZookeeperCluster ,
449+ authentication_classes : DereferencedAuthenticationClasses ,
441450 ) -> Result < ValidatedCluster , super :: validate:: Error > {
442451 validate (
443452 zk,
444453 & DereferencedObjects {
445- authentication_classes : DereferencedAuthenticationClasses :: new_for_tests ( ) ,
454+ authentication_classes,
446455 } ,
447456 & operator_environment ( ) ,
448457 )
@@ -452,18 +461,33 @@ pub(crate) mod test_support {
452461 pub fn validated_cluster ( zk : & v1alpha1:: ZookeeperCluster ) -> ValidatedCluster {
453462 try_validate ( zk) . expect ( "validate should succeed for the test fixture" )
454463 }
464+
465+ /// Runs the real validate step with a single TLS client-auth `AuthenticationClass`, mirroring
466+ /// the `use-client-auth-tls` kuttl scenario.
467+ pub fn validated_cluster_with_client_auth ( zk : & v1alpha1:: ZookeeperCluster ) -> ValidatedCluster {
468+ try_validate_with_auth (
469+ zk,
470+ DereferencedAuthenticationClasses :: new_for_tests_with_tls_client_auth ( ) ,
471+ )
472+ . expect ( "validate should succeed for the test fixture" )
473+ }
455474}
456475
457476#[ cfg( test) ]
458477mod tests {
459- use std:: str:: FromStr ;
478+ use std:: { collections :: BTreeSet , str:: FromStr } ;
460479
461480 use stackable_operator:: {
462481 k8s_openapi:: api:: core:: v1:: ConfigMap , v2:: types:: operator:: RoleGroupName ,
463482 } ;
464483
465484 use super :: * ;
466- use crate :: zk_controller:: test_support:: { cluster_info, minimal_zk, validated_cluster} ;
485+ use crate :: zk_controller:: {
486+ test_support:: {
487+ cluster_info, minimal_zk, validated_cluster, validated_cluster_with_client_auth,
488+ } ,
489+ validate:: ValidatedCluster ,
490+ } ;
467491
468492 #[ test]
469493 fn test_default_config ( ) {
@@ -536,9 +560,10 @@ mod tests {
536560
537561 #[ test]
538562 fn test_seeded_operator_defaults ( ) {
539- // These values are seeded directly by the ConfigMap builder and must stay
540- // byte-identical (pinned by the kuttl snapshot
541- // `tests/templates/kuttl/smoke/14-assert.yaml.j2`).
563+ // These values are seeded directly by the ConfigMap builder and must stay byte-identical.
564+ // This test (together with the TLS x client-auth matrix tests below) is the source of truth
565+ // for the rendered `zoo.cfg` / `security.properties`; the kuttl smoke keeps only the
566+ // live-cluster readiness/status checks.
542567 let zookeeper_yaml = r#"
543568 apiVersion: zookeeper.stackable.tech/v1alpha1
544569 kind: ZookeeperCluster
@@ -731,15 +756,280 @@ mod tests {
731756 assert ! ( cm. contains_key( "vector.yaml" ) ) ;
732757 }
733758
759+ // ---------------------------------------------------------------------------------------------
760+ // TLS x client-auth matrix (candidate #1 in unit-tests.md).
761+ //
762+ // These four tests own what the kuttl smoke `14-assert` `zoo.cfg` heredoc used to assert across
763+ // the `use-server-tls` x `use-client-auth-tls` scenario matrix: the exact set of `zoo.cfg`
764+ // properties and the ports/`ssl.*` lines that vary with TLS and client mTLS. Asserting the full
765+ // key set (not just `contains`) catches accidentally added *or* removed properties. Client mTLS
766+ // is injected via `validated_cluster_with_client_auth`, which the previous `new_for_tests()`
767+ // path could not reach.
768+ // ---------------------------------------------------------------------------------------------
769+
770+ /// The `zoo.cfg` keys that are always present regardless of the TLS/client-auth matrix
771+ /// (operator-injected defaults + quorum TLS, which is always on).
772+ fn base_zoo_cfg_keys ( ) -> BTreeSet < String > {
773+ [
774+ "admin.serverPort" ,
775+ "authProvider.x509" ,
776+ "clientPort" ,
777+ "dataDir" ,
778+ "initLimit" ,
779+ "metricsProvider.className" ,
780+ "metricsProvider.httpPort" ,
781+ "serverCnxnFactory" ,
782+ "ssl.quorum.clientAuth" ,
783+ "ssl.quorum.hostnameVerification" ,
784+ "ssl.quorum.keyStore.location" ,
785+ "ssl.quorum.trustStore.location" ,
786+ "sslQuorum" ,
787+ "syncLimit" ,
788+ "tickTime" ,
789+ ]
790+ . into_iter ( )
791+ . map ( str:: to_owned)
792+ . collect ( )
793+ }
794+
795+ /// The `zoo.cfg` property keys, excluding the replica-dependent `server.<myid>` quorum entries
796+ /// (those are asserted separately in `test_server_lines_use_myid_offset_across_rolegroups`).
797+ fn zoo_cfg_keys ( cm : & ConfigMap ) -> BTreeSet < String > {
798+ cm. data
799+ . as_ref ( )
800+ . unwrap ( )
801+ . get ( "zoo.cfg" )
802+ . unwrap ( )
803+ . lines ( )
804+ . filter_map ( |line| line. split_once ( '=' ) . map ( |( k, _) | k. to_owned ( ) ) )
805+ . filter ( |k| !k. starts_with ( "server." ) )
806+ . collect ( )
807+ }
808+
809+ /// TLS off, client-auth off: insecure client port, no server-`ssl.*` lines, no
810+ /// `client.portUnification`, no `ssl.clientAuth`.
811+ #[ test]
812+ fn test_matrix_no_tls_no_client_auth ( ) {
813+ let cm = build_config_map (
814+ r#"
815+ apiVersion: zookeeper.stackable.tech/v1alpha1
816+ kind: ZookeeperCluster
817+ metadata:
818+ name: simple-zookeeper
819+ spec:
820+ image:
821+ productVersion: "3.9.5"
822+ clusterConfig:
823+ tls:
824+ serverSecretClass: null
825+ servers:
826+ roleGroups:
827+ default:
828+ replicas: 3
829+ "# ,
830+ ) ;
831+ let zoo_cfg = cm. data . as_ref ( ) . unwrap ( ) . get ( "zoo.cfg" ) . unwrap ( ) ;
832+
833+ assert_eq ! ( zoo_cfg_keys( & cm) , base_zoo_cfg_keys( ) ) ;
834+ assert ! ( zoo_cfg. contains( "clientPort=2181" ) , "{zoo_cfg}" ) ;
835+ assert ! ( !zoo_cfg. contains( "client.portUnification" ) , "{zoo_cfg}" ) ;
836+ assert ! ( !zoo_cfg. contains( "ssl.clientAuth" ) , "{zoo_cfg}" ) ;
837+ assert ! ( !zoo_cfg. contains( "ssl.keyStore.location" ) , "{zoo_cfg}" ) ;
838+ }
839+
840+ /// TLS on (default), client-auth off: secure client port, server-`ssl.*` lines and
841+ /// `client.portUnification`, but no `ssl.clientAuth`.
842+ #[ test]
843+ fn test_matrix_server_tls_no_client_auth ( ) {
844+ let cm = build_config_map (
845+ r#"
846+ apiVersion: zookeeper.stackable.tech/v1alpha1
847+ kind: ZookeeperCluster
848+ metadata:
849+ name: simple-zookeeper
850+ spec:
851+ image:
852+ productVersion: "3.9.5"
853+ servers:
854+ roleGroups:
855+ default:
856+ replicas: 3
857+ "# ,
858+ ) ;
859+ let zoo_cfg = cm. data . as_ref ( ) . unwrap ( ) . get ( "zoo.cfg" ) . unwrap ( ) ;
860+
861+ let mut expected = base_zoo_cfg_keys ( ) ;
862+ expected. extend (
863+ [
864+ "client.portUnification" ,
865+ "ssl.hostnameVerification" ,
866+ "ssl.keyStore.location" ,
867+ "ssl.trustStore.location" ,
868+ ]
869+ . into_iter ( )
870+ . map ( str:: to_owned) ,
871+ ) ;
872+ assert_eq ! ( zoo_cfg_keys( & cm) , expected) ;
873+ assert ! ( zoo_cfg. contains( "clientPort=2282" ) , "{zoo_cfg}" ) ;
874+ assert ! ( zoo_cfg. contains( "client.portUnification=true" ) , "{zoo_cfg}" ) ;
875+ assert ! (
876+ zoo_cfg. contains( "ssl.keyStore.location=/stackable/server_tls/keystore.p12" ) ,
877+ "{zoo_cfg}"
878+ ) ;
879+ assert ! ( !zoo_cfg. contains( "ssl.clientAuth" ) , "{zoo_cfg}" ) ;
880+ }
881+
882+ /// TLS on, client-auth on: as above plus `ssl.clientAuth=need`.
883+ #[ test]
884+ fn test_matrix_server_tls_and_client_auth ( ) {
885+ let zk = minimal_zk (
886+ r#"
887+ apiVersion: zookeeper.stackable.tech/v1alpha1
888+ kind: ZookeeperCluster
889+ metadata:
890+ name: simple-zookeeper
891+ spec:
892+ image:
893+ productVersion: "3.9.5"
894+ servers:
895+ roleGroups:
896+ default:
897+ replicas: 3
898+ "# ,
899+ ) ;
900+ let cm = config_map_for ( & validated_cluster_with_client_auth ( & zk) , "default" ) ;
901+ let zoo_cfg = cm. data . as_ref ( ) . unwrap ( ) . get ( "zoo.cfg" ) . unwrap ( ) ;
902+
903+ let mut expected = base_zoo_cfg_keys ( ) ;
904+ expected. extend (
905+ [
906+ "client.portUnification" ,
907+ "ssl.clientAuth" ,
908+ "ssl.hostnameVerification" ,
909+ "ssl.keyStore.location" ,
910+ "ssl.trustStore.location" ,
911+ ]
912+ . into_iter ( )
913+ . map ( str:: to_owned) ,
914+ ) ;
915+ assert_eq ! ( zoo_cfg_keys( & cm) , expected) ;
916+ assert ! ( zoo_cfg. contains( "clientPort=2282" ) , "{zoo_cfg}" ) ;
917+ assert ! ( zoo_cfg. contains( "ssl.clientAuth=need" ) , "{zoo_cfg}" ) ;
918+ }
919+
920+ /// Client-auth on while server TLS is explicitly disabled: the client `AuthenticationClass`
921+ /// alone turns TLS on (secure port, `ssl.*` lines, `client.portUnification`) and adds
922+ /// `ssl.clientAuth=need`. This cross term was previously unreachable in unit tests.
923+ #[ test]
924+ fn test_matrix_client_auth_forces_tls_without_server_secret_class ( ) {
925+ let zk = minimal_zk (
926+ r#"
927+ apiVersion: zookeeper.stackable.tech/v1alpha1
928+ kind: ZookeeperCluster
929+ metadata:
930+ name: simple-zookeeper
931+ spec:
932+ image:
933+ productVersion: "3.9.5"
934+ clusterConfig:
935+ tls:
936+ serverSecretClass: null
937+ servers:
938+ roleGroups:
939+ default:
940+ replicas: 3
941+ "# ,
942+ ) ;
943+ let cm = config_map_for ( & validated_cluster_with_client_auth ( & zk) , "default" ) ;
944+ let zoo_cfg = cm. data . as_ref ( ) . unwrap ( ) . get ( "zoo.cfg" ) . unwrap ( ) ;
945+
946+ let mut expected = base_zoo_cfg_keys ( ) ;
947+ expected. extend (
948+ [
949+ "client.portUnification" ,
950+ "ssl.clientAuth" ,
951+ "ssl.hostnameVerification" ,
952+ "ssl.keyStore.location" ,
953+ "ssl.trustStore.location" ,
954+ ]
955+ . into_iter ( )
956+ . map ( str:: to_owned) ,
957+ ) ;
958+ assert_eq ! ( zoo_cfg_keys( & cm) , expected) ;
959+ assert ! ( zoo_cfg. contains( "clientPort=2282" ) , "{zoo_cfg}" ) ;
960+ assert ! ( zoo_cfg. contains( "client.portUnification=true" ) , "{zoo_cfg}" ) ;
961+ assert ! ( zoo_cfg. contains( "ssl.clientAuth=need" ) , "{zoo_cfg}" ) ;
962+ }
963+
964+ /// `server.<myid>` quorum lines are rendered into the per-rolegroup `zoo.cfg` with
965+ /// `myidOffset` applied and colons escaped, aggregated across *all* server role groups. Mirrors
966+ /// the kuttl smoke `14-assert` `server.N` lines (primary offset 10, secondary offset 20).
967+ #[ test]
968+ fn test_server_lines_use_myid_offset_across_rolegroups ( ) {
969+ let zk = minimal_zk (
970+ r#"
971+ apiVersion: zookeeper.stackable.tech/v1alpha1
972+ kind: ZookeeperCluster
973+ metadata:
974+ name: test-zk
975+ spec:
976+ image:
977+ productVersion: "3.9.5"
978+ clusterConfig:
979+ tls:
980+ serverSecretClass: null
981+ servers:
982+ roleGroups:
983+ primary:
984+ replicas: 2
985+ config:
986+ myidOffset: 10
987+ secondary:
988+ replicas: 1
989+ config:
990+ myidOffset: 20
991+ "# ,
992+ ) ;
993+ let validated = validated_cluster ( & zk) ;
994+ // The `server.N` lines are identical in every rolegroup's `zoo.cfg`; inspect the primary's.
995+ let cm = config_map_for ( & validated, "primary" ) ;
996+ let zoo_cfg = cm. data . as_ref ( ) . unwrap ( ) . get ( "zoo.cfg" ) . unwrap ( ) ;
997+
998+ // Colons are escaped by the Java-properties writer (`\:`); non-TLS client port 2181.
999+ assert ! (
1000+ zoo_cfg. contains(
1001+ "server.10=test-zk-server-primary-0.test-zk-server-primary-headless.default.svc.cluster.local\\ :2888\\ :3888;2181"
1002+ ) ,
1003+ "{zoo_cfg}"
1004+ ) ;
1005+ assert ! (
1006+ zoo_cfg. contains( "server.11=test-zk-server-primary-1." ) ,
1007+ "{zoo_cfg}"
1008+ ) ;
1009+ assert ! (
1010+ zoo_cfg. contains( "server.20=test-zk-server-secondary-0." ) ,
1011+ "{zoo_cfg}"
1012+ ) ;
1013+ // Exactly the three expected quorum entries, no more.
1014+ assert_eq ! (
1015+ zoo_cfg. lines( ) . filter( |l| l. starts_with( "server." ) ) . count( ) ,
1016+ 3 ,
1017+ "{zoo_cfg}"
1018+ ) ;
1019+ }
1020+
7341021 fn build_config_map ( zookeeper_yaml : & str ) -> ConfigMap {
735- let zookeeper = minimal_zk ( zookeeper_yaml) ;
736- let validated_cluster = validated_cluster ( & zookeeper) ;
737- let role_group_name = RoleGroupName :: from_str ( "default" ) . expect ( "valid role group name" ) ;
1022+ config_map_for ( & validated_cluster ( & minimal_zk ( zookeeper_yaml) ) , "default" )
1023+ }
1024+
1025+ /// Builds the rolegroup `ConfigMap` for the named server role group of a validated cluster.
1026+ fn config_map_for ( validated_cluster : & ValidatedCluster , role_group : & str ) -> ConfigMap {
1027+ let role_group_name = RoleGroupName :: from_str ( role_group) . expect ( "valid role group name" ) ;
7381028 let rolegroup_config =
7391029 & validated_cluster. role_group_configs [ & ZookeeperRole :: Server ] [ & role_group_name] ;
7401030
7411031 config_map:: build_server_rolegroup_config_map (
742- & validated_cluster,
1032+ validated_cluster,
7431033 & cluster_info ( ) ,
7441034 & role_group_name,
7451035 rolegroup_config,
0 commit comments