package scheduler import ( "context" "encoding/base64" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) type retryResolver struct { calls atomic.Int32 recovered chan struct{} } func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result { call := r.calls.Add(1) if call == 1 { return ipresolve.Result{IPv4Err: errors.New("network is not ready")} } if call == 2 { close(r.recovered) } return ipresolve.Result{IPv4: "198.51.100.42"} } func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil } func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) { t.Parallel() ctx := context.Background() api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/zones/123/rrsets" || r.Header.Get("Authorization") != "Bearer test-token" { http.Error(w, "unexpected request", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "rrsets": [ {"name":"@","type":"A","ttl":60,"records":[{"value":"198.51.100.10","comment":"Apex"}]}, {"name":"home","type":"A","ttl":120,"records":[{"value":"198.51.100.11","comment":"Router"}]}, {"name":"home","type":"AAAA","ttl":120,"records":[{"value":"2001:db8::10","comment":"Router"}]}, {"name":"www","type":"CNAME","ttl":300,"records":[{"value":"example.de."}]} ], "meta":{"pagination":{"next_page":null}} }`)) })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } defer database.Close() keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) added, err := schedule.SyncZone(ctx, zone.ID) if err != nil || added != 2 { t.Fatalf("expected apex and home to be imported, added=%d err=%v", added, err) } targets, err := database.ListTargets(ctx, zone.ID) if err != nil || len(targets) != 2 { t.Fatalf("expected two address targets, got %#v err=%v", targets, err) } var homeID int64 for _, target := range targets { if target.RecordName == "home" { homeID = target.ID if !target.UpdateIPv4 || !target.UpdateIPv6 || target.Active || target.Description != "Router" || target.TTL != 120 { t.Fatalf("home target was not grouped safely: %#v", target) } } } if homeID == 0 { t.Fatal("home target missing") } cnames, err := database.ListCNAMERecords(ctx, zone.ID) if err != nil || len(cnames) != 1 || !cnames[0].Active || cnames[0].RecordName != "www" || cnames[0].Destination != "example.de." || cnames[0].TTL != 300 { t.Fatalf("CNAME was not imported: %#v err=%v", cnames, err) } home, err := database.GetTarget(ctx, homeID) if err != nil { t.Fatal(err) } home.TTL = 60 if err := database.UpdateTarget(ctx, home); err != nil { t.Fatal(err) } added, err = schedule.SyncZone(ctx, zone.ID) if err != nil || added != 0 { t.Fatalf("TTL refresh import failed: added=%d err=%v", added, err) } home, err = database.GetTarget(ctx, homeID) if err != nil || home.TTL != 120 { t.Fatalf("provider TTL was not refreshed on existing target: %#v err=%v", home, err) } if err := database.ArchiveTarget(ctx, homeID); err != nil { t.Fatal(err) } added, err = schedule.SyncZone(ctx, zone.ID) if err != nil || added != 0 { t.Fatalf("re-import should not restore archived target, added=%d err=%v", added, err) } linked, _ := database.ListTargets(ctx, zone.ID) archived, _ := database.ListArchivedTargets(ctx, zone.ID) if len(linked) != 1 || len(archived) != 1 || archived[0].ID != homeID { t.Fatalf("archive state changed during provider import: linked=%#v archived=%#v", linked, archived) } } func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) { t.Parallel() ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) settings, err := database.GetSettings(ctx) if err != nil { t.Fatal(err) } if err := database.UpdateSettings(ctx, settings.UpdateIntervalSeconds, false, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { t.Fatal(err) } schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil))) if _, err := schedule.Run(ctx); !errors.Is(err, ErrDisabled) { t.Fatalf("disabled updater accepted a run: %v", err) } runs, err := database.RecentRuns(ctx, 10) if err != nil || len(runs) != 0 { t.Fatalf("disabled run must not create history: %#v err=%v", runs, err) } schedule.nextRun.Store(time.Now().Add(time.Hour).Unix()) schedule.Trigger() if !schedule.NextRunAt().IsZero() { t.Fatalf("trigger did not reset the visible countdown: %v", schedule.NextRunAt()) } select { case <-schedule.wake: default: t.Fatal("trigger did not wake the scheduler") } } func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) { t.Parallel() database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) settings, err := database.GetSettings(context.Background()) if err != nil { t.Fatal(err) } if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { t.Fatal(err) } resolver := &retryResolver{recovered: make(chan struct{})} schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil))) schedule.resolver = resolver schedule.retryMin = 5 * time.Millisecond schedule.retryMax = 20 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) schedule.Start(ctx) select { case <-resolver.recovered: case <-time.After(2 * time.Second): t.Fatal("scheduler did not retry the failed startup resolution") } deadline := time.Now().Add(time.Second) for { settings, err = database.GetSettings(context.Background()) if err != nil { t.Fatal(err) } if settings.LastIPv4 == "198.51.100.42" { break } if time.Now().After(deadline) { t.Fatalf("successful retry did not persist IPv4: %#v", settings) } time.Sleep(5 * time.Millisecond) } runs, err := database.RecentRuns(context.Background(), 10) if err != nil { t.Fatal(err) } if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" { t.Fatalf("unexpected startup retry history: %#v", runs) } } func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) { t.Parallel() schedule := &Scheduler{retryMin: 5 * time.Second} tests := []struct { failures int want time.Duration }{ {failures: 1, want: 5 * time.Second}, {failures: 2, want: 10 * time.Second}, {failures: 3, want: 20 * time.Second}, {failures: 4, want: 40 * time.Second}, {failures: 5, want: time.Minute}, {failures: 20, want: time.Minute}, } for _, test := range tests { if got := schedule.retryDelay(test.failures, time.Minute); got != test.want { t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want) } } } func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) { t.Parallel() schedule := &Scheduler{} changed, status, err := schedule.syncTarget( context.Background(), "unused-token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", UpdateIPv6: true}, nil, "", "", false, ) if err != nil || changed || status != "AAAA global deaktiviert" { t.Fatalf("disabled IPv6 was not skipped cleanly: changed=%v status=%q err=%v", changed, status, err) } } func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) { t.Parallel() ctx := context.Background() changes := make(chan int, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/zones/123/rrsets": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}],"meta":{"pagination":{"next_page":null}}}`)) case "/zones/123/rrsets/home/A/actions/change_ttl": var body struct { TTL int `json:"ttl"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } changes <- body.TTL w.WriteHeader(http.StatusCreated) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) if err := schedule.SyncTargetTTL(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, TTL: 300}); err != nil { t.Fatalf("sync target TTL: %v", err) } select { case ttl := <-changes: if ttl != 300 { t.Fatalf("unexpected changed TTL: %d", ttl) } default: t.Fatal("TTL action was not called") } } func TestSyncStaticTargetWritesManualAddress(t *testing.T) { t.Parallel() ctx := context.Background() actions := make(chan string, 2) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/zones/123/rrsets": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11","comment":"Old"}]}],"meta":{"pagination":{"next_page":null}}}`)) case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records": actions <- r.URL.Path w.WriteHeader(http.StatusCreated) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) changed, status, err := schedule.SyncStaticTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, StaticIPv4: "203.0.113.25", Description: "Manual", TTL: 300}) if err != nil || !changed || status != "A aktualisiert ยท TTL aktualisiert" { t.Fatalf("static target was not synchronized: changed=%v status=%q err=%v", changed, status, err) } seen := map[string]bool{} for range 2 { seen[<-actions] = true } for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records"} { if !seen[expected] { t.Errorf("missing static update action %s", expected) } } } func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) { t.Parallel() ctx := context.Background() actions := make(chan string, 2) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/zones/123/rrsets": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}],"meta":{"pagination":{"next_page":null}}}`)) case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl": actions <- r.URL.Path w.WriteHeader(http.StatusCreated) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) changed, err := schedule.SyncZoneTTL(ctx, zone.ID, []model.Target{{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true}}, []model.CNAMERecord{{ZoneID: zone.ID, Active: true, RecordName: "www"}}, 600) if err != nil || changed != 2 { t.Fatalf("zone TTL was not synchronized: changed=%d err=%v", changed, err) } seen := map[string]bool{} for range 2 { seen[<-actions] = true } for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl"} { if !seen[expected] { t.Errorf("missing bulk TTL action %s", expected) } } } func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) { t.Parallel() ctx := context.Background() deletions := make(chan string, 2) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/zones/123/rrsets": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}],"meta":{"pagination":{"next_page":null}}}`)) case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA": if r.Method != http.MethodDelete { http.Error(w, "unexpected method", http.StatusBadRequest) return } deletions <- r.URL.Path w.WriteHeader(http.StatusNoContent) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } target := model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, UpdateIPv6: true} schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) deleted, err := schedule.DeleteTargetFamilies(ctx, target, true, false) if err != nil || deleted != 1 { t.Fatalf("A record was not deleted: deleted=%d err=%v", deleted, err) } deleted, err = schedule.DeleteIPv6Targets(ctx, []model.Target{target}) if err != nil || deleted != 1 { t.Fatalf("global AAAA cleanup failed: deleted=%d err=%v", deleted, err) } seen := map[string]bool{} for range 2 { seen[<-deletions] = true } for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA"} { if !seen[expected] { t.Errorf("missing delete request %s", expected) } } } func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) { t.Parallel() ctx := context.Background() deletions := make(chan string, 4) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/zones/123/rrsets": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"rrsets":[ {"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}, {"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}, {"name":"legacy","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::12"}]}, {"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}, {"name":"mail","type":"CNAME","ttl":300,"records":[{"value":"external.example."}]}, {"name":"@","type":"TXT","ttl":300,"records":[{"value":"unmanaged"}]} ],"meta":{"pagination":{"next_page":null}}}`)) case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME": if r.Method != http.MethodDelete { http.Error(w, "unexpected method", http.StatusBadRequest) return } deletions <- r.URL.Path w.WriteHeader(http.StatusNoContent) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer api.Close() dir := t.TempDir() database, err := store.Open(ctx, filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) keyFile := filepath.Join(dir, "master-key") key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { t.Fatal(err) } cipher, err := secure.LoadCipher(keyFile) if err != nil { t.Fatal(err) } ciphertext, nonce, err := cipher.Encrypt("test-token") if err != nil { t.Fatal(err) } credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) if err != nil { t.Fatal(err) } zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") if err != nil { t.Fatal(err) } schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) deleted, err := schedule.DeleteZoneRecords( ctx, zone.ID, []model.Target{{RecordName: "home"}, {RecordName: "legacy"}}, []model.CNAMERecord{{RecordName: "www"}}, ) if err != nil || deleted != 4 { t.Fatalf("managed zone records were not deleted: deleted=%d err=%v", deleted, err) } seen := map[string]bool{} for range 4 { seen[<-deletions] = true } for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME"} { if !seen[expected] { t.Errorf("missing delete request %s", expected) } } }