feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
+181
-9
@@ -21,7 +21,12 @@ type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||
var (
|
||||
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
|
||||
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
|
||||
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
|
||||
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||
)
|
||||
|
||||
type ManagedTarget struct {
|
||||
Target model.Target
|
||||
@@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
if err := s.applyMigrationV10(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.applyMigrationV11(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
|
||||
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
|
||||
return err
|
||||
@@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
|
||||
return fmt.Errorf("apply schema v11: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
||||
@@ -453,6 +478,22 @@ const schemaV10 = `
|
||||
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||
`
|
||||
|
||||
const schemaV11 = `
|
||||
CREATE TABLE software_update_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
registry_username TEXT NOT NULL DEFAULT '',
|
||||
token_ciphertext BLOB NOT NULL DEFAULT X'',
|
||||
token_nonce BLOB NOT NULL DEFAULT X'',
|
||||
latest_version TEXT NOT NULL DEFAULT '',
|
||||
release_url TEXT NOT NULL DEFAULT '',
|
||||
desired_version TEXT NOT NULL DEFAULT '',
|
||||
last_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
|
||||
`
|
||||
|
||||
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
||||
@@ -460,8 +501,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
|
||||
return err
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
|
||||
SELECT ?, ?, ?, 'admin', 1, ?, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created != 1 {
|
||||
return ErrAdminAlreadyConfigured
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
|
||||
@@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
|
||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
|
||||
}
|
||||
@@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var currentRole string
|
||||
var currentActive int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(¤tRole, ¤tActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
|
||||
var activeAdmins int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeAdmins <= 1 {
|
||||
return ErrLastActiveAdmin
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
// Administrators receive full access from their role. Removing stale member
|
||||
// grants prevents hidden permissions from resurfacing after a later demotion.
|
||||
if role == "admin" {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if currentRole != role || intBool(currentActive) != active {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
|
||||
return s.UpdatePassword(ctx, userID, passwordHash)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
|
||||
@@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var role string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "admin" {
|
||||
return ErrAdminAccessImmutable
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
|
||||
var settings model.SoftwareUpdateSettings
|
||||
var checkedAt, updatedAt int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
|
||||
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
|
||||
)
|
||||
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordSoftwareUpdateFailure intentionally retains the last known release so
|
||||
// a temporary Gitea outage never removes an already visible update reminder.
|
||||
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
|
||||
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
|
||||
last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6),
|
||||
last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1095,8 +1257,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
|
||||
}
|
||||
|
||||
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
|
||||
return err
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
|
||||
|
||||
@@ -38,6 +38,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -168,6 +188,100 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -268,6 +382,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -275,6 +392,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -335,6 +456,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user