package systemtime import ( "context" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" ) type recordedCommand struct { name string args []string } type fakeCommandRunner struct { commands []recordedCommand failAt int } func (r *fakeCommandRunner) Run(_ context.Context, name string, args ...string) error { r.commands = append(r.commands, recordedCommand{name: name, args: append([]string(nil), args...)}) if r.failAt > 0 && len(r.commands) == r.failAt { return errors.New("command failed") } return nil } func TestOSNTPApplierSynchronizesAndLeavesDaemonRunning(t *testing.T) { root := t.TempDir() configPath := filepath.Join(root, "etc", "ntp.conf") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(configPath, []byte("server old.example iburst\n"), 0o640); err != nil { t.Fatal(err) } applier, err := NewOSNTPApplier(configPath, filepath.Join(root, "S49ntp"), filepath.Join(root, "ntpd")) if err != nil { t.Fatal(err) } runner := &fakeCommandRunner{} applier.runner = runner appliedAt := time.Date(2026, 7, 30, 7, 1, 2, 0, time.UTC) applier.clock = func() time.Time { return appliedAt } got, err := applier.SyncNTP(context.Background(), []string{"ntp.kriss.re.kr", "time.bora.net"}) if err != nil { t.Fatalf("SyncNTP() error = %v", err) } if got != appliedAt { t.Fatalf("SyncNTP() time = %v, want %v", got, appliedAt) } wantCommands := []recordedCommand{ {name: filepath.Join(root, "S49ntp"), args: []string{"stop"}}, {name: filepath.Join(root, "ntpd"), args: []string{"-g", "-q", "-c", configPath}}, {name: filepath.Join(root, "S49ntp"), args: []string{"start"}}, } if !reflect.DeepEqual(runner.commands, wantCommands) { t.Fatalf("commands = %#v, want %#v", runner.commands, wantCommands) } content, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } text := string(content) if !strings.Contains(text, "server ntp.kriss.re.kr iburst") || !strings.Contains(text, "server time.bora.net iburst") { t.Fatalf("NTP config = %q", text) } } func TestOSNTPApplierRestoresConfigurationWhenSyncFails(t *testing.T) { root := t.TempDir() configPath := filepath.Join(root, "etc", "ntp.conf") if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } before := []byte("server old.example iburst\n") if err := os.WriteFile(configPath, before, 0o640); err != nil { t.Fatal(err) } applier, err := NewOSNTPApplier(configPath, filepath.Join(root, "S49ntp"), filepath.Join(root, "ntpd")) if err != nil { t.Fatal(err) } runner := &fakeCommandRunner{failAt: 2} applier.runner = runner applier.clock = func() time.Time { return time.Date(2026, 7, 30, 7, 1, 2, 0, time.UTC) } if _, err := applier.SyncNTP(context.Background(), []string{"ntp.kriss.re.kr"}); err == nil { t.Fatal("SyncNTP() succeeded, want failure") } after, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(after, before) { t.Fatalf("restored config = %q, want %q", after, before) } if len(runner.commands) != 3 || runner.commands[2].args[0] != "start" { t.Fatalf("rollback commands = %#v", runner.commands) } } func TestBuildNTPConfigCannotContainLineInjection(t *testing.T) { _, err := NormalizeNTPRequest(NTPRequest{Servers: []string{"ntp.example\nserver evil"}, Timezone: "UTC"}) if err == nil { t.Fatal("NormalizeNTPRequest() accepted a line break") } }