@@ -17,6 +17,7 @@ limitations under the License.
1717package service
1818
1919import (
20+ "cmp"
2021 "context"
2122 "crypto/x509"
2223 "encoding/pem"
@@ -28,7 +29,9 @@ import (
2829 "github.com/jumpstarter-dev/jumpstarter/controller/internal/config"
2930 "github.com/jumpstarter-dev/jumpstarter/controller/internal/oidc"
3031 pb "github.com/jumpstarter-dev/jumpstarter/controller/internal/protocol/jumpstarter/v1"
32+ "google.golang.org/grpc/codes"
3133 "google.golang.org/grpc/metadata"
34+ "google.golang.org/grpc/status"
3235)
3336
3437// testSigner returns a deterministic Signer built from a fixed seed for use in tests.
@@ -85,51 +88,57 @@ func TestGetServiceEndpoints_RequiresAuthentication(t *testing.T) {
8588 }
8689}
8790
88- // buildTelemetryEndpointsResponse exercises the response-building logic
89- // without going through the auth gate, for isolated unit testing.
90- func buildTelemetryEndpointsResponse (cfg * config.Telemetry ) * pb.GetServiceEndpointsResponse {
91+ // buildTelemetryEndpointsResponse mirrors the production GetServiceEndpoints
92+ // response-building logic without going through the auth gate.
93+ // It returns an error when no endpoint can be resolved, matching production.
94+ func buildTelemetryEndpointsResponse (cfg * config.Telemetry ) (* pb.GetServiceEndpointsResponse , error ) {
9195 resp := & pb.GetServiceEndpointsResponse {}
9296 if cfg != nil && cfg .Enabled {
93- minSev := cfg .Logging .Filter .MinSeverity
94- if minSev == "" {
95- minSev = "info"
96- }
97- ep := cfg .Endpoint
97+ ep := cmp .Or (cfg .Endpoint , telemetryEndpoint ())
9898 if ep == "" {
99- ep = telemetryEndpoint ( )
99+ return nil , status . Error ( codes . Internal , "telemetry is enabled but no endpoint is configured" )
100100 }
101101 resp .TelemetryEndpoints = append (resp .TelemetryEndpoints , & pb.TelemetryEndpoint {
102102 Endpoint : ep ,
103103 Certificate : cfg .Certificate ,
104- MinSeverity : minSev ,
104+ MinSeverity : cmp . Or ( cfg . Logging . Filter . MinSeverity , "info" ) ,
105105 })
106106 }
107- return resp
107+ return resp , nil
108108}
109109
110110func TestGetServiceEndpoints_NilConfig_ReturnsEmptyList (t * testing.T ) {
111- resp := buildTelemetryEndpointsResponse (nil )
111+ resp , err := buildTelemetryEndpointsResponse (nil )
112+ if err != nil {
113+ t .Fatalf ("unexpected error: %v" , err )
114+ }
112115 if len (resp .TelemetryEndpoints ) != 0 {
113116 t .Errorf ("expected empty telemetry_endpoints, got %d" , len (resp .TelemetryEndpoints ))
114117 }
115118}
116119
117120func TestGetServiceEndpoints_DisabledConfig_ReturnsEmptyList (t * testing.T ) {
118- resp := buildTelemetryEndpointsResponse (& config.Telemetry {Enabled : false , Endpoint : "telemetry:9093" })
121+ resp , err := buildTelemetryEndpointsResponse (& config.Telemetry {Enabled : false , Endpoint : "telemetry:9093" })
122+ if err != nil {
123+ t .Fatalf ("unexpected error: %v" , err )
124+ }
119125 if len (resp .TelemetryEndpoints ) != 0 {
120126 t .Errorf ("expected empty telemetry_endpoints when disabled, got %d" , len (resp .TelemetryEndpoints ))
121127 }
122128}
123129
124130func TestGetServiceEndpoints_WithEndpoint_ReturnsEndpoint (t * testing.T ) {
125- resp := buildTelemetryEndpointsResponse (& config.Telemetry {
131+ resp , err := buildTelemetryEndpointsResponse (& config.Telemetry {
126132 Enabled : true ,
127133 Endpoint : "telemetry.jumpstarter.svc:9093" ,
128134 Certificate : "--- PEM ---" ,
129135 Logging : config.TelemetryLogging {
130136 Filter : config.TelemetryLoggingFilter {MinSeverity : "warning" },
131137 },
132138 })
139+ if err != nil {
140+ t .Fatalf ("unexpected error: %v" , err )
141+ }
133142
134143 if len (resp .TelemetryEndpoints ) != 1 {
135144 t .Fatalf ("expected 1 telemetry endpoint, got %d" , len (resp .TelemetryEndpoints ))
@@ -147,10 +156,13 @@ func TestGetServiceEndpoints_WithEndpoint_ReturnsEndpoint(t *testing.T) {
147156}
148157
149158func TestGetServiceEndpoints_DefaultsMinSeverityToInfo (t * testing.T ) {
150- resp := buildTelemetryEndpointsResponse (& config.Telemetry {
159+ resp , err := buildTelemetryEndpointsResponse (& config.Telemetry {
151160 Enabled : true ,
152161 Endpoint : "telemetry:9093" ,
153162 })
163+ if err != nil {
164+ t .Fatalf ("unexpected error: %v" , err )
165+ }
154166
155167 if len (resp .TelemetryEndpoints ) != 1 {
156168 t .Fatalf ("expected 1 endpoint, got %d" , len (resp .TelemetryEndpoints ))
@@ -163,10 +175,13 @@ func TestGetServiceEndpoints_DefaultsMinSeverityToInfo(t *testing.T) {
163175func TestGetServiceEndpoints_UsesEnvVarWhenEndpointEmpty (t * testing.T ) {
164176 t .Setenv ("GRPC_TELEMETRY_ENDPOINT" , "telemetry.jumpstarter.svc:9093" )
165177
166- resp := buildTelemetryEndpointsResponse (& config.Telemetry {
178+ resp , err := buildTelemetryEndpointsResponse (& config.Telemetry {
167179 Enabled : true ,
168180 // Endpoint intentionally left empty — should fall back to env var.
169181 })
182+ if err != nil {
183+ t .Fatalf ("unexpected error: %v" , err )
184+ }
170185
171186 if len (resp .TelemetryEndpoints ) != 1 {
172187 t .Fatalf ("expected 1 endpoint, got %d" , len (resp .TelemetryEndpoints ))
@@ -176,30 +191,31 @@ func TestGetServiceEndpoints_UsesEnvVarWhenEndpointEmpty(t *testing.T) {
176191 }
177192}
178193
179- func TestGetServiceEndpoints_BothEndpointAndEnvVarEmpty_ReturnsEmptyEndpoint (t * testing.T ) {
194+ func TestGetServiceEndpoints_BothEndpointAndEnvVarEmpty_ReturnsError (t * testing.T ) {
180195 t .Setenv ("GRPC_TELEMETRY_ENDPOINT" , "" )
181196
182- resp := buildTelemetryEndpointsResponse (& config.Telemetry {
197+ _ , err := buildTelemetryEndpointsResponse (& config.Telemetry {
183198 Enabled : true ,
184199 // Both Endpoint and GRPC_TELEMETRY_ENDPOINT are empty.
185200 })
186-
187- if len (resp .TelemetryEndpoints ) != 1 {
188- t .Fatalf ("expected 1 endpoint entry, got %d" , len (resp .TelemetryEndpoints ))
201+ if err == nil {
202+ t .Fatal ("expected error when no endpoint is configured, got nil" )
189203 }
190- // An empty endpoint is returned; the caller must handle this gracefully.
191- if resp .TelemetryEndpoints [0 ].Endpoint != "" {
192- t .Errorf ("Endpoint = %q, want empty string when nothing is configured" , resp .TelemetryEndpoints [0 ].Endpoint )
204+ if status .Code (err ) != codes .Internal {
205+ t .Errorf ("expected codes.Internal, got %v" , status .Code (err ))
193206 }
194207}
195208
196209func TestGetServiceEndpoints_ConfigEndpointTakesPrecedenceOverEnvVar (t * testing.T ) {
197210 t .Setenv ("GRPC_TELEMETRY_ENDPOINT" , "env-telemetry.svc:9093" )
198211
199- resp := buildTelemetryEndpointsResponse (& config.Telemetry {
212+ resp , err := buildTelemetryEndpointsResponse (& config.Telemetry {
200213 Enabled : true ,
201214 Endpoint : "config-telemetry.svc:9093" ,
202215 })
216+ if err != nil {
217+ t .Fatalf ("unexpected error: %v" , err )
218+ }
203219
204220 if len (resp .TelemetryEndpoints ) != 1 {
205221 t .Fatalf ("expected 1 endpoint, got %d" , len (resp .TelemetryEndpoints ))
@@ -242,33 +258,6 @@ func writeTLSPEMFiles(t *testing.T) (certPath, keyPath string) {
242258 return certPath , keyPath
243259}
244260
245- // selfSignedSANs generates a self-signed certificate with the same logic as
246- // loadTLSCredentials for a given GRPC_TELEMETRY_ENDPOINT value, and returns
247- // its DNS SANs for assertion.
248- func selfSignedSANs (t * testing.T , advertised string ) []string {
249- t .Helper ()
250- var dnsnames []string
251- if advertised != "" {
252- dns , _ , err := endpointToSAN (advertised )
253- if err != nil {
254- dnsnames = []string {"localhost" }
255- } else {
256- dnsnames = dns
257- }
258- } else {
259- dnsnames = []string {"localhost" }
260- }
261- cert , err := NewSelfSignedCertificate ("test" , dnsnames , nil )
262- if err != nil {
263- t .Fatalf ("NewSelfSignedCertificate: %v" , err )
264- }
265- leaf , err := x509 .ParseCertificate (cert .Certificate [0 ])
266- if err != nil {
267- t .Fatalf ("ParseCertificate: %v" , err )
268- }
269- return leaf .DNSNames
270- }
271-
272261func TestTelemetryService_LoadTLSCredentials_SelfSigned (t * testing.T ) {
273262 t .Setenv ("EXTERNAL_CERT_PEM" , "" )
274263 t .Setenv ("EXTERNAL_KEY_PEM" , "" )
@@ -292,20 +281,56 @@ func TestTelemetryService_LoadTLSCredentials_SelfSigned(t *testing.T) {
292281}
293282
294283func TestTelemetryService_LoadTLSCredentials_SelfSignedUsesAdvertisedEndpointForSAN (t * testing.T ) {
295- // When GRPC_TELEMETRY_ENDPOINT is set, the self-signed cert SAN should derive
296- // from the advertised hostname — not from the bind address.
297- // We test the SAN derivation logic directly (same code path as loadTLSCredentials).
298- sans := selfSignedSANs (t , "telemetry.jumpstarter.svc:9093" )
299- if len (sans ) != 1 || sans [0 ] != "telemetry.jumpstarter.svc" {
300- t .Errorf ("expected SAN [telemetry.jumpstarter.svc], got %v" , sans )
284+ // The self-signed cert SAN must match the advertised endpoint hostname so
285+ // that TLS hostname verification succeeds when exporters connect.
286+ t .Setenv ("EXTERNAL_CERT_PEM" , "" )
287+ t .Setenv ("EXTERNAL_KEY_PEM" , "" )
288+ t .Setenv ("GRPC_TELEMETRY_ENDPOINT" , "telemetry.jumpstarter.svc:9093" )
289+
290+ svc := & TelemetryService {BindAddr : ":9093" , Signer : testSigner (t )}
291+ _ , selfSignedPEM , err := svc .loadTLSCredentials ()
292+ if err != nil {
293+ t .Fatalf ("loadTLSCredentials() failed: %v" , err )
294+ }
295+ if selfSignedPEM == "" {
296+ t .Fatal ("expected non-empty selfSignedPEM" )
297+ }
298+
299+ block , _ := pem .Decode ([]byte (selfSignedPEM ))
300+ if block == nil {
301+ t .Fatal ("selfSignedPEM is not valid PEM" )
302+ }
303+ leaf , err := x509 .ParseCertificate (block .Bytes )
304+ if err != nil {
305+ t .Fatalf ("ParseCertificate: %v" , err )
306+ }
307+ if len (leaf .DNSNames ) != 1 || leaf .DNSNames [0 ] != "telemetry.jumpstarter.svc" {
308+ t .Errorf ("expected SAN [telemetry.jumpstarter.svc], got %v" , leaf .DNSNames )
301309 }
302310}
303311
304312func TestTelemetryService_LoadTLSCredentials_SelfSignedFallsBackToLocalhostWhenNoEndpoint (t * testing.T ) {
305- // When GRPC_TELEMETRY_ENDPOINT is empty, SAN defaults to "localhost".
306- sans := selfSignedSANs (t , "" )
307- if len (sans ) != 1 || sans [0 ] != "localhost" {
308- t .Errorf ("expected SAN [localhost], got %v" , sans )
313+ // When GRPC_TELEMETRY_ENDPOINT is unset, the self-signed cert SAN defaults to "localhost".
314+ t .Setenv ("EXTERNAL_CERT_PEM" , "" )
315+ t .Setenv ("EXTERNAL_KEY_PEM" , "" )
316+ t .Setenv ("GRPC_TELEMETRY_ENDPOINT" , "" )
317+
318+ svc := & TelemetryService {BindAddr : ":9093" , Signer : testSigner (t )}
319+ _ , selfSignedPEM , err := svc .loadTLSCredentials ()
320+ if err != nil {
321+ t .Fatalf ("loadTLSCredentials() failed: %v" , err )
322+ }
323+
324+ block , _ := pem .Decode ([]byte (selfSignedPEM ))
325+ if block == nil {
326+ t .Fatal ("selfSignedPEM is not valid PEM" )
327+ }
328+ leaf , err := x509 .ParseCertificate (block .Bytes )
329+ if err != nil {
330+ t .Fatalf ("ParseCertificate: %v" , err )
331+ }
332+ if len (leaf .DNSNames ) != 1 || leaf .DNSNames [0 ] != "localhost" {
333+ t .Errorf ("expected SAN [localhost], got %v" , leaf .DNSNames )
309334 }
310335}
311336
@@ -384,6 +409,42 @@ func TestTelemetryService_LoadTLSCredentials_MissingCertFileReturnsError(t *test
384409 }
385410}
386411
412+ func TestTelemetryService_LoadTLSCredentials_MissingKeyFileReturnsError (t * testing.T ) {
413+ certPath , _ := writeTLSPEMFiles (t )
414+ t .Setenv ("EXTERNAL_CERT_PEM" , certPath )
415+ t .Setenv ("EXTERNAL_KEY_PEM" , "/does/not/exist/tls.key" )
416+
417+ svc := & TelemetryService {BindAddr : ":9093" , Signer : testSigner (t )}
418+ _ , _ , err := svc .loadTLSCredentials ()
419+ if err == nil {
420+ t .Fatal ("expected error reading missing key file" )
421+ }
422+ if ! strings .Contains (err .Error (), "key" ) {
423+ t .Errorf ("expected 'key' in error message, got: %v" , err )
424+ }
425+ }
426+
427+ func TestTelemetryService_LoadTLSCredentials_ValidCertInvalidKeyReturnsError (t * testing.T ) {
428+ certPath , _ := writeTLSPEMFiles (t )
429+
430+ keyFile , err := os .CreateTemp (t .TempDir (), "tls-*.key" )
431+ if err != nil {
432+ t .Fatalf ("CreateTemp: %v" , err )
433+ }
434+ if err := keyFile .Close (); err != nil {
435+ t .Fatalf ("close: %v" , err )
436+ }
437+
438+ t .Setenv ("EXTERNAL_CERT_PEM" , certPath )
439+ t .Setenv ("EXTERNAL_KEY_PEM" , keyFile .Name ())
440+
441+ svc := & TelemetryService {BindAddr : ":9093" , Signer : testSigner (t )}
442+ _ , _ , err = svc .loadTLSCredentials ()
443+ if err == nil {
444+ t .Fatal ("expected error parsing mismatched cert/key pair" )
445+ }
446+ }
447+
387448func TestTelemetryService_Start_FailsWhenExternalCertFileMissing (t * testing.T ) {
388449 t .Setenv ("EXTERNAL_CERT_PEM" , "/no/such/cert.pem" )
389450 t .Setenv ("EXTERNAL_KEY_PEM" , "/no/such/key.pem" )
0 commit comments