Files
hetzner-api-dyndns-records-…/internal/scheduler/scheduler_test.go
T

821 lines
28 KiB
Go

package scheduler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"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 resolverFunc func(context.Context) ipresolve.Result
func (f resolverFunc) ResolveWithIPv6(ctx context.Context, _, _ []string, _ bool) ipresolve.Result {
return f(ctx)
}
func (f resolverFunc) ValidateEndpoint(context.Context, string) error { return nil }
func TestOneFailedFamilyDoesNotBlockTheOther(t *testing.T) {
for _, protectedFamily := range []string{"A", "AAAA"} {
t.Run(protectedFamily, func(t *testing.T) {
var writes atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/"+protectedFamily+"/") {
t.Error("wrote protected family")
}
writes.Add(1)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()
ttl := 60
rrsets := map[string]hetzner.RRSet{}
for _, family := range []string{"A", "AAAA"} {
rrset := hetzner.RRSet{Name: "home", Type: family, TTL: &ttl, Records: []hetzner.Record{{Value: "old"}}}
rrset.Protection.Change = family == protectedFamily
rrsets[rrKey("home", family)] = rrset
}
schedule := New(nil, hetzner.New(api.URL), nil, nil, nil)
changed, status, err := schedule.syncTarget(context.Background(), "token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", TTL: 60, UpdateIPv4: true, UpdateIPv6: true}, rrsets, "198.51.100.42", "2001:db8::42", true)
if !changed || err == nil || !strings.Contains(err.Error(), protectedFamily+":") || !strings.Contains(status, "aktualisiert") || writes.Load() != 1 {
t.Fatalf("partial update lost: changed=%v status=%q err=%v writes=%d", changed, status, err, writes.Load())
}
})
}
}
func TestRunPersistsConfirmedPartialTargetChanges(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
cipher, err := secure.LoadOrCreateCipher(filepath.Join(dir, "secrets", "key"))
if err != nil {
t.Fatal(err)
}
ciphertext, nonce, err := cipher.Encrypt("token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.org")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.org", Active: true, UpdateIPv4: true, UpdateIPv6: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(ctx, 300, true, true, nil, nil); err != nil {
t.Fatal(err)
}
var writes atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"protection":{"change":true},"records":[{"value":"198.51.100.1"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::1"}]}]}`))
} else {
if r.URL.Path != "/zones/123/rrsets/home/AAAA/actions/set_records" {
t.Errorf("unexpected write: %s", r.URL.Path)
}
writes.Add(1)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}
}))
defer api.Close()
worker := New(database, hetzner.New(api.URL), nil, cipher, slog.New(slog.NewTextHandler(io.Discard, nil)))
worker.resolver = resolverFunc(func(context.Context) ipresolve.Result {
return ipresolve.Result{IPv4: "198.51.100.42", IPv6: "2001:db8::42"}
})
summary, err := worker.Run(ctx)
if err == nil || summary != "1 Ziel(e) aktualisiert, 1 Fehler" || writes.Load() != 1 {
t.Fatalf("incorrect partial result: %q %v writes=%d", summary, err, writes.Load())
}
target, err = database.GetTarget(ctx, target.ID)
if err != nil || target.LastUpdatedAt.IsZero() || target.LastError == "" {
t.Fatalf("confirmed change or error lost: %#v %v", target, err)
}
runs, err := database.RecentRuns(ctx, 1)
if err != nil || len(runs) != 1 || runs[0].Status != "partial" {
t.Fatalf("wrong partial run status: %#v %v", runs, err)
}
}
func TestCanceledRunFinishesHistoryAndReleasesWorker(t *testing.T) {
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
ctx, cancel := context.WithCancel(context.Background())
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { cancel(); return ipresolve.Result{IPv4Err: context.Canceled} })
if _, err := schedule.Run(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("lost cancellation: %v", err)
}
runs, err := database.RecentRuns(context.Background(), 10)
if err != nil || len(runs) != 1 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || schedule.IsRunning() {
t.Fatalf("canceled run stuck: %#v err=%v", runs, err)
}
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { return ipresolve.Result{IPv4: "198.51.100.42"} })
if _, err := schedule.Run(context.Background()); err != nil {
t.Fatalf("worker did not recover: %v", err)
}
}
func TestDNSChangesAndUpdaterCannotOverlap(t *testing.T) {
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
unlock, err := schedule.LockChanges(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := schedule.Run(context.Background()); !errors.Is(err, ErrRunInProgress) {
t.Fatalf("updater entered active edit: %v", err)
}
unlock()
entered := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
schedule.resolver = resolverFunc(func(ctx context.Context) ipresolve.Result {
close(entered)
<-ctx.Done()
return ipresolve.Result{IPv4Err: ctx.Err()}
})
schedule.Start(ctx)
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("worker did not start")
}
waitCtx, stop := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer stop()
if release, err := schedule.LockChanges(waitCtx); err == nil {
release()
t.Fatal("edit entered active updater")
}
cancel()
shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), time.Second)
defer stopShutdown()
if err := schedule.Wait(shutdownCtx); err != nil {
t.Fatal(err)
}
release, err := schedule.LockChanges(shutdownCtx)
if err != nil {
t.Fatalf("shutdown left lock held: %v", err)
}
release()
}
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 TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(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.Hour); 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)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
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)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
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)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
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.StatusOK)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
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.StatusOK)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
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)
}
}
}