@@ -2,10 +2,13 @@ package readiness_test
22
33import (
44 "context"
5+ "database/sql"
6+ "database/sql/driver"
57 "errors"
68 "net/http"
79 "net/http/httptest"
810 "strings"
11+ "sync"
912 "testing"
1013 "time"
1114
@@ -396,3 +399,284 @@ func TestPingRedis_PreservesShortNonSecretError(t *testing.T) {
396399 t .Fatalf ("want preserved non-secret error, got %q" , res .LastError )
397400 }
398401}
402+
403+ // ---------------------------------------------------------------------
404+ // PingDB — defensive coverage. The package contract is that a nil
405+ // *sql.DB returns "db_not_configured" (so a partially-wired service
406+ // doesn't panic at probe time) and a real ping failure is surfaced as
407+ // failed with the error scrubbed.
408+ // ---------------------------------------------------------------------
409+
410+ // TestPingDB_NilDBIsFailed — the worker config can leave the customer DB
411+ // handle empty; the check should fail-with-explanation rather than panic.
412+ func TestPingDB_NilDBIsFailed (t * testing.T ) {
413+ res := readiness .PingDB (nil , time .Second )(context .Background ())
414+ if res .Status != readiness .StatusFailed {
415+ t .Fatalf ("want failed for nil db, got %q" , res .Status )
416+ }
417+ if res .LastError != "db_not_configured" {
418+ t .Fatalf ("want db_not_configured, got %q" , res .LastError )
419+ }
420+ }
421+
422+ // fakeDBDriver implements database/sql/driver.Driver with an Open that
423+ // always fails. Lets us exercise PingDB's error path without dragging
424+ // a real DB driver into common/. The error message intentionally
425+ // includes a password-shaped fragment so we also verify scrub().
426+ type fakeDBDriver struct {}
427+
428+ func (fakeDBDriver ) Open (name string ) (driver.Conn , error ) {
429+ return nil , errors .New (`pq: connection failed: password=hunter2letmein invalid` )
430+ }
431+
432+ var fakeDBRegisterOnce sync.Once
433+
434+ func registerFakeDB (t * testing.T ) {
435+ t .Helper ()
436+ fakeDBRegisterOnce .Do (func () {
437+ sql .Register ("readiness_fake_db" , fakeDBDriver {})
438+ })
439+ }
440+
441+ // TestPingDB_PingFailureIsFailed exercises the ping-error path on a real
442+ // *sql.DB whose driver always returns a credential-bearing error. The
443+ // LastError must be present, must NOT include the password, and the
444+ // status must be failed.
445+ func TestPingDB_PingFailureIsFailed (t * testing.T ) {
446+ registerFakeDB (t )
447+ db , err := sql .Open ("readiness_fake_db" , "ignored" )
448+ if err != nil {
449+ t .Fatalf ("sql.Open: %v" , err )
450+ }
451+ defer db .Close ()
452+
453+ res := readiness .PingDB (db , 200 * time .Millisecond )(context .Background ())
454+ if res .Status != readiness .StatusFailed {
455+ t .Fatalf ("want failed, got %q" , res .Status )
456+ }
457+ if res .LastError == "" {
458+ t .Fatalf ("want LastError populated on ping failure" )
459+ }
460+ if strings .Contains (res .LastError , "hunter2letmein" ) {
461+ t .Fatalf ("PingDB leaked password through LastError: %q" , res .LastError )
462+ }
463+ }
464+
465+ // ---------------------------------------------------------------------
466+ // mapHTTPStatus — additional branch coverage. 408 / 429 / generic 4xx
467+ // each route to a distinct (status, error) bucket; the existing 200
468+ // + 401 + 502 + timeout tests cover the other arms.
469+ // ---------------------------------------------------------------------
470+
471+ // TestHTTPHeadCheck_408IsFailed — 408 Request Timeout from the upstream
472+ // is symmetric with our own timeout: the upstream is malfunctioning,
473+ // failed + upstream_408.
474+ func TestHTTPHeadCheck_408IsFailed (t * testing.T ) {
475+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
476+ w .WriteHeader (408 )
477+ }))
478+ defer srv .Close ()
479+
480+ res := readiness .HTTPHeadCheck (nil , "GET" , srv .URL , nil , time .Second )(context .Background ())
481+ if res .Status != readiness .StatusFailed {
482+ t .Fatalf ("want failed for 408, got %q" , res .Status )
483+ }
484+ if ! strings .Contains (res .LastError , "408" ) {
485+ t .Fatalf ("want LastError to include 408, got %q" , res .LastError )
486+ }
487+ }
488+
489+ // TestHTTPHeadCheck_429IsFailed — 429 Too Many Requests means the
490+ // upstream is rate-limiting us. Failed so the NR alert fires; not
491+ // degraded because continued probes would only make it worse.
492+ func TestHTTPHeadCheck_429IsFailed (t * testing.T ) {
493+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
494+ w .WriteHeader (429 )
495+ }))
496+ defer srv .Close ()
497+
498+ res := readiness .HTTPHeadCheck (nil , "GET" , srv .URL , nil , time .Second )(context .Background ())
499+ if res .Status != readiness .StatusFailed {
500+ t .Fatalf ("want failed for 429, got %q" , res .Status )
501+ }
502+ if ! strings .Contains (res .LastError , "429" ) {
503+ t .Fatalf ("want LastError to include 429, got %q" , res .LastError )
504+ }
505+ }
506+
507+ // TestHTTPHeadCheck_Generic4xxIsDegraded — a non-auth, non-throttle 4xx
508+ // (e.g. 404 because the probe URL is wrong) means the probe shape is
509+ // off but the upstream is reachable. Degraded with http_<code> so the
510+ // operator knows to fix the probe config, not the upstream.
511+ func TestHTTPHeadCheck_Generic4xxIsDegraded (t * testing.T ) {
512+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
513+ w .WriteHeader (404 )
514+ }))
515+ defer srv .Close ()
516+
517+ res := readiness .HTTPHeadCheck (nil , "GET" , srv .URL , nil , time .Second )(context .Background ())
518+ if res .Status != readiness .StatusDegraded {
519+ t .Fatalf ("want degraded for 404, got %q" , res .Status )
520+ }
521+ if ! strings .Contains (res .LastError , "404" ) {
522+ t .Fatalf ("want LastError to include 404, got %q" , res .LastError )
523+ }
524+ }
525+
526+ // TestHTTPHeadCheck_BadURLBuildFails — a malformed URL trips the
527+ // http.NewRequestWithContext error path, which maps to a fixed
528+ // "request_build_failed" string (never the URL itself, which could
529+ // contain credentials).
530+ func TestHTTPHeadCheck_BadURLBuildFails (t * testing.T ) {
531+ // Control character in URL forces NewRequestWithContext to fail.
532+ res := readiness .HTTPHeadCheck (nil , "GET" , "http://invalid\x7f host/" , nil , time .Second )(context .Background ())
533+ if res .Status != readiness .StatusFailed {
534+ t .Fatalf ("want failed for bad URL, got %q" , res .Status )
535+ }
536+ if res .LastError != "request_build_failed" {
537+ t .Fatalf ("want request_build_failed, got %q" , res .LastError )
538+ }
539+ }
540+
541+ // TestHTTPHeadCheck_DefaultMethodIsGET — passing method="" defaults to
542+ // GET. Pins the contract so a future refactor that drops the default
543+ // doesn't silently start sending empty-method requests.
544+ func TestHTTPHeadCheck_DefaultMethodIsGET (t * testing.T ) {
545+ var seenMethod string
546+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
547+ seenMethod = r .Method
548+ w .WriteHeader (200 )
549+ }))
550+ defer srv .Close ()
551+
552+ _ = readiness .HTTPHeadCheck (nil , "" , srv .URL , nil , time .Second )(context .Background ())
553+ if seenMethod != http .MethodGet {
554+ t .Fatalf ("want default method GET, got %q" , seenMethod )
555+ }
556+ }
557+
558+ // ---------------------------------------------------------------------
559+ // GRPCHealth — defensive: nil checker returns failed instead of panicking.
560+ // ---------------------------------------------------------------------
561+
562+ // TestGRPCHealth_NilCheckerIsFailed — symmetric with PingDB/PingRedis.
563+ // A boot-time mis-wire (the provisioner client field is nil) must
564+ // surface as a check failure, not a panic in the readiness handler.
565+ func TestGRPCHealth_NilCheckerIsFailed (t * testing.T ) {
566+ res := readiness .GRPCHealth (nil , time .Second )(context .Background ())
567+ if res .Status != readiness .StatusFailed {
568+ t .Fatalf ("want failed for nil checker, got %q" , res .Status )
569+ }
570+ if res .LastError != "grpc_not_configured" {
571+ t .Fatalf ("want grpc_not_configured, got %q" , res .LastError )
572+ }
573+ }
574+
575+ // ---------------------------------------------------------------------
576+ // scrubNetError — exhaustive enum coverage. The function maps net.Error
577+ // shapes to short stable strings; each branch must be exercised.
578+ // scrubNetError is package-internal but reachable through GRPCHealth
579+ // (which wraps it) and HTTPHeadCheck (via client.Do failures).
580+ // ---------------------------------------------------------------------
581+
582+ // TestScrubNetError_NilIsEmpty — defensive nil handling.
583+ func TestScrubNetError_NilIsEmpty (t * testing.T ) {
584+ // Reachable indirectly via GRPCHealth with a checker that returns nil:
585+ // we already cover that as the OK path. For the nil-error mapping
586+ // specifically, we exercise it through a GRPCHealth checker that
587+ // returns nil on the call path (already tested). This test serves
588+ // as documentation that the package guards nil — no separate assert.
589+ res := readiness .GRPCHealth (fakeGRPC {err : nil }, time .Second )(context .Background ())
590+ if res .Status != readiness .StatusOK {
591+ t .Fatalf ("want ok for nil error, got %q" , res .Status )
592+ }
593+ }
594+
595+ // TestScrubNetError_DNSFailure — "no such host" maps to "dns_failure".
596+ // Exercised via GRPCHealth so the scrubNetError function is hit on the
597+ // real callsite.
598+ func TestScrubNetError_DNSFailure (t * testing.T ) {
599+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New ("dial tcp: lookup nowhere.invalid: no such host" )}, time .Second )(context .Background ())
600+ if res .Status != readiness .StatusFailed {
601+ t .Fatalf ("want failed, got %q" , res .Status )
602+ }
603+ if res .LastError != "dns_failure" {
604+ t .Fatalf ("want dns_failure, got %q" , res .LastError )
605+ }
606+ }
607+
608+ // TestScrubNetError_TLSFailure — "x509" or "TLS" in the error maps to
609+ // "tls_failure". Pins the auth-blip vs cert-blip distinction.
610+ func TestScrubNetError_TLSFailure (t * testing.T ) {
611+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New ("x509: certificate signed by unknown authority" )}, time .Second )(context .Background ())
612+ if res .Status != readiness .StatusFailed {
613+ t .Fatalf ("want failed, got %q" , res .Status )
614+ }
615+ if res .LastError != "tls_failure" {
616+ t .Fatalf ("want tls_failure, got %q" , res .LastError )
617+ }
618+
619+ // Also exercise the bare "TLS" string match.
620+ res2 := readiness .GRPCHealth (fakeGRPC {err : errors .New ("remote error: TLS handshake failure" )}, time .Second )(context .Background ())
621+ if res2 .LastError != "tls_failure" {
622+ t .Fatalf ("want tls_failure for TLS handshake, got %q" , res2 .LastError )
623+ }
624+ }
625+
626+ // TestScrubNetError_ConnectionRefused — the canonical down-upstream
627+ // shape maps to "connection_refused".
628+ func TestScrubNetError_ConnectionRefused (t * testing.T ) {
629+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New ("dial tcp 127.0.0.1:50051: connect: connection refused" )}, time .Second )(context .Background ())
630+ if res .LastError != "connection_refused" {
631+ t .Fatalf ("want connection_refused, got %q" , res .LastError )
632+ }
633+ }
634+
635+ // TestScrubNetError_TimeoutAndDeadline — both "timeout" and "deadline
636+ // exceeded" route to the same stable string.
637+ func TestScrubNetError_TimeoutAndDeadline (t * testing.T ) {
638+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New ("operation timeout" )}, time .Second )(context .Background ())
639+ if res .LastError != "timeout" {
640+ t .Fatalf ("want timeout, got %q" , res .LastError )
641+ }
642+ res2 := readiness .GRPCHealth (fakeGRPC {err : errors .New ("context deadline exceeded" )}, time .Second )(context .Background ())
643+ if res2 .LastError != "timeout" {
644+ t .Fatalf ("want timeout for deadline exceeded, got %q" , res2 .LastError )
645+ }
646+ }
647+
648+ // TestScrubNetError_GenericLongError — an unrecognized error longer
649+ // than 60 chars is truncated to 60. Preserves debuggability without
650+ // blowing the wire budget.
651+ func TestScrubNetError_GenericLongError (t * testing.T ) {
652+ long := strings .Repeat ("x" , 200 )
653+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New (long )}, time .Second )(context .Background ())
654+ if len (res .LastError ) > 60 {
655+ t .Fatalf ("scrubNetError did not truncate long error: len=%d" , len (res .LastError ))
656+ }
657+ }
658+
659+ // TestScrubNetError_GenericShortError — a short unrecognized error is
660+ // passed through unchanged.
661+ func TestScrubNetError_GenericShortError (t * testing.T ) {
662+ res := readiness .GRPCHealth (fakeGRPC {err : errors .New ("weird upstream" )}, time .Second )(context .Background ())
663+ if res .LastError != "weird upstream" {
664+ t .Fatalf ("want preserved short error, got %q" , res .LastError )
665+ }
666+ }
667+
668+ // TestFormatTimeout — the helper formats a duration as <ms>ms.
669+ // formatTimeout is exported only via export_test.go (no caller in
670+ // production code today); pinning the shape here keeps the helper
671+ // usable for the next consumer without surprise.
672+ func TestFormatTimeout (t * testing.T ) {
673+ if got := readiness .FormatTimeoutForTest (250 * time .Millisecond ); got != "250ms" {
674+ t .Fatalf ("want 250ms, got %q" , got )
675+ }
676+ if got := readiness .FormatTimeoutForTest (time .Second ); got != "1000ms" {
677+ t .Fatalf ("want 1000ms, got %q" , got )
678+ }
679+ if got := readiness .FormatTimeoutForTest (0 ); got != "0ms" {
680+ t .Fatalf ("want 0ms, got %q" , got )
681+ }
682+ }
0 commit comments