Files
config/watcher_hardening_test.go

292 lines
7.4 KiB
Go

package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func manualWatcher(t *testing.T, c *Config, path string) *watcher {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
w := &watcher{ctx: ctx, cancel: cancel, opts: WatchOptions{ReloadTimeout: time.Second, MaxWatchers: 10}, filePath: path, watchers: make(map[int64]chan string), read: readConfigFile}
w.watching.Store(true)
c.mutex.Lock()
c.watcher = w
c.mutex.Unlock()
t.Cleanup(c.StopAutoUpdate)
return w
}
func watcherFixture(t *testing.T) (*Config, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("value=1\n"), 0600); err != nil {
t.Fatal(err)
}
c := New()
if err := c.Register("value", uint8(0)); err != nil {
t.Fatal(err)
}
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
return c, path
}
func TestReloadCannotPublishAfterStopTimeoutOrNewLoad(t *testing.T) {
for _, mode := range []string{"stop", "timeout", "new-load"} {
t.Run(mode, func(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
staged, err := readConfigFile(context.Background(), path, fileSettings{})
if err != nil {
t.Fatal(err)
}
w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) {
switch mode {
case "stop":
c.StopAutoUpdate()
case "timeout":
<-ctx.Done()
case "new-load":
if err := os.WriteFile(path, []byte("value=3\n"), 0600); err != nil {
t.Fatal(err)
}
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
}
return staged, nil // Deliberately complete a stale read successfully.
}
if mode == "timeout" {
w.opts.ReloadTimeout = time.Millisecond
}
w.checkAndReload(c)
want := int64(1)
if mode == "new-load" {
want = 3
}
if value, _ := c.Get("value"); value != want {
t.Fatalf("stale publication: %v want %v", value, want)
}
if mode == "timeout" {
select {
case event := <-ch:
if event != EventReloadTimeout {
t.Fatal(event)
}
default:
t.Fatal("timeout event missing")
}
}
if mode == "stop" {
if _, ok := <-ch; ok {
t.Fatal("subscriber not closed by stop")
}
}
})
}
}
func TestWatcherDebouncesErrorsAndRecovers(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.Debounce = time.Hour
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value="), 0600); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
select {
case event := <-ch:
t.Fatalf("transient error emitted: %s", event)
default:
}
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
w.observedAt = time.Now().Add(-2 * time.Hour)
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal(value)
}
if event := <-ch; event != "value" {
t.Fatal(event)
}
// A stable invalid edit is reported once and leaves the old value intact.
writeTestFile(t, path, []byte("value=256\n"), 0600)
w.checkAndReload(c)
w.observedAt = time.Now().Add(-2 * time.Hour)
w.checkAndReload(c)
w.checkAndReload(c)
select {
case <-ch:
default:
t.Fatal("missing invalid edit event")
}
select {
case event := <-ch:
t.Fatalf("duplicate invalid edit: %s", event)
default:
}
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("invalid value published")
}
}
func TestWatcherRetriesTimedOutUnchangedContent(t *testing.T) {
for _, mode := range []string{"read-error", "late-result"} {
t.Run(mode, func(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.ReloadTimeout = 20 * time.Millisecond
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
staged, err := readConfigFile(context.Background(), path, fileSettings{})
if err != nil {
t.Fatal(err)
}
attempts := 0
w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) {
attempts++
if attempts <= 2 {
<-ctx.Done()
if mode == "read-error" {
return nil, ctx.Err()
}
}
return staged, nil
}
w.checkAndReload(c)
select {
case event := <-ch:
if event != EventReloadTimeout {
t.Fatal(event)
}
default:
t.Fatal("timeout event missing")
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(1) {
t.Fatal("timed-out result published")
}
select {
case event := <-ch:
t.Fatalf("duplicate timeout: %s", event)
default:
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("unchanged content was not retried after timeout")
}
select {
case event := <-ch:
if event != "value" {
t.Fatal(event)
}
default:
t.Fatal("recovery event missing")
}
})
}
}
func TestWatcherSameMetadataReplacementAndRecreation(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
ch := w.subscribe()
w.checkAndReload(c)
info, _ := os.Stat(path)
replacement := path + ".new"
writeTestFile(t, replacement, []byte("value=2\n"), 0600)
_ = os.Chtimes(replacement, info.ModTime(), info.ModTime())
if err := os.Rename(replacement, path); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("replacement missed")
}
if event := <-ch; event != "value" {
t.Fatal(event)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
w.checkAndReload(c)
if event := <-ch; event != EventFileDeleted {
t.Fatal(event)
}
select {
case event := <-ch:
t.Fatalf("repeated deletion: %s", event)
default:
}
writeTestFile(t, path, []byte("value=3\n"), 0600)
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(3) {
t.Fatal("recreation missed")
}
c.StopAutoUpdate()
for range ch {
} // Stop closes every subscriber synchronously.
c.AutoUpdateWithOptions(WatchOptions{PollInterval: time.Hour})
if !c.IsWatching() {
t.Fatal("restart failed")
}
}
func TestBuilderDoesNotHideInvalidSourceBehindMissingFile(t *testing.T) {
var target struct {
Port uint8 `toml:"port"`
}
c, err := NewBuilder().WithTarget(&target).WithFile(filepath.Join(t.TempDir(), "missing.toml")).WithArgs([]string{"--port=256"}).Build()
if err == nil || c != nil {
t.Fatalf("missing file hid invalid CLI: %v", err)
}
}
// A mode change is reported once; the next edit applies under the new mode
func TestWatcherReportsPermissionChangeOnce(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.VerifyPermissions = true
ch := w.subscribe()
for i, step := range []struct {
edit func() error
event string
value int64
}{
{func() error { return os.WriteFile(path, []byte("value=2\n"), 0600) }, "value", 2},
{func() error { return os.Chmod(path, 0640) }, EventPermissionsChanged, 2},
{func() error { return os.WriteFile(path, []byte("value=3\n"), 0640) }, "value", 3},
} {
if err := step.edit(); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
select {
case event := <-ch:
if event != step.event {
t.Fatalf("step %d: event %s, want %s", i, event, step.event)
}
default:
t.Fatalf("step %d: no event", i)
}
if value, _ := c.Get("value"); value != step.value {
t.Fatalf("step %d: value %v, want %d", i, value, step.value)
}
}
}