mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat(health): add speaker_clock check with set_clock quick-fix
A speaker with a wrong clock fails TLS to any HTTPS host because the certificate appears not-yet-valid or expired (the CURL ErrorCode 60 seen in #345, where the failing speaker had a wrong clock, the only one of several speakers that was off, with a failing NTP sync; these speakers default to the year 2000 at boot until NTP succeeds). Nothing surfaced this before. The check reads each speaker's /clockTime and compares its UTC epoch to the service's epoch. Using the epoch (ClockTime.GetUTC, not GetTime) keeps the comparison timezone-independent. Tiers: under 60s no finding; 60s-5m info; 5m-24h warning; 24h-or-more, or a time outside the year 2000..2100 plausibility window, error. Findings note a stale or missing NTP sync. A set_clock quick-fix on the warning and error findings pushes the current time to the speaker via POST /clockTime (client.SetClockTime). That call is plain HTTP on :8090, so it works regardless of the speaker's wrong clock or TLS state. It is a band-aid: if NTP is still failing the clock drifts again and resets on reboot, so the confirm dialog and success message point at restoring time sync as the durable fix. An SSH set-clock fallback is left for later since the HTTP path is confirmed on firmware 27. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c466246dee
commit
3b3cec7e94
@@ -150,6 +150,28 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
health.RegisterSpeakerCABundleCheck(s.healthRegistry, ds, func(deviceIP string) (string, string, bool) {
|
||||
return s.sm.ProbeCABundles(deviceIP)
|
||||
})
|
||||
health.RegisterSpeakerClockCheck(s.healthRegistry, ds, func(ip string) (int64, int64, bool) {
|
||||
cfg := client.DefaultConfig()
|
||||
cfg.Host = ip
|
||||
cfg.Timeout = 5 * time.Second
|
||||
|
||||
c := client.NewClient(cfg)
|
||||
ct, err := c.GetClockTime()
|
||||
|
||||
if err != nil || ct == nil || ct.GetUTC() == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return ct.GetUTC(), ct.GetUTCSyncTime(), true
|
||||
}, func(ip string) error {
|
||||
cfg := client.DefaultConfig()
|
||||
cfg.Host = ip
|
||||
cfg.Timeout = 5 * time.Second
|
||||
|
||||
c := client.NewClient(cfg)
|
||||
|
||||
return c.SetClockTime(models.NewClockTimeRequest(time.Now()))
|
||||
})
|
||||
health.RegisterServerURLReachableCheck(s.healthRegistry, func() string {
|
||||
serverURL, _ := s.GetSettings()
|
||||
return serverURL
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckIDSpeakerClock is the registry ID of the speaker clock skew check.
|
||||
const CheckIDSpeakerClock = "speaker_clock"
|
||||
|
||||
// clockPlausibilityMin is the lower bound of the plausibility window (year 2000).
|
||||
// Matches the lower bound used by ClockTimeRequest.Validate in pkg/models/clocktime.go.
|
||||
const clockPlausibilityMin int64 = 946684800
|
||||
|
||||
// clockPlausibilityMax is the upper bound of the plausibility window (year 2100).
|
||||
// Matches the upper bound used by ClockTimeRequest.Validate in pkg/models/clocktime.go.
|
||||
const clockPlausibilityMax int64 = 4102444800
|
||||
|
||||
// clockStaleNTPThreshold is how long after the last NTP sync before we flag it
|
||||
// as stale.
|
||||
const clockStaleNTPThreshold = time.Hour
|
||||
|
||||
// setClockQuickFix is the QuickFix descriptor for the set_clock action.
|
||||
// Attached to Warning and Error findings only (Info is harmless sub-5-minute drift).
|
||||
var setClockQuickFix = QuickFix{
|
||||
ID: "set_clock",
|
||||
Label: "Set clock from AfterTouch",
|
||||
Confirm: "Sets the speaker's clock to this server's current time. " +
|
||||
"If the speaker's NTP sync is failing, the clock may drift again or reset on reboot " +
|
||||
"— restoring time sync is the durable fix.",
|
||||
}
|
||||
|
||||
// RegisterSpeakerClockCheck registers a per-device health check that
|
||||
// compares each speaker's UTC epoch to the service's UTC epoch and surfaces
|
||||
// findings when the skew is large enough to cause TLS certificate failures.
|
||||
//
|
||||
// clockFn must return the speaker's clock as epoch seconds (utc), its last
|
||||
// NTP sync epoch (sync), and ok=false when /clockTime could not be read or
|
||||
// had no usable UTC time.
|
||||
//
|
||||
// setFn sets the clock on the speaker at the given IP. It is called by the
|
||||
// set_clock QuickFix. Passing nil disables the fix registration.
|
||||
func RegisterSpeakerClockCheck(
|
||||
r *Registry,
|
||||
ds *datastore.DataStore,
|
||||
clockFn func(deviceIP string) (utc, sync int64, ok bool),
|
||||
setFn func(deviceIP string) error,
|
||||
) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSpeakerClock,
|
||||
Title: "Speaker clock accuracy",
|
||||
Run: func() []Finding {
|
||||
return runSpeakerClockCheck(ds, clockFn, time.Now())
|
||||
},
|
||||
})
|
||||
|
||||
if setFn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.RegisterFix(CheckIDSpeakerClock, "set_clock", func(target Target) (string, error) {
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not enumerate devices: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
ip string
|
||||
devName string
|
||||
)
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == target.Device {
|
||||
ip = devices[i].IPAddress
|
||||
devName = displayName(devices[i].Name, devices[i].DeviceID)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ip == "" {
|
||||
return "", fmt.Errorf("device %q not found or has no IP address", target.Device)
|
||||
}
|
||||
|
||||
if err := setFn(ip); err != nil {
|
||||
return "", fmt.Errorf("set clock on %s: %w", devName, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Set clock on %s to %s. "+
|
||||
"If NTP is still failing the clock may drift again or reset on reboot "+
|
||||
"— restoring time sync is the durable fix.",
|
||||
devName, now,
|
||||
), nil
|
||||
})
|
||||
}
|
||||
|
||||
func runSpeakerClockCheck(
|
||||
ds *datastore.DataStore,
|
||||
clockFn func(deviceIP string) (utc, sync int64, ok bool),
|
||||
now time.Time,
|
||||
) []Finding {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityError,
|
||||
Message: "Could not enumerate devices: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
utc, sync, ok := clockFn(dev.IPAddress)
|
||||
if !ok {
|
||||
// Reachability is speaker_info_reachable's job; do not duplicate.
|
||||
continue
|
||||
}
|
||||
|
||||
target := Target{Account: dev.AccountID, Device: dev.DeviceID}
|
||||
name := displayName(dev.Name, dev.DeviceID)
|
||||
|
||||
skew := now.Unix() - utc
|
||||
|
||||
abs := skew
|
||||
if abs < 0 {
|
||||
abs = -abs
|
||||
}
|
||||
|
||||
// Build common details lines for enrichment.
|
||||
speakerTimeStr := time.Unix(utc, 0).UTC().Format(time.RFC3339)
|
||||
serviceTimeStr := now.UTC().Format(time.RFC3339)
|
||||
|
||||
var skewSign string
|
||||
if skew >= 0 {
|
||||
skewSign = fmt.Sprintf("+%ds", skew)
|
||||
} else {
|
||||
skewSign = fmt.Sprintf("%ds", skew)
|
||||
}
|
||||
|
||||
details := fmt.Sprintf(
|
||||
"Speaker UTC: %s. Service UTC: %s. Signed skew: %s.",
|
||||
speakerTimeStr, serviceTimeStr, skewSign,
|
||||
)
|
||||
|
||||
// NTP staleness note.
|
||||
ntpNote := ""
|
||||
if sync == 0 {
|
||||
ntpNote = " Speaker has never successfully synced with an NTP server — NTP is likely failing."
|
||||
} else if now.Unix()-sync > int64(clockStaleNTPThreshold.Seconds()) {
|
||||
lastSync := time.Unix(sync, 0).UTC().Format(time.RFC3339)
|
||||
ntpNote = fmt.Sprintf(" Speaker's last NTP sync was %s (more than 1 hour ago) — NTP is likely failing.", lastSync)
|
||||
}
|
||||
|
||||
if ntpNote != "" {
|
||||
details += ntpNote
|
||||
}
|
||||
|
||||
remediation := " Remediation: ensure the speaker can reach an NTP server" +
|
||||
" (UDP/123 outbound to a reachable time server). AfterTouch can also" +
|
||||
" push the current time via /clockTime (soundtouch-cli set-clock-time) if needed."
|
||||
details += remediation
|
||||
|
||||
// Determine tier. The plausibility check is applied regardless of abs
|
||||
// magnitude: a speaker claiming year 2000 or year 2101 is an Error even
|
||||
// if the arithmetic skew happens to be small.
|
||||
outsidePlausibility := utc < clockPlausibilityMin || utc > clockPlausibilityMax
|
||||
|
||||
switch {
|
||||
case outsidePlausibility || abs >= int64(24*time.Hour/time.Second):
|
||||
var msg string
|
||||
if outsidePlausibility {
|
||||
msg = fmt.Sprintf(
|
||||
"Device %s: speaker clock is implausible (speaker reads %s, service is %s)."+
|
||||
" HTTPS/TLS will fail: certificates appear not-yet-valid or expired,"+
|
||||
" so TuneIn/BMX content and any HTTPS source will break until time sync is restored.",
|
||||
name, speakerTimeStr, serviceTimeStr,
|
||||
)
|
||||
} else {
|
||||
msg = fmt.Sprintf(
|
||||
"Device %s: speaker clock is far off (speaker reads %s, service is %s)."+
|
||||
" HTTPS/TLS will fail: certificates appear not-yet-valid or expired,"+
|
||||
" so TuneIn/BMX content and any HTTPS source will break until time sync is restored.",
|
||||
name, speakerTimeStr, serviceTimeStr,
|
||||
)
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityError,
|
||||
Target: target,
|
||||
Message: msg,
|
||||
Details: details,
|
||||
QuickFixes: []QuickFix{setClockQuickFix},
|
||||
})
|
||||
|
||||
case abs >= int64(5*time.Minute/time.Second):
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Device %s: clock is off by %s; may cause intermittent HTTPS/TLS failures.",
|
||||
name, formatDuration(abs),
|
||||
),
|
||||
Details: details,
|
||||
QuickFixes: []QuickFix{setClockQuickFix},
|
||||
})
|
||||
|
||||
case abs >= 60:
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityInfo,
|
||||
Target: target,
|
||||
Message: fmt.Sprintf(
|
||||
"Device %s: minor clock drift of %s; NTP sync may be loose.",
|
||||
name, formatDuration(abs),
|
||||
),
|
||||
Details: details,
|
||||
})
|
||||
|
||||
default:
|
||||
// abs < 60s: healthy, no finding.
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// formatDuration renders a duration (given as unsigned seconds) as a
|
||||
// human-readable string (e.g. "10m30s", "2h5m", "33d").
|
||||
func formatDuration(secs int64) string {
|
||||
days := secs / 86400
|
||||
rem := secs % 86400
|
||||
hours := rem / 3600
|
||||
rem %= 3600
|
||||
minutes := rem / 60
|
||||
seconds := rem % 60
|
||||
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dd%dh", days, hours)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
|
||||
if hours > 0 {
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
|
||||
if minutes > 0 {
|
||||
if seconds > 0 {
|
||||
return fmt.Sprintf("%dm%ds", minutes, seconds)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// newSpeakerClockDatastore creates a temporary datastore populated with a
|
||||
// single device. ip may be "" to test the empty-IP skip behaviour.
|
||||
func newSpeakerClockDatastore(t *testing.T, accountID, deviceID, ip string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "speaker-clock-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Speaker-" + deviceID,
|
||||
IPAddress: ip,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func TestSpeakerClock_InSync(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0) // fixed reference: 2025-06-03 00:00:00 UTC
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 5, now.Unix() - 30, true // 5s skew, recent NTP sync
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for in-sync clock, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_InfoTier_90sSkew(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 90, now.Unix() - 30, true
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding for 90s skew, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityInfo {
|
||||
t.Errorf("expected SeverityInfo, got %s", got[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_WarningTier_10mSkew(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 600, now.Unix() - 30, true // 10 minutes
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding for 10m skew, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityWarning {
|
||||
t.Errorf("expected SeverityWarning, got %s", got[0].Severity)
|
||||
}
|
||||
if !strings.Contains(got[0].Message, "10m") {
|
||||
t.Errorf("expected duration in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_ErrorTier_33daysSkew(t *testing.T) {
|
||||
// The #345 case: speaker clock ~33 days behind service time.
|
||||
now := time.Unix(1748908800, 0)
|
||||
skewSecs := int64(33 * 24 * 60 * 60)
|
||||
speakerUTC := now.Unix() - skewSecs
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "606405FE97AE", "192.0.2.20")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return speakerUTC, 0, true // sync==0 means never NTP-synced
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding for 33d skew, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityError {
|
||||
t.Errorf("expected SeverityError, got %s", got[0].Severity)
|
||||
}
|
||||
|
||||
// Message must name both the speaker time and the service time.
|
||||
speakerTimeStr := time.Unix(speakerUTC, 0).UTC().Format(time.RFC3339)
|
||||
serviceTimeStr := now.UTC().Format(time.RFC3339)
|
||||
|
||||
if !strings.Contains(got[0].Message, speakerTimeStr) {
|
||||
t.Errorf("expected speaker time %q in message, got %q", speakerTimeStr, got[0].Message)
|
||||
}
|
||||
if !strings.Contains(got[0].Message, serviceTimeStr) {
|
||||
t.Errorf("expected service time %q in message, got %q", serviceTimeStr, got[0].Message)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(got[0].Message), "tls") {
|
||||
t.Errorf("expected TLS mention in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_ErrorTier_PlausibilityWindow(t *testing.T) {
|
||||
// Speaker epoch is just inside year 2000 boundary (but within a minute of it),
|
||||
// which is outside the plausibility window.
|
||||
now := time.Unix(1748908800, 0)
|
||||
speakerUTC := int64(946684800 + 60) // year 2000 + 60s: inside window but barely
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return speakerUTC, 0, true
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one error finding for year-2000 epoch, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityError {
|
||||
t.Errorf("expected SeverityError (>24h skew), got %s", got[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_ErrorTier_BeforePlausibilityMin(t *testing.T) {
|
||||
// Epoch before year 2000 (e.g. Unix 0 = 1970).
|
||||
now := time.Unix(1748908800, 0)
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return 0, 0, true // epoch 0 = 1970, outside plausibility window
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one error finding for epoch 0, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityError {
|
||||
t.Errorf("expected SeverityError for implausible epoch, got %s", got[0].Severity)
|
||||
}
|
||||
if !strings.Contains(got[0].Message, "implausible") {
|
||||
t.Errorf("expected 'implausible' in message, got %q", got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_OkFalse_Skipped(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return 0, 0, false // unreachable
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings when clockFn returns ok=false, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_EmptyIP_Skipped(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "") // empty IP
|
||||
|
||||
called := false
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
called = true
|
||||
return now.Unix(), now.Unix() - 30, true
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no findings for device with empty IP, got %+v", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("clockFn should not be called for a device with empty IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_StaleNTPSync_DetailsNote(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
// Skew of 90s (Info tier) but NTP sync was 2 hours ago.
|
||||
lastSync := now.Unix() - int64(2*time.Hour/time.Second)
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 90, lastSync, true
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Details, "NTP is likely failing") {
|
||||
t.Errorf("expected NTP staleness note in Details, got %q", got[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_ZeroSync_DetailsNote(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
// Skew of 90s (Info tier) with zero sync (never synced).
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 90, 0, true // sync == 0
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Details, "never") {
|
||||
t.Errorf("expected 'never' in NTP staleness note in Details, got %q", got[0].Details)
|
||||
}
|
||||
if !strings.Contains(got[0].Details, "NTP is likely failing") {
|
||||
t.Errorf("expected 'NTP is likely failing' in Details, got %q", got[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
// --- QuickFix descriptor tests ---
|
||||
|
||||
func TestSpeakerClock_ErrorFinding_CarriesSetClockQuickFix(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
skewSecs := int64(33 * 24 * 60 * 60)
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - skewSecs, 0, true
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityError {
|
||||
t.Fatalf("expected SeverityError, got %s", got[0].Severity)
|
||||
}
|
||||
|
||||
hasSetClock := false
|
||||
for _, qf := range got[0].QuickFixes {
|
||||
if qf.ID == "set_clock" {
|
||||
hasSetClock = true
|
||||
}
|
||||
}
|
||||
if !hasSetClock {
|
||||
t.Errorf("expected set_clock QuickFix on Error finding, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_WarningFinding_CarriesSetClockQuickFix(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 600, now.Unix() - 30, true // 10 minutes skew
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityWarning {
|
||||
t.Fatalf("expected SeverityWarning, got %s", got[0].Severity)
|
||||
}
|
||||
|
||||
hasSetClock := false
|
||||
for _, qf := range got[0].QuickFixes {
|
||||
if qf.ID == "set_clock" {
|
||||
hasSetClock = true
|
||||
}
|
||||
}
|
||||
if !hasSetClock {
|
||||
t.Errorf("expected set_clock QuickFix on Warning finding, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_InfoFinding_NoSetClockQuickFix(t *testing.T) {
|
||||
now := time.Unix(1748908800, 0)
|
||||
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return now.Unix() - 90, now.Unix() - 30, true // 90s skew = Info tier
|
||||
}
|
||||
|
||||
got := runSpeakerClockCheck(ds, clockFn, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
if got[0].Severity != SeverityInfo {
|
||||
t.Fatalf("expected SeverityInfo, got %s", got[0].Severity)
|
||||
}
|
||||
|
||||
for _, qf := range got[0].QuickFixes {
|
||||
if qf.ID == "set_clock" {
|
||||
t.Errorf("Info finding must not carry set_clock QuickFix, but got one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- RunFix dispatch tests ---
|
||||
|
||||
// newSpeakerClockRegistry creates a Registry with RegisterSpeakerClockCheck
|
||||
// wired up using the provided setFn stub.
|
||||
func newSpeakerClockRegistry(t *testing.T, ds *datastore.DataStore, setFn func(string) error) *Registry {
|
||||
t.Helper()
|
||||
|
||||
r := NewRegistry()
|
||||
clockFn := func(string) (int64, int64, bool) {
|
||||
return time.Now().Unix(), time.Now().Unix() - 30, true
|
||||
}
|
||||
RegisterSpeakerClockCheck(r, ds, clockFn, setFn)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSpeakerClock_RunFix_Success(t *testing.T) {
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
var calledIP string
|
||||
setFn := func(ip string) error {
|
||||
calledIP = ip
|
||||
return nil
|
||||
}
|
||||
|
||||
r := newSpeakerClockRegistry(t, ds, setFn)
|
||||
|
||||
msg, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "DEVICE01"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calledIP != "192.0.2.10" {
|
||||
t.Errorf("setFn called with IP %q, want 192.0.2.10", calledIP)
|
||||
}
|
||||
if !strings.Contains(msg, "Set clock on") {
|
||||
t.Errorf("expected 'Set clock on' in message, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "restoring time sync is the durable fix") {
|
||||
t.Errorf("expected NTP band-aid note in message, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_RunFix_SetFnError_Propagates(t *testing.T) {
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
setFn := func(string) error {
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
|
||||
r := newSpeakerClockRegistry(t, ds, setFn)
|
||||
|
||||
_, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "DEVICE01"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failing setFn, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Errorf("expected original error in message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerClock_RunFix_UnknownDevice_ReturnsError(t *testing.T) {
|
||||
ds := newSpeakerClockDatastore(t, "1000001", "DEVICE01", "192.0.2.10")
|
||||
|
||||
setFn := func(string) error { return nil }
|
||||
|
||||
r := newSpeakerClockRegistry(t, ds, setFn)
|
||||
|
||||
_, _, err := r.RunFix(CheckIDSpeakerClock, "set_clock", Target{Device: "NOSUCHDEVICE"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown device, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("expected 'not found' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user