From df4a7a89d1d5fc4ddaf26424c5d6bf7805582040 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Apr 2026 23:36:04 +0700 Subject: [PATCH] checker: adopt unified ReportContext reporter signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the checker-sdk-go interface consolidation: reporter methods now take sdk.ReportContext and read the payload via ctx.Data() instead of the raw json.RawMessage parameter. Backed by the same underlying logic — this is a signature migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- caldav/definition.go | 12 ++ caldav/report.go | 19 ++- carddav/definition.go | 10 ++ carddav/report.go | 11 +- internal/dav/endpoints.go | 21 ++- internal/dav/endpoints_test.go | 8 + internal/dav/report.go | 74 +++++++-- internal/dav/tls_related.go | 275 +++++++++++++++++++++++++++++++ internal/dav/tls_related_test.go | 82 +++++++++ 9 files changed, 480 insertions(+), 32 deletions(-) create mode 100644 internal/dav/tls_related.go create mode 100644 internal/dav/tls_related_test.go diff --git a/caldav/definition.go b/caldav/definition.go index f9d6c39..5899293 100644 --- a/caldav/definition.go +++ b/caldav/definition.go @@ -18,7 +18,19 @@ func Definition() *sdk.CheckerDefinition { Name: "CalDAV server", Version: Version, Availability: sdk.CheckerAvailability{ + // The probe itself only needs a domain name (discovery runs on + // the whole domain via /.well-known + SRV), so the checker is + // always offered at domain scope. ApplyToDomain: true, + + // Also offered at service scope so alerts — including the TLS + // alerts derived from the endpoints we publish — surface on a + // dedicated "CalDAV" service page rather than on the domain + // page. The abstract.CalDAV service type does not exist in the + // happyDomain service catalog yet; until it does, this has no + // visible effect, but makes the intent explicit. + ApplyToService: true, + LimitToServices: []string{"abstract.CalDAV"}, }, ObservationKeys: []sdk.ObservationKey{ObservationKey}, Options: sdk.CheckerOptionsDocumentation{ diff --git a/caldav/report.go b/caldav/report.go index 39dd680..7355c2d 100644 --- a/caldav/report.go +++ b/caldav/report.go @@ -4,19 +4,26 @@ import ( "encoding/json" "fmt" + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-dav/internal/dav" ) // GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider. // -// The actual rendering is delegated to the shared renderer in internal/dav so -// CalDAV and CardDAV produce visually identical reports; the only difference -// is the title and the set of phases rendered (CalDAV includes Scheduling). -func (p *caldavProvider) GetHTMLReport(raw json.RawMessage) (string, error) { +// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV +// produce visually identical reports; the only differences are the title +// and the set of phases (CalDAV includes Scheduling). +// +// Downstream TLS probes published for the endpoints we discovered are read +// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts + +// dedicated TLS phase) — per +// happydomain3/docs/checker-discovery-endpoint.md. +func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d dav.Observation - if err := json.Unmarshal(raw, &d); err != nil { + if err := json.Unmarshal(ctx.Data(), &d); err != nil { return "", fmt.Errorf("failed to unmarshal caldav report: %w", err) } d.Kind = dav.KindCalDAV - return dav.RenderReport(&d, "CalDAV Server") + return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey)) } diff --git a/carddav/definition.go b/carddav/definition.go index b45e33d..8ba5d6a 100644 --- a/carddav/definition.go +++ b/carddav/definition.go @@ -15,7 +15,17 @@ func Definition() *sdk.CheckerDefinition { Name: "CardDAV server", Version: Version, Availability: sdk.CheckerAvailability{ + // Domain scope for the probe itself (discovery runs across the + // whole domain via /.well-known + SRV). ApplyToDomain: true, + + // Service scope so downstream TLS alerts attach to a dedicated + // "CardDAV" service page instead of the domain page. See the + // CalDAV sibling for the rationale; abstract.CardDAV is not in + // the happyDomain service catalog yet but the intent is encoded + // here ahead of time. + ApplyToService: true, + LimitToServices: []string{"abstract.CardDAV"}, }, ObservationKeys: []sdk.ObservationKey{ObservationKey}, Options: sdk.CheckerOptionsDocumentation{ diff --git a/carddav/report.go b/carddav/report.go index 61ce078..33610b7 100644 --- a/carddav/report.go +++ b/carddav/report.go @@ -4,14 +4,19 @@ import ( "encoding/json" "fmt" + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-dav/internal/dav" ) -func (p *carddavProvider) GetHTMLReport(raw json.RawMessage) (string, error) { +// GetHTMLReport folds downstream TLS probes (published on our discovered +// endpoints) into the CardDAV report via ctx.Related — see the CalDAV +// sibling for the rationale. +func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d dav.Observation - if err := json.Unmarshal(raw, &d); err != nil { + if err := json.Unmarshal(ctx.Data(), &d); err != nil { return "", fmt.Errorf("failed to unmarshal carddav report: %w", err) } d.Kind = dav.KindCardDAV - return dav.RenderReport(&d, "CardDAV Server") + return dav.RenderReport(&d, "CardDAV Server", ctx.Related(dav.TLSRelatedKey)) } diff --git a/internal/dav/endpoints.go b/internal/dav/endpoints.go index 7e57fcb..0b739ff 100644 --- a/internal/dav/endpoints.go +++ b/internal/dav/endpoints.go @@ -15,6 +15,12 @@ import ( // the endpoint was reached via SRV, we also surface each SRV target as its // own endpoint — those are the names operators actually need certificates on, // and they may differ from the queried domain. +// +// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike +// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target +// split: clients negotiate TLS for the hostname they connect to). We fill +// the field unconditionally so consumers can rely on it being set, matching +// the convention already used by the XMPP checker. func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint { if obs == nil || obs.Discovery.ContextURL == "" { return nil @@ -22,7 +28,7 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint { var out []sdk.DiscoveredEndpoint seen := map[string]struct{}{} - add := func(host string, port uint16, sni string) { + add := func(host string, port uint16) { if host == "" || port == 0 { return } @@ -31,20 +37,17 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint { return } seen[key] = struct{}{} - ep := sdk.DiscoveredEndpoint{ + out = append(out, sdk.DiscoveredEndpoint{ Type: "tls", Host: host, Port: port, - } - if sni != "" && sni != host { - ep.SNI = sni - } - out = append(out, ep) + SNI: host, + }) } // Primary endpoint: the resolved context URL. if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok { - add(host, port, "") + add(host, port) } // Secondary endpoints: every TLS SRV target. Clients may connect to any @@ -54,7 +57,7 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint { if port == 0 { port = 443 } - add(r.Target, port, "") + add(r.Target, port) } return out diff --git a/internal/dav/endpoints_test.go b/internal/dav/endpoints_test.go index 8f1d5e1..0488ae5 100644 --- a/internal/dav/endpoints_test.go +++ b/internal/dav/endpoints_test.go @@ -13,6 +13,11 @@ func TestDiscoverEndpoints_contextURLOnly(t *testing.T) { if got[0].Host != "dav.example.com" || got[0].Port != 443 || got[0].Type != "tls" { t.Errorf("unexpected endpoint: %+v", got[0]) } + // SNI must be set unconditionally (uniform with the XMPP checker), + // even when it is equal to Host. + if got[0].SNI != "dav.example.com" { + t.Errorf("SNI = %q, want dav.example.com", got[0].SNI) + } } func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) { @@ -46,6 +51,9 @@ func TestDiscoverEndpoints_srvTargets(t *testing.T) { hosts := map[string]bool{} for _, e := range got { hosts[e.Host] = true + if e.SNI != e.Host { + t.Errorf("endpoint %+v: SNI=%q, want %q (equal to Host)", e, e.SNI, e.Host) + } } for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} { if !hosts[want] { diff --git a/internal/dav/report.go b/internal/dav/report.go index 91d639b..43038da 100644 --- a/internal/dav/report.go +++ b/internal/dav/report.go @@ -4,15 +4,25 @@ import ( "fmt" "html/template" "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" ) // RenderReport turns an Observation into a self-contained HTML document. // // The report foregrounds action items for the failure modes we see most often -// (well-known misconfig, missing DAV capability, missing credentials) before -// showing the full per-phase evidence. -func RenderReport(obs *Observation, title string) (string, error) { - data := buildReportData(obs, title) +// (well-known misconfig, missing DAV capability, missing credentials, +// downstream TLS issues on the endpoints we published) before showing the +// full per-phase evidence. +// +// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil +// is fine: the TLS section is simply omitted. This is how the happyDomain +// cross-checker composition story (see +// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate +// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS +// dashboard. +func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) { + data := buildReportData(obs, title, tlsRelated) var buf strings.Builder if err := reportTemplate.Execute(&buf, data); err != nil { return "", fmt.Errorf("render dav report: %w", err) @@ -21,15 +31,16 @@ func RenderReport(obs *Observation, title string) (string, error) { } type reportData struct { - Title string - Domain string - Verdict string - VerdictCls string - Callouts []calloutData - Phases []phaseData - Raw string - ShowSched bool - Scheduling *SchedulingResult + Title string + Domain string + Verdict string + VerdictCls string + Callouts []calloutData + Phases []phaseData + Raw string + ShowSched bool + Scheduling *SchedulingResult + TLSSummaries []TLSSummary } type calloutData struct { @@ -51,7 +62,7 @@ type phaseItem struct { Mono string } -func buildReportData(o *Observation, title string) reportData { +func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData { d := reportData{ Title: title, Domain: o.Domain, @@ -61,6 +72,21 @@ func buildReportData(o *Observation, title string) reportData { d.Callouts = buildCallouts(o) d.Phases = buildPhases(o) + // Fold downstream TLS probes (published by checker-tls against the + // endpoints we discovered) into the report. + tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated) + d.TLSSummaries = tlsSummaries + for _, c := range tlsCallouts { + d.Callouts = append(d.Callouts, calloutData{ + Severity: c.Severity, + Title: c.Title, + Body: c.Body, + }) + } + if len(tlsSummaries) > 0 { + d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries)) + } + switch { case hasSeverity(d.Phases, "fail"): d.Verdict = "Critical issues detected" @@ -225,6 +251,26 @@ func buildPhases(o *Observation) []phaseData { return phases } +// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase +// rendered at the bottom of the report. Open when anything is non-OK so +// operators don't need to expand it to see the problem. +func buildTLSPhase(summaries []TLSSummary) phaseData { + p := phaseData{Title: "TLS (from checker-tls)"} + for _, s := range summaries { + label := s.Address + if s.TLSVersion != "" { + label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion) + } + p.Items = append(p.Items, phaseItem{ + Label: label, + Status: s.Status, + Detail: s.Detail, + }) + } + p.Open = hasItemSeverity(p.Items, "warn", "fail") + return p +} + // ── small helpers used by buildPhases ──────────────────────────────────────── func wellKnownStatus(d DiscoveryResult) string { diff --git a/internal/dav/tls_related.go b/internal/dav/tls_related.go new file mode 100644 index 0000000..35050d2 --- /dev/null +++ b/internal/dav/tls_related.go @@ -0,0 +1,275 @@ +package dav + +import ( + "encoding/json" + "fmt" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey is the observation key we expect a TLS checker to publish for +// the endpoints we discover. Matches the cross-checker convention documented +// in happydomain3/docs/checker-discovery-endpoint.md. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally +// only read the fields we need and tolerate missing ones — the TLS checker's +// full schema is owned by that checker. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + + // Alternative shape used by the reference checker-tls payload sketched + // in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}. + Cert *struct { + NotAfter time.Time `json:"notAfter,omitempty"` + SANMatch *bool `json:"sanMatch,omitempty"` + ChainValid *bool `json:"chainValid,omitempty"` + DaysRemaining *int `json:"daysRemaining,omitempty"` + SubjectCN string `json:"subjectCN,omitempty"` + IssuerCN string `json:"issuerCN,omitempty"` + } `json:"cert,omitempty"` + + Rules []struct { + Code string `json:"code,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + } `json:"rules,omitempty"` + + Issues []struct { + Code string `json:"code,omitempty"` + Severity string `json:"severity,omitempty"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +// address returns the canonical "host:port" string used to match a probe +// against one of our discovered endpoints. +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +// certExpiry normalises the two schema shapes into a single (t, ok) pair so +// callers don't have to know which one the TLS checker emits. +func (v *tlsProbeView) certExpiry() (time.Time, bool) { + if !v.NotAfter.IsZero() { + return v.NotAfter, true + } + if v.Cert != nil && !v.Cert.NotAfter.IsZero() { + return v.Cert.NotAfter, true + } + return time.Time{}, false +} + +func (v *tlsProbeView) hostnameOK() (bool, bool) { + if v.HostnameMatch != nil { + return *v.HostnameMatch, true + } + if v.Cert != nil && v.Cert.SANMatch != nil { + return *v.Cert.SANMatch, true + } + return false, false +} + +func (v *tlsProbeView) chainOK() (bool, bool) { + if v.ChainValid != nil { + return *v.ChainValid, true + } + if v.Cert != nil && v.Cert.ChainValid != nil { + return *v.Cert.ChainValid, true + } + return false, false +} + +// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil +// when the payload doesn't look like one. +// +// Two payload shapes are accepted: +// +// 1. {"probes": {"": , …}} — the current convention +// used by checker-tls. Each consumer picks its own probe via +// r.EndpointID so one observation does not leak into another's report. +// 2. — a single top-level probe object, kept for back-compat with +// callers that pre-date the keyed map and with unit-test fixtures. +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var keyed struct { + Probes map[string]tlsProbeView `json:"probes"` + } + if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { + if p, ok := keyed.Probes[r.EndpointID]; ok { + return &p + } + return nil + } + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +// TLSSummary is what the HTML report renders for each probed endpoint. +type TLSSummary struct { + Address string + TLSVersion string + Status string // "ok", "warn", "fail", "info" + Detail string + NotAfter time.Time + DaysRemaining int +} + +// tlsCallout captures a cross-checker issue we want to foreground in the +// "Action items" section of the HTML report. +type tlsCallout struct { + Severity string // "warn" or "crit" + Title string + Body string +} + +// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary +// for rendering, (2) callouts for the top of the report when there's anything +// actionable. Callers pass both through the reportData pipeline. +func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) { + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + sum := buildTLSSummary(v) + summaries = append(summaries, sum) + callouts = append(callouts, buildTLSCallouts(v, sum.Address)...) + } + return summaries, callouts +} + +func buildTLSSummary(v *tlsProbeView) TLSSummary { + s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"} + + if t, ok := v.certExpiry(); ok { + s.NotAfter = t + days := int(time.Until(t) / (24 * time.Hour)) + if v.Cert != nil && v.Cert.DaysRemaining != nil { + days = *v.Cert.DaysRemaining + } + s.DaysRemaining = days + switch { + case days < 0: + s.Status = "fail" + s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days) + case days < 14: + s.Status = "warn" + s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days) + default: + s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days) + } + } + + if ok, has := v.hostnameOK(); has && !ok { + s.Status = "fail" + s.Detail = "certificate does not cover the endpoint hostname" + } + if ok, has := v.chainOK(); has && !ok { + s.Status = "fail" + s.Detail = "certificate chain validation failed" + } + + // Explicit issues from the TLS checker outrank our inferred status. + for _, iss := range v.Issues { + sev := strings.ToLower(iss.Severity) + switch sev { + case "crit": + s.Status = "fail" + case "warn": + if s.Status != "fail" { + s.Status = "warn" + } + } + if iss.Message != "" { + s.Detail = iss.Message + } + } + return s +} + +func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout { + var out []tlsCallout + + // Structured issues from the TLS checker are the preferred source. + for _, iss := range v.Issues { + sev := strings.ToLower(iss.Severity) + if sev != "crit" && sev != "warn" { + continue + } + callout := tlsCallout{ + Severity: sev, + Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)), + } + if callout.Title == "TLS on "+addr+": " { + callout.Title = "TLS issue on " + addr + } + if iss.Fix != "" { + callout.Body = iss.Fix + } else { + callout.Body = "See the TLS checker report for details." + } + out = append(out, callout) + } + if len(out) > 0 { + return out + } + + // Fallback: synthesize callouts from structured flags. + if t, ok := v.certExpiry(); ok { + days := int(time.Until(t) / (24 * time.Hour)) + if v.Cert != nil && v.Cert.DaysRemaining != nil { + days = *v.Cert.DaysRemaining + } + switch { + case days < 0: + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Certificate on %s has expired", addr), + Body: fmt.Sprintf("Renew it — clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)), + }) + case days < 14: + out = append(out, tlsCallout{ + Severity: "warn", + Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days), + Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)), + }) + } + } + if ok, has := v.chainOK(); has && !ok { + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Broken certificate chain on %s", addr), + Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.", + }) + } + if ok, has := v.hostnameOK(); has && !ok { + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Certificate does not cover %s", addr), + Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.", + }) + } + return out +} diff --git a/internal/dav/tls_related_test.go b/internal/dav/tls_related_test.go new file mode 100644 index 0000000..e9f9124 --- /dev/null +++ b/internal/dav/tls_related_test.go @@ -0,0 +1,82 @@ +package dav + +import ( + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func relatedFrom(t *testing.T, payload any) sdk.RelatedObservation { + t.Helper() + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return sdk.RelatedObservation{Key: TLSRelatedKey, Data: b} +} + +func TestFoldTLSRelated_expiringCertProducesCallout(t *testing.T) { + exp := time.Now().Add(5 * 24 * time.Hour) + related := []sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", + "port": 443, + "not_after": exp, + })} + sums, callouts := foldTLSRelated(related) + + if len(sums) != 1 || sums[0].Address != "dav.example.com:443" || sums[0].Status != "warn" { + t.Fatalf("summary: %+v", sums) + } + if len(callouts) != 1 || callouts[0].Severity != "warn" { + t.Fatalf("expected a warn callout, got %+v", callouts) + } + if !strings.Contains(callouts[0].Title, "expires in") { + t.Errorf("callout title: %q", callouts[0].Title) + } +} + +func TestFoldTLSRelated_expiredCertCrit(t *testing.T) { + exp := time.Now().Add(-2 * 24 * time.Hour) + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, "not_after": exp, + })}) + if len(callouts) != 1 || callouts[0].Severity != "crit" { + t.Fatalf("expected crit for expired cert, got %+v", callouts) + } +} + +func TestFoldTLSRelated_chainInvalid(t *testing.T) { + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, "chain_valid": false, + })}) + if len(callouts) != 1 || callouts[0].Severity != "crit" { + t.Fatalf("expected crit for broken chain, got %+v", callouts) + } +} + +func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) { + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, + "chain_valid": false, // would normally synthesize a callout + "issues": []map[string]any{ + {"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"}, + }, + })}) + // When explicit issues exist, we do not also emit synthesized callouts — + // the TLS checker is the source of truth for severity and wording. + if len(callouts) != 1 || callouts[0].Severity != "warn" { + t.Fatalf("want single warn callout, got %+v", callouts) + } + if !strings.Contains(callouts[0].Body, "disable TLS") { + t.Errorf("fix text lost: %q", callouts[0].Body) + } +} + +func TestFoldTLSRelated_empty(t *testing.T) { + if sums, callouts := foldTLSRelated(nil); sums != nil || callouts != nil { + t.Errorf("expected nil,nil on nil input, got %+v %+v", sums, callouts) + } +}