Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b, v0.135.0)
tier2_capture.go: classified apps get TierSecondary per-bind legs (paperless copy shrinks — export
drops); legacy apps keep the byte-identical resolver set. v2 relpath-mirroring layout
(backups/secondary/<stack>/{marker LAST, recovery-unit/, hdd/<rel>/, userdata/<rel>/}); N>1 native
(errTier2MultiDir/tier2AppDataName deleted). Migration=delete-and-rebuild + reconcile; all RemoveAll
via tier2SafeRemove (refuses outside backups/secondary/). SSD=state-only tier. selectTier2Target
never picks network storage (pinned+auto, F-6C-1). Restore reads v2 behind a marker gate.
Part 0: offbox_enlarge_blocked is a persisted one-time Load seed (opt-out sticks), not a getter
append. Part 0.5: offsite restore scratch prefers a local (non-network) path.
Full v2 test suite + all 10 §10 red-proofs verified. Destructive writes bounded to backups/secondary/.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -23,57 +25,82 @@ func count(list []string, want string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// F2a: the new warning type is on by default for new customers.
|
||||
func writeSettings(t *testing.T, json string) (string, *Settings) {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(json), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(p, discardLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, s
|
||||
}
|
||||
|
||||
// The new warning type is on by default for new customers.
|
||||
func TestDefaultEnabledEvents_ContainsEnlargeBlocked(t *testing.T) {
|
||||
if !contains(DefaultEnabledEvents, "offbox_enlarge_blocked") {
|
||||
t.Error("DefaultEnabledEvents must contain offbox_enlarge_blocked (new customers get it)")
|
||||
}
|
||||
}
|
||||
|
||||
// F2b: an EXISTING customer's stored prefs (predating the type) gain it via append-if-absent —
|
||||
// idempotent (two reads → one entry) and their OTHER choices are preserved.
|
||||
func TestGetNotificationPrefs_MigratesExisting(t *testing.T) {
|
||||
// Part 0: the seed runs at LOAD (not the getter) — an existing customer's stored prefs gain the type,
|
||||
// their other choices preserved, and the change is persisted (marker + type on disk).
|
||||
func TestSeedOffboxEnlargeNotice_AtLoad(t *testing.T) {
|
||||
p, s := writeSettings(t, `{"notifications":{"enabled_events":["backup_failed","disk_warning"],"cooldown_hours":6}}`)
|
||||
ev := s.GetNotificationPrefs().EnabledEvents
|
||||
if !contains(ev, "offbox_enlarge_blocked") {
|
||||
t.Error("seed must append the type at load")
|
||||
}
|
||||
if !contains(ev, "backup_failed") || !contains(ev, "disk_warning") {
|
||||
t.Error("existing choices must be preserved")
|
||||
}
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !strings.Contains(string(raw), "offbox_enlarge_notice_seeded") {
|
||||
t.Error("seed marker must persist to disk")
|
||||
}
|
||||
if !strings.Contains(string(raw), "offbox_enlarge_blocked") {
|
||||
t.Error("the seeded type must persist to disk (so the getter returns it verbatim)")
|
||||
}
|
||||
}
|
||||
|
||||
// Part 0: the seed is idempotent across loads (a customer already having the type keeps exactly one).
|
||||
func TestSeedOffboxEnlargeNotice_Idempotent(t *testing.T) {
|
||||
p, _ := writeSettings(t, `{"notifications":{"enabled_events":["offbox_enlarge_blocked","backup_failed"],"cooldown_hours":6}}`)
|
||||
s2, err := Load(p, discardLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := count(s2.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked"); c != 1 {
|
||||
t.Errorf("idempotent seed → exactly one entry, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Part 0 (the whole point): a deliberate opt-out STICKS — after the seed, unchecking + saving and
|
||||
// reloading must NOT re-enable the type (the 3a-fix getter append re-enabled it forever; this fixes it).
|
||||
func TestSeedOffboxEnlargeNotice_OptOutSticks(t *testing.T) {
|
||||
p, s := writeSettings(t, `{"notifications":{"enabled_events":["backup_failed","offbox_enlarge_blocked"],"cooldown_hours":6}}`)
|
||||
// seed already ran at Load (seeded=true persisted). Customer unchecks the warning and saves.
|
||||
if err := s.SetNotificationPrefs(&NotificationPrefs{EnabledEvents: []string{"backup_failed"}, CooldownHours: 6}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := Load(p, discardLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(s2.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked") {
|
||||
t.Error("opt-out must STICK — the seed must not re-enable the type after a deliberate uncheck")
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh customer (no file → nil prefs) still gets the type via DefaultEnabledEvents.
|
||||
func TestGetNotificationPrefs_FreshCustomer(t *testing.T) {
|
||||
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), discardLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A customer who kept only two events and never had the new one.
|
||||
if err := s.SetNotificationPrefs(&NotificationPrefs{
|
||||
Email: "c@example.com",
|
||||
EnabledEvents: []string{"backup_failed", "disk_warning"},
|
||||
CooldownHours: 6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p1 := s.GetNotificationPrefs()
|
||||
if !contains(p1.EnabledEvents, "offbox_enlarge_blocked") {
|
||||
t.Error("existing prefs must gain offbox_enlarge_blocked (append-if-absent migration)")
|
||||
}
|
||||
if !contains(p1.EnabledEvents, "backup_failed") || !contains(p1.EnabledEvents, "disk_warning") {
|
||||
t.Error("the customer's existing choices must be preserved")
|
||||
}
|
||||
|
||||
// idempotent: a second read still has exactly ONE entry.
|
||||
p2 := s.GetNotificationPrefs()
|
||||
if c := count(p2.EnabledEvents, "offbox_enlarge_blocked"); c != 1 {
|
||||
t.Errorf("migration must be idempotent, got %d entries", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A customer who already has the type keeps exactly one (no duplication).
|
||||
func TestGetNotificationPrefs_AlreadyPresentNoDuplicate(t *testing.T) {
|
||||
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), discardLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetNotificationPrefs(&NotificationPrefs{
|
||||
EnabledEvents: []string{"offbox_enlarge_blocked", "backup_failed"},
|
||||
CooldownHours: 6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := count(s.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked"); c != 1 {
|
||||
t.Errorf("already-present type must not duplicate, got %d", c)
|
||||
if !contains(s.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked") {
|
||||
t.Error("fresh customer must get offbox_enlarge_blocked via DefaultEnabledEvents")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ type Settings struct {
|
||||
// Notification preferences (Phase 2 — define struct now, leave empty)
|
||||
Notifications *NotificationPrefs `json:"notifications,omitempty"`
|
||||
|
||||
// OffboxEnlargeNoticeSeeded (v0.135.0) guards the ONE-TIME seed of offbox_enlarge_blocked into an
|
||||
// existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append
|
||||
// re-enabled it on every read — this replaces it).
|
||||
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
|
||||
|
||||
// Cached state
|
||||
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
||||
|
||||
@@ -170,6 +175,7 @@ type CrossDriveBackup struct {
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian)
|
||||
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
||||
|
||||
@@ -318,6 +324,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
|
||||
_ = os.WriteFile(path, bak, 0644) // best-effort promote
|
||||
s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
|
||||
s2.migrateResticToRsync()
|
||||
s2.seedOffboxEnlargeNotice()
|
||||
return s2, nil
|
||||
}
|
||||
}
|
||||
@@ -333,9 +340,28 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
|
||||
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
|
||||
}
|
||||
s.migrateResticToRsync()
|
||||
s.seedOffboxEnlargeNotice()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// seedOffboxEnlargeNotice runs ONCE (guarded by OffboxEnlargeNoticeSeeded): an existing customer whose
|
||||
// stored prefs predate offbox_enlarge_blocked (v0.135.0) gets it appended enabled — they could not have
|
||||
// deliberately disabled a type that did not exist. Persisted, so a LATER opt-out sticks (unlike the
|
||||
// 3a-fix getter append, which re-enabled it on every read). Fresh customers (nil prefs) get the type via
|
||||
// DefaultEnabledEvents; the seed only touches customers with an explicit stored EnabledEvents list.
|
||||
func (s *Settings) seedOffboxEnlargeNotice() {
|
||||
if s.OffboxEnlargeNoticeSeeded {
|
||||
return
|
||||
}
|
||||
s.OffboxEnlargeNoticeSeeded = true
|
||||
if s.Notifications != nil && s.Notifications.EnabledEvents != nil {
|
||||
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "offbox_enlarge_blocked")
|
||||
}
|
||||
if err := s.save(); err != nil && s.log != nil {
|
||||
s.log.Printf("[ERROR] [settings] Failed to save offbox-enlarge-notice seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
|
||||
// Called once during Load() before the mutex is exposed.
|
||||
func (s *Settings) migrateResticToRsync() {
|
||||
@@ -520,13 +546,12 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
||||
if prefs.EnabledEvents == nil {
|
||||
prefs.EnabledEvents = DefaultEnabledEvents
|
||||
}
|
||||
// Return a copy of the slice
|
||||
// Return a copy of the slice verbatim. The offbox_enlarge_blocked seed is a ONE-TIME persisted
|
||||
// migration (seedOffboxEnlargeNotice at Load), NOT a getter append — so a customer's later opt-out
|
||||
// sticks instead of being re-enabled on every read.
|
||||
events := make([]string, len(prefs.EnabledEvents))
|
||||
copy(events, prefs.EnabledEvents)
|
||||
// 3a-fix append-if-absent migration: an existing customer's stored prefs predate
|
||||
// offbox_enlarge_blocked, so they cannot have deliberately disabled it — surface it enabled so the
|
||||
// checkbox renders checked and the startup sync (main.go:782) carries it to the hub. Idempotent.
|
||||
prefs.EnabledEvents = appendIfAbsent(events, "offbox_enlarge_blocked")
|
||||
prefs.EnabledEvents = events
|
||||
return &prefs
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user