Files
hetzner-api-dyndns-records-…/internal/store/store_test.go
T
admin bb60c06c0e
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
feat: establish secure update foundation
2026-08-29 11:30:43 +02:00

592 lines
23 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
)
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
t.Parallel()
ctx := context.Background()
databasePath := filepath.Join(t.TempDir(), "legacy.db")
legacy, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, schemaV1); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, 1)`); err != nil {
t.Fatal(err)
}
if _, err := legacy.ExecContext(ctx, `INSERT INTO admins(id, password_hash, created_at, updated_at) VALUES(1, 'legacy-password-hash', 1, 1)`); err != nil {
t.Fatal(err)
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
database, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
var applied int
if err := database.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&applied); err != nil {
t.Fatal(err)
}
if applied != buildinfo.CurrentSchemaVersion {
t.Fatalf("direct migration applied %d of %d schemas", applied, buildinfo.CurrentSchemaVersion)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil || admin.Role != "admin" || !admin.Active {
t.Fatalf("legacy administrator was not retained: %#v, %v", admin, err)
}
}
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
databasePath := filepath.Join(dataDir, "app.db")
database, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
dirInfo, err := os.Stat(dataDir)
if err != nil {
t.Fatal(err)
}
dbInfo, err := os.Stat(databasePath)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0o700 || dbInfo.Mode().Perm() != 0o600 {
t.Fatalf("insecure storage permissions: dir=%o database=%o", dirInfo.Mode().Perm(), dbInfo.Mode().Perm())
}
}
func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) {
t.Fatalf("second first-run administrator was not rejected: %v", err)
}
if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil {
t.Fatal("rejected second administrator was persisted")
}
}
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hash, err := secure.HashPassword("a secure password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "10", "example.de")
if err != nil {
t.Fatal(err)
}
home, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, Active: true, StaticIPv4: "203.0.113.10", TTL: 60})
if err != nil {
t.Fatal(err)
}
if home.StaticIPv4 != "203.0.113.10" {
t.Fatalf("static IPv4 was not persisted: %#v", home)
}
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "aaa", FQDN: "aaa.example.de", UpdateIPv4: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
orderedTargets, err := database.ListTargets(ctx, zone.ID)
if err != nil || len(orderedTargets) != 2 || orderedTargets[0].ID != home.ID || orderedTargets[1].ID != other.ID {
t.Fatalf("active domain target was not sorted before inactive target: %#v err=%v", orderedTargets, err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
t.Fatal(err)
}
allowed, err := database.CanAccessTarget(ctx, member.ID, zone.ID, home.ID)
if err != nil || !allowed {
t.Fatalf("target-specific grant was not honored: allowed=%v err=%v", allowed, err)
}
allowed, err = database.CanAccessTarget(ctx, member.ID, zone.ID, other.ID)
if err != nil || allowed {
t.Fatalf("ungranted target was visible: allowed=%v err=%v", allowed, err)
}
managed, err := database.CanManageTarget(ctx, member.ID, zone.ID, home.ID)
if err != nil || !managed {
t.Fatalf("target management grant was not honored: managed=%v err=%v", managed, err)
}
targets, err := database.ListAccessibleTargets(ctx, member.ID, zone.ID)
if err != nil || len(targets) != 1 || targets[0].ID != home.ID {
t.Fatalf("expected one scoped target, got %#v (err=%v)", targets, err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}, ManageZoneIDs: []int64{zone.ID}, CreateTargetZoneIDs: []int64{zone.ID}}); err != nil {
t.Fatal(err)
}
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
if err != nil || len(targets) != 2 || targets[0].ID != home.ID || targets[1].ID != other.ID {
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
}
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
if err != nil || !canCreate {
t.Fatalf("domain create grant was not honored: canCreate=%v err=%v", canCreate, err)
}
if err := database.ArchiveTarget(ctx, home.ID); err != nil {
t.Fatal(err)
}
linked, err := database.ListTargets(ctx, zone.ID)
if err != nil || len(linked) != 1 || linked[0].ID != other.ID {
t.Fatalf("archived target remained linked: %#v, %v", linked, err)
}
archived, err := database.ListArchivedTargets(ctx, zone.ID)
if err != nil || len(archived) != 1 || archived[0].ID != home.ID || archived[0].ArchivedAt.IsZero() {
t.Fatalf("archived target was not retained: %#v, %v", archived, err)
}
imported, err := database.UpsertImportedTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, TTL: 60})
if err != nil || imported {
t.Fatalf("provider re-import unexpectedly restored an archived target: imported=%v err=%v", imported, err)
}
if err := database.RestoreTarget(ctx, home.ID); err != nil {
t.Fatal(err)
}
restored, err := database.GetTarget(ctx, home.ID)
if err != nil || !restored.ArchivedAt.IsZero() || restored.Active {
t.Fatalf("target was not safely restored as inactive: %#v, %v", restored, err)
}
}
func TestInactiveUserSessionIsRejected(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hash, _ := secure.HashPassword("a secure password")
user, err := database.CreateUser(ctx, "member", "Member", hash, "member")
if err != nil {
t.Fatal(err)
}
session, err := database.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: "token", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
loaded, err := database.GetSession(ctx, session.TokenHash)
if err != nil || loaded == nil || loaded.Username != "member" {
t.Fatalf("session was not resolved with user: %#v, %v", loaded, err)
}
if err := database.UpdateUserState(ctx, user.ID, "Member", "member", false); err != nil {
t.Fatal(err)
}
loaded, err = database.GetSession(ctx, session.TokenHash)
if err != nil || loaded != nil {
t.Fatalf("inactive user session was accepted: %#v, %v", loaded, err)
}
}
func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hash, err := secure.HashPassword("a secure administrator password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
t.Fatalf("last active administrator could be removed: %v", err)
}
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("administrator accepted editable individual grants: %v", err)
}
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
if err != nil {
t.Fatal(err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
t.Fatal(err)
}
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
t.Fatal(err)
}
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
}
grants, err := database.ListAccessGrants(ctx, member.ID)
if err != nil {
t.Fatal(err)
}
if len(grants) != 0 {
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("promoted administrator accepted individual grants: %v", err)
}
}
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
t.Fatal(err)
}
checkedAt := time.Now().UTC().Truncate(time.Second)
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
t.Fatal(err)
}
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
t.Fatal(err)
}
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
t.Fatal(err)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
t.Fatalf("software update state was not persisted safely: %#v", settings)
}
if err := database.ClearDesiredSoftwareVersion(ctx, "v9.9.9"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSoftwareUpdateSettings(ctx)
if err != nil || settings.DesiredVersion != "v1.2.3" {
t.Fatalf("unrelated update cleared the pending target: %#v, %v", settings, err)
}
if err := database.ClearDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSoftwareUpdateSettings(ctx)
if err != nil || settings.DesiredVersion != "" {
t.Fatalf("matching completed target remained pending: %#v, %v", settings, err)
}
}
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
created, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole Zuhause", BaseURL: "https://pi.hole/api", InsecureTLS: true}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")})
if err != nil {
t.Fatal(err)
}
if _, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Zweites Pi-hole", BaseURL: "https://second.pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")}); !errors.Is(err, ErrPiHoleAlreadyConfigured) {
t.Fatalf("second Pi-hole integration was not rejected: %v", err)
}
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, created.ID)
if err != nil || loaded.Name != "Pi-hole Zuhause" || loaded.BaseURL != "https://pi.hole/api" || !loaded.InsecureTLS || string(credential.Ciphertext) != "encrypted" {
t.Fatalf("Pi-hole integration not persisted: %#v %#v err=%v", loaded, credential, err)
}
loaded.Name = "Pi-hole Büro"
loaded.InsecureTLS = false
if err := database.UpdatePiHoleIntegration(ctx, loaded, model.PiHoleCredential{Ciphertext: []byte("new-encrypted"), Nonce: []byte("new-nonce")}); err != nil {
t.Fatal(err)
}
if err := database.MarkPiHoleSynced(ctx, loaded.ID, "connection failed"); err != nil {
t.Fatal(err)
}
integrations, err := database.ListPiHoleIntegrations(ctx)
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole Büro" || integrations[0].LastError != "connection failed" || integrations[0].LastSyncAt.IsZero() {
t.Fatalf("Pi-hole integration status not listed: %#v err=%v", integrations, err)
}
links, err := database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.20 nas.lan", "192.168.1.30 printer.lan"})
if err != nil || len(links) != 2 || !links[0].Active || !links[1].Active {
t.Fatalf("Pi-hole record links not synchronized: %#v err=%v", links, err)
}
firstID, secondID := links[0].ID, links[1].ID
if err := database.ArchivePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
t.Fatal(err)
}
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
if err != nil || len(links) != 2 || links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record link not archived: %#v err=%v", links, err)
}
if err := database.RestorePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
t.Fatal(err)
}
if err := database.UpdatePiHoleRecordLink(ctx, loaded.ID, firstID, "192.168.1.21 nas.lan", false); err != nil {
t.Fatal(err)
}
if err := database.DeletePiHoleRecordLink(ctx, loaded.ID, secondID); err != nil {
t.Fatal(err)
}
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.21 nas.lan" || links[0].Active || !links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record link lifecycle failed: %#v err=%v", links, err)
}
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.21 nas.lan"})
if err != nil || len(links) != 1 || !links[0].Active {
t.Fatalf("remote Pi-hole record did not become active: %#v err=%v", links, err)
}
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, nil)
if err != nil || len(links) != 1 || links[0].Active {
t.Fatalf("missing remote Pi-hole record was deleted instead of retained as inactive: %#v err=%v", links, err)
}
if err := database.DeletePiHoleIntegration(ctx, loaded.ID); err != nil {
t.Fatal(err)
}
if _, err := database.GetPiHoleIntegration(ctx, loaded.ID); err == nil {
t.Fatal("deleted Pi-hole integration still exists")
}
if links, err := database.ListPiHoleRecordLinks(ctx, loaded.ID); err != nil || len(links) != 0 {
t.Fatalf("Pi-hole record links were not removed with integration: %#v err=%v", links, err)
}
}
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "ipv6-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "40", "example.de")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, StaticIPv6: "2001:db8::10", TTL: 60})
if err != nil {
t.Fatal(err)
}
targets, err := database.ListTargetsWithIPv6(ctx)
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
}
if err := database.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil {
t.Fatal(err)
}
if err := database.DisableIPv6ForTargets(ctx); err != nil {
t.Fatal(err)
}
target, err = database.GetTarget(ctx, target.ID)
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
}
settings, err := database.GetSettings(ctx)
if err != nil || settings.LastIPv6 != "" {
t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err)
}
if err := database.DeleteTarget(ctx, target.ID); err != nil {
t.Fatal(err)
}
if _, err := database.GetTarget(ctx, target.ID); err == nil {
t.Fatal("deleted target was still found")
}
}
func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := 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 !settings.UpdaterEnabled || !settings.IPv6Enabled {
t.Fatalf("new installations must enable updater and IPv6 by default: %#v", settings)
}
if err := database.UpdateSettings(ctx, 600, false, false, []string{"https://v4.example"}, []string{"https://v6.example"}); err != nil {
t.Fatal(err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.UpdaterEnabled || settings.IPv6Enabled || settings.UpdateIntervalSeconds != 600 {
t.Fatalf("updater settings were not persisted: %#v err=%v", settings, err)
}
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "error-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "20", "errors.example")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.errors.example", UpdateIPv4: true, Active: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateTargetResult(ctx, target.ID, "Fehler", "provider rejected update", false); err != nil {
t.Fatal(err)
}
if err := database.MarkZoneSynced(ctx, zone.ID, "zone unavailable"); err != nil {
t.Fatal(err)
}
runID, err := database.CreateUpdateRun(ctx)
if err != nil {
t.Fatal(err)
}
if err := database.FinishUpdateRun(ctx, runID, "partial", "198.51.100.1", "", "one error"); err != nil {
t.Fatal(err)
}
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
t.Fatal(err)
}
if err := database.UpdateRunState(ctx, "", "", "error"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.LastIPv4 != "198.51.100.1" {
t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
}
if err := database.ClearErrors(ctx); err != nil {
t.Fatal(err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 0 {
t.Fatalf("errors were not cleared: count=%d err=%v", count, err)
}
clearedTarget, err := database.GetTarget(ctx, target.ID)
if err != nil || clearedTarget.LastError != "" || clearedTarget.LastStatus != "Zurückgesetzt" {
t.Fatalf("target error was not reset: %#v err=%v", clearedTarget, err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.LastRunStatus != "" {
t.Fatalf("last run error state was not reset: %#v err=%v", settings, err)
}
}
func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "cname-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "30", "example.de")
if err != nil {
t.Fatal(err)
}
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "aaa", FQDN: "aaa.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
if err != nil {
t.Fatal(err)
}
created.Destination = "edge.example.net."
created.TTL = 600
if err := database.UpdateCNAMERecord(ctx, created); err != nil {
t.Fatal(err)
}
updated, err := database.GetCNAMERecord(ctx, created.ID)
if err != nil || !updated.Active || updated.Destination != "edge.example.net." || updated.TTL != 600 {
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
}
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "zzz", FQDN: "zzz.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
t.Fatal(err)
}
records, err := database.ListCNAMERecords(ctx, zone.ID)
if err != nil || len(records) != 2 {
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
}
if records[0].RecordName != "zzz" || records[1].RecordName != "aaa" {
t.Fatalf("active CNAME was not sorted before inactive CNAME: %#v", records)
}
byName := make(map[string]model.CNAMERecord, len(records))
for _, record := range records {
byName[record.RecordName] = record
}
if byName["aaa"].Active || !byName["zzz"].Active || byName["zzz"].Destination != "assets.example.net." || byName["zzz"].TTL != 120 {
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
}
}