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:
Tobias Gesellchen
2026-06-03 23:29:15 +02:00
co-authored by Claude Opus 4.8
parent c466246dee
commit 3b3cec7e94
3 changed files with 695 additions and 0 deletions
+22
View File
@@ -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
+275
View File
@@ -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())
}
}