// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisor import ( "context" "fmt" "os" "testing" "github.com/stretchr/testify/require" "k8s.io/utils/ptr" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" ) func TestFromPath(t *testing.T) { tests := []struct { name string yaml string allowedCiphersError error wantConfig *Config wantError string }{ { name: "Happy", yaml: here.Doc(` --- apiGroupSuffix: some.suffix.com labels: myLabelKey1: myLabelValue1 myLabelKey2: myLabelValue2 names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: unix address: :1234 http: network: tcp address: 127.0.0.1:1234 insecureAcceptExternalUnencryptedHttpRequests: false log: level: info format: json aggregatedAPIServerPort: 12345 aggregatedAPIServerDisableAdmissionPlugins: - NamespaceLifecycle - MutatingAdmissionWebhook - ValidatingAdmissionPolicy - ValidatingAdmissionWebhook tls: onedottwo: allowedCiphers: - foo - bar - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 audit: logUsernamesAndGroups: enabled logInternalPaths: enabled oidc: ignoreUserInfoEndpoint: whenIssuerExactlyMatches: - https://foo.com - https://bar.com `), wantConfig: &Config{ APIGroupSuffix: ptr.To("some.suffix.com"), Labels: map[string]string{ "myLabelKey1": "myLabelValue1", "myLabelKey2": "myLabelValue2", }, NamesConfig: NamesConfigSpec{ DefaultTLSCertificateSecret: "my-secret-name", }, Endpoints: &Endpoints{ HTTPS: &Endpoint{ Network: "unix", Address: ":1234", }, HTTP: &Endpoint{ Network: "tcp", Address: "127.0.0.1:1234", }, }, Log: plog.LogSpec{ Level: plog.LevelInfo, Format: plog.FormatJSON, }, AggregatedAPIServerPort: ptr.To[int64](12345), AggregatedAPIServerDisableAdmissionPlugins: []string{ "NamespaceLifecycle", "MutatingAdmissionWebhook", "ValidatingAdmissionPolicy", "ValidatingAdmissionWebhook", }, TLS: TLSSpec{ OneDotTwo: TLSProtocolSpec{ AllowedCiphers: []string{ "foo", "bar", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", }, }, }, Audit: AuditSpec{ LogUsernamesAndGroups: "enabled", LogInternalPaths: "enabled", }, OIDC: OIDCSpec{ IgnoreUserInfoEndpoint: IgnoreUserInfoEndpointSpec{ WhenIssuerExactlyMatches: []string{ "https://foo.com", "https://bar.com", }, }, }, }, }, { name: "cli is a bad log format when configured by the user", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name log: level: info format: cli `), wantError: "decode yaml: error unmarshaling JSON: while decoding JSON: invalid log format, valid choices are the empty string or 'json'", }, { name: "When only the required fields are present, causes other fields to be defaulted", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name `), wantConfig: &Config{ APIGroupSuffix: ptr.To("pinniped.dev"), Labels: map[string]string{}, NamesConfig: NamesConfigSpec{ DefaultTLSCertificateSecret: "my-secret-name", }, Endpoints: &Endpoints{ HTTPS: &Endpoint{ Network: "tcp", Address: ":8443", }, HTTP: &Endpoint{ Network: "disabled", }, }, AggregatedAPIServerPort: ptr.To[int64](10250), Audit: AuditSpec{ LogInternalPaths: "", LogUsernamesAndGroups: "", }, AggregatedAPIServerDisableAdmissionPlugins: nil, TLS: TLSSpec{}, Log: plog.LogSpec{}, OIDC: OIDCSpec{}, }, }, { name: "audit settings can be disabled explicitly", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name audit: logInternalPaths: disabled logUsernamesAndGroups: disabled `), wantConfig: &Config{ APIGroupSuffix: ptr.To("pinniped.dev"), Labels: map[string]string{}, NamesConfig: NamesConfigSpec{ DefaultTLSCertificateSecret: "my-secret-name", }, Endpoints: &Endpoints{ HTTPS: &Endpoint{ Network: "tcp", Address: ":8443", }, HTTP: &Endpoint{ Network: "disabled", }, }, AggregatedAPIServerPort: ptr.To[int64](10250), Audit: AuditSpec{ LogInternalPaths: "disabled", LogUsernamesAndGroups: "disabled", }, }, }, { name: "ignoreUserInfoEndpoint can be explicitly an empty list", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name oidc: ignoreUserInfoEndpoint: whenIssuerExactlyMatches: [] `), wantConfig: &Config{ APIGroupSuffix: ptr.To("pinniped.dev"), Labels: map[string]string{}, NamesConfig: NamesConfigSpec{ DefaultTLSCertificateSecret: "my-secret-name", }, Endpoints: &Endpoints{ HTTPS: &Endpoint{ Network: "tcp", Address: ":8443", }, HTTP: &Endpoint{ Network: "disabled", }, }, AggregatedAPIServerPort: ptr.To[int64](10250), OIDC: OIDCSpec{ IgnoreUserInfoEndpoint: IgnoreUserInfoEndpointSpec{WhenIssuerExactlyMatches: []string{}}, }, }, }, { name: "all endpoints disabled", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: disabled http: network: disabled `), wantError: "validate endpoints: all endpoints are disabled", }, { name: "invalid https endpoint", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: foo http: network: disabled `), wantError: `validate https endpoint: unknown network "foo"`, }, { name: "invalid http endpoint", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: disabled http: network: bar `), wantError: `validate http endpoint: unknown network "bar"`, }, { name: "http endpoint uses tcp but binds to more than only loopback interfaces with insecureAcceptExternalUnencryptedHttpRequests missing", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: disabled http: network: tcp address: :8080 `), wantError: `validate http endpoint: http listener address ":8080" for "tcp" network may only bind to loopback interfaces`, }, { name: "http endpoint uses tcp but binds to more than only loopback interfaces", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: disabled http: network: tcp address: :8080 `), wantError: `validate http endpoint: http listener address ":8080" for "tcp" network may only bind to loopback interfaces`, }, { name: "endpoint disabled with non-empty address", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: disabled address: wee `), wantError: `validate https endpoint: address set to "wee" when disabled, should be empty`, }, { name: "endpoint tcp with empty address", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: http: network: tcp `), wantError: `validate http endpoint: address must be set with "tcp" network`, }, { name: "endpoint unix with empty address", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name endpoints: https: network: unix `), wantError: `validate https endpoint: address must be set with "unix" network`, }, { name: "Missing defaultTLSCertificateSecret name", yaml: here.Doc(` --- `), wantError: "validate names: missing required names: defaultTLSCertificateSecret", }, { name: "apiGroupSuffix is prefixed with '.'", yaml: here.Doc(` --- apiGroupSuffix: .starts.with.dot names: defaultTLSCertificateSecret: my-secret-name `), wantError: "validate apiGroupSuffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')", }, { name: "AggregatedAPIServerPortDefault too small", yaml: here.Doc(` --- aggregatedAPIServerPort: 1023 `), wantError: "validate aggregatedAPIServerPort: must be within range 1024 to 65535", }, { name: "AggregatedAPIServerPortDefault too large", yaml: here.Doc(` --- aggregatedAPIServerPort: 65536 `), wantError: "validate aggregatedAPIServerPort: must be within range 1024 to 65535", }, { name: "invalid audit.logUsernamesAndGroups format", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name audit: logUsernamesAndGroups: this-is-not-a-valid-value `), wantError: "validate audit: invalid logUsernamesAndGroups format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')", }, { name: "invalid audit.logInternalPaths format", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name audit: logInternalPaths: this-is-not-a-valid-value `), wantError: "validate audit: invalid logInternalPaths format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')", }, { name: "returns setAllowedCiphers errors", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name tls: onedottwo: allowedCiphers: - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 `), allowedCiphersError: fmt.Errorf("some error from setAllowedCiphers"), wantError: "validate tls: some error from setAllowedCiphers", }, { name: "invalid aggregatedAPIServerDisableAdmissionPlugins", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name aggregatedAPIServerDisableAdmissionPlugins: [foobar, ValidatingAdmissionWebhook, foobaz] `), wantError: "validate aggregatedAPIServerDisableAdmissionPlugins: admission plugin names not recognized: [foobar foobaz] (each must be one of [NamespaceLifecycle MutatingAdmissionPolicy MutatingAdmissionWebhook ValidatingAdmissionPolicy ValidatingAdmissionWebhook])", }, { name: "invalid ignoreUserInfoEndpoint", yaml: here.Doc(` --- names: defaultTLSCertificateSecret: my-secret-name oidc: ignoreUserInfoEndpoint: "should be a struct, but is a string" `), wantError: "decode yaml: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go struct field OIDCSpec.oidc.ignoreUserInfoEndpoint of type supervisor.IgnoreUserInfoEndpointSpec", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { // this is a serial test because it sets the global logger // Write yaml to temp file f, err := os.CreateTemp("", "pinniped-test-config-yaml-*") require.NoError(t, err) t.Cleanup(func() { err := os.Remove(f.Name()) require.NoError(t, err) }) _, err = f.WriteString(test.yaml) require.NoError(t, err) err = f.Close() require.NoError(t, err) // Test FromPath() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) var actualCiphers []string setAllowedCiphers := func(ciphers []string) error { actualCiphers = ciphers return test.allowedCiphersError } config, err := FromPath(ctx, f.Name(), setAllowedCiphers) if test.wantError != "" { require.EqualError(t, err, test.wantError) return } require.NoError(t, err) require.Equal(t, test.wantConfig, config) require.Equal(t, test.wantConfig.TLS.OneDotTwo.AllowedCiphers, actualCiphers) }) } } func TestAddrIsOnlyOnLoopback(t *testing.T) { tests := []struct { addr string want bool }{ {addr: "localhost:", want: true}, {addr: "localhost:0", want: true}, {addr: "localhost:80", want: true}, {addr: "localhost:http", want: true}, {addr: "ip6-localhost:", want: true}, {addr: "ip6-localhost:0", want: true}, {addr: "ip6-localhost:80", want: true}, {addr: "ip6-localhost:http", want: true}, {addr: "ip6-loopback:", want: true}, {addr: "ip6-loopback:0", want: true}, {addr: "ip6-loopback:80", want: true}, {addr: "ip6-loopback:http", want: true}, {addr: "127.0.0.1:", want: true}, {addr: "127.0.0.1:0", want: true}, {addr: "127.0.0.1:80", want: true}, {addr: "127.0.0.1:http", want: true}, {addr: "[::1]:", want: true}, {addr: "[::1]:0", want: true}, {addr: "[::1]:80", want: true}, {addr: "[::1]:http", want: true}, {addr: "[0:0:0:0:0:0:0:1]:", want: true}, {addr: "[0:0:0:0:0:0:0:1]:0", want: true}, {addr: "[0:0:0:0:0:0:0:1]:80", want: true}, {addr: "[0:0:0:0:0:0:0:1]:http", want: true}, {addr: "", want: false}, // illegal input, can't be empty {addr: "host", want: false}, // illegal input, need colon {addr: "localhost", want: false}, // illegal input, need colon {addr: "127.0.0.1", want: false}, // illegal input, need colon {addr: ":", want: false}, // illegal input, need either host or port {addr: "2001:db8::1:80", want: false}, // illegal input, forgot square brackets {addr: ":0", want: false}, {addr: ":80", want: false}, {addr: ":http", want: false}, {addr: "notlocalhost:", want: false}, {addr: "notlocalhost:0", want: false}, {addr: "notlocalhost:80", want: false}, {addr: "notlocalhost:http", want: false}, {addr: "0.0.0.0:", want: false}, {addr: "0.0.0.0:0", want: false}, {addr: "0.0.0.0:80", want: false}, {addr: "0.0.0.0:http", want: false}, {addr: "[::]:", want: false}, {addr: "[::]:0", want: false}, {addr: "[::]:80", want: false}, {addr: "[::]:http", want: false}, {addr: "42.42.42.42:", want: false}, {addr: "42.42.42.42:0", want: false}, {addr: "42.42.42.42:80", want: false}, {addr: "42.42.42.42:http", want: false}, {addr: "[2001:db8::1]:", want: false}, {addr: "[2001:db8::1]:0", want: false}, {addr: "[2001:db8::1]:80", want: false}, {addr: "[2001:db8::1]:http", want: false}, {addr: "[fe80::1%zone]:", want: false}, {addr: "[fe80::1%zone]:0", want: false}, {addr: "[fe80::1%zone]:80", want: false}, {addr: "[fe80::1%zone]:http", want: false}, } for _, test := range tests { tt := test t.Run(fmt.Sprintf("address %s should be %t", tt.addr, tt.want), func(t *testing.T) { t.Parallel() require.Equal(t, tt.want, addrIsOnlyOnLoopback(tt.addr)) }) } }