From 0ca6190e3cba41b9eaab78a60edb42fb601c1f37 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 11:12:28 +0100 Subject: [PATCH 01/29] Align secondary path validation across config load, CLI install, and TUI Centralize validation for SECONDARY_PATH and SECONDARY_LOG_PATH so all entrypoints enforce the same absolute-local-path rules. Reject remote/UNC-style secondary paths during config loading, keep SECONDARY_LOG_PATH optional, and update the CLI installer to retry on invalid secondary path input instead of aborting. Add coverage for config parsing, migration, installer, runtime validation, and TUI flows. --- cmd/proxsave/helpers_test.go | 51 +++++++++- cmd/proxsave/install.go | 32 +++++-- cmd/proxsave/install_test.go | 54 +++++++++++ cmd/proxsave/prompts.go | 19 ++-- cmd/proxsave/runtime_helpers.go | 9 +- docs/CLI_REFERENCE.md | 2 +- docs/CLOUD_STORAGE.md | 2 +- docs/CONFIGURATION.md | 10 +- internal/config/config.go | 18 ++++ internal/config/config_test.go | 42 +++++++++ internal/config/migration.go | 9 +- internal/config/migration_test.go | 25 +++++ internal/config/templates/backup.env | 6 +- internal/config/validation_secondary.go | 58 ++++++++++++ internal/config/validation_secondary_test.go | 99 ++++++++++++++++++++ internal/tui/wizard/install.go | 54 ++++++----- internal/tui/wizard/install_test.go | 56 +++++++++++ 17 files changed, 496 insertions(+), 50 deletions(-) create mode 100644 internal/config/validation_secondary.go create mode 100644 internal/config/validation_secondary_test.go diff --git a/cmd/proxsave/helpers_test.go b/cmd/proxsave/helpers_test.go index e27d735a..dd3490f2 100644 --- a/cmd/proxsave/helpers_test.go +++ b/cmd/proxsave/helpers_test.go @@ -411,8 +411,55 @@ func TestInputMapInputError(t *testing.T) { func TestValidateFutureFeatures_SecondaryWithoutPath(t *testing.T) { cfg := &config.Config{SecondaryEnabled: true} - if err := validateFutureFeatures(cfg); err == nil { - t.Error("expected error for secondary enabled without path") + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for secondary enabled without path") + } + if got, want := err.Error(), "SECONDARY_PATH is required when SECONDARY_ENABLED=true"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) + } +} + +func TestValidateFutureFeatures_SecondaryRejectsRemotePath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "remote:path", + } + + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for remote-style secondary path") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) + } +} + +func TestValidateFutureFeatures_SecondaryAllowsEmptyLogPath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "/backup/secondary", + SecondaryLogPath: "", + } + + if err := validateFutureFeatures(cfg); err != nil { + t.Fatalf("expected empty secondary log path to be allowed, got %v", err) + } +} + +func TestValidateFutureFeatures_SecondaryRejectsInvalidLogPath(t *testing.T) { + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: "/backup/secondary", + SecondaryLogPath: "remote:/logs", + } + + err := validateFutureFeatures(cfg) + if err == nil { + t.Fatal("expected error for invalid secondary log path") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("validateFutureFeatures error = %q, want %q", got, want) } } diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 55606234..f6f76364 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -730,16 +730,32 @@ func configureSecondaryStorage(ctx context.Context, reader *bufio.Reader, templa return "", err } if enableSecondary { - secondaryPath, err := promptNonEmpty(ctx, reader, "Secondary backup path (SECONDARY_PATH): ") - if err != nil { - return "", err + var secondaryPath string + for { + secondaryPath, err = promptNonEmpty(ctx, reader, "Secondary backup path (SECONDARY_PATH): ") + if err != nil { + return "", err + } + secondaryPath = sanitizeEnvValue(secondaryPath) + if err := config.ValidateRequiredSecondaryPath(secondaryPath); err != nil { + fmt.Printf("%v\n", err) + continue + } + break } - secondaryPath = sanitizeEnvValue(secondaryPath) - secondaryLog, err := promptNonEmpty(ctx, reader, "Secondary log path (SECONDARY_LOG_PATH): ") - if err != nil { - return "", err + var secondaryLog string + for { + secondaryLog, err = promptOptional(ctx, reader, "Secondary log path (SECONDARY_LOG_PATH, optional - press Enter to skip): ") + if err != nil { + return "", err + } + secondaryLog = sanitizeEnvValue(secondaryLog) + if err := config.ValidateOptionalSecondaryLogPath(secondaryLog); err != nil { + fmt.Printf("%v\n", err) + continue + } + break } - secondaryLog = sanitizeEnvValue(secondaryLog) template = setEnvValue(template, "SECONDARY_ENABLED", "true") template = setEnvValue(template, "SECONDARY_PATH", secondaryPath) template = setEnvValue(template, "SECONDARY_LOG_PATH", secondaryLog) diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index fc9b8350..2c7f5740 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -228,6 +228,60 @@ func TestConfigureSecondaryStorageEnabled(t *testing.T) { } } +func TestConfigureSecondaryStorageEnabledWithEmptyLogPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\n/mnt/secondary\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_ENABLED=true") { + t.Fatalf("expected SECONDARY_ENABLED=true in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/secondary") { + t.Fatalf("expected secondary path in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageRejectsInvalidBackupPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\nrelative/path\n/mnt/secondary\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/secondary") { + t.Fatalf("expected corrected secondary path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageRejectsInvalidLogPath(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("y\n/mnt/secondary\nremote:/logs\n\n")) + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, "") + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in template: %q", result) + } +} + func TestConfigureSecondaryStorageDisabled(t *testing.T) { var result string var err error diff --git a/cmd/proxsave/prompts.go b/cmd/proxsave/prompts.go index 15b906af..fe9ca654 100644 --- a/cmd/proxsave/prompts.go +++ b/cmd/proxsave/prompts.go @@ -49,18 +49,25 @@ func promptYesNo(ctx context.Context, reader *bufio.Reader, question string, def func promptNonEmpty(ctx context.Context, reader *bufio.Reader, question string) (string, error) { for { - if err := ctx.Err(); err != nil { - return "", errInteractiveAborted - } - fmt.Print(question) - resp, err := input.ReadLineWithContext(ctx, reader) + resp, err := promptOptional(ctx, reader, question) if err != nil { return "", err } - resp = strings.TrimSpace(resp) if resp != "" { return resp, nil } fmt.Println("Value cannot be empty.") } } + +func promptOptional(ctx context.Context, reader *bufio.Reader, question string) (string, error) { + if err := ctx.Err(); err != nil { + return "", errInteractiveAborted + } + fmt.Print(question) + resp, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return "", err + } + return strings.TrimSpace(resp), nil +} diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index b95d90eb..2cb4f482 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -239,8 +239,13 @@ func resolveHostname() string { } func validateFutureFeatures(cfg *config.Config) error { - if cfg.SecondaryEnabled && cfg.SecondaryPath == "" { - return fmt.Errorf("secondary backup enabled but SECONDARY_PATH is empty") + if cfg.SecondaryEnabled { + if err := config.ValidateRequiredSecondaryPath(cfg.SecondaryPath); err != nil { + return err + } + if err := config.ValidateOptionalSecondaryLogPath(cfg.SecondaryLogPath); err != nil { + return err + } } if cfg.CloudEnabled && cfg.CloudRemote == "" { logging.Warning("Cloud backup enabled but CLOUD_REMOTE is empty – disabling cloud storage for this run") diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index c9850468..5b69b537 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -136,7 +136,7 @@ Some interactive commands support two interface modes: **Wizard workflow**: 1. Generates/updates the configuration file (`configs/backup.env` by default) -2. Optionally configures secondary storage +2. Optionally configures secondary storage (`SECONDARY_PATH` required if enabled; `SECONDARY_LOG_PATH` optional; invalid secondary paths are re-prompted/rejected) 3. Optionally configures cloud storage (rclone) 4. Optionally enables firewall rules collection (`BACKUP_FIREWALL_RULES=false` by default) 5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`) diff --git a/docs/CLOUD_STORAGE.md b/docs/CLOUD_STORAGE.md index 240b112f..3e4161b8 100644 --- a/docs/CLOUD_STORAGE.md +++ b/docs/CLOUD_STORAGE.md @@ -815,7 +815,7 @@ cp -a /restore/* / A: No, currently only one `CLOUD_REMOTE` is supported. Workaround: Use `rclone union` to combine multiple backends. **Q: Can I use a network address like "192.168.0.10/folder" for SECONDARY_PATH?** -A: **No**. `SECONDARY_PATH` and `BACKUP_PATH` require **filesystem-mounted paths only**. Network shares must be mounted first using NFS/CIFS/SMB mount commands, then you use the local mount point path (e.g., `/mnt/nas-backup`). +A: **No**. `SECONDARY_PATH` and `BACKUP_PATH` require **absolute local filesystem paths**. For network shares, mount them first using NFS/CIFS/SMB, then use the local mount point path (e.g., `/mnt/nas-backup`). If you want to use a direct network address without mounting, configure it as `CLOUD_REMOTE` using rclone with an S3-compatible backend (like MinIO) or appropriate protocol. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e0f3d826..63dfe6c8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -408,10 +408,10 @@ BACKUP_EXCLUDE_PATTERNS="*/cache/**, /var/tmp/**, *.log" # Enable secondary storage SECONDARY_ENABLED=false # true | false -# Secondary backup path +# Secondary backup path (required when SECONDARY_ENABLED=true) SECONDARY_PATH=/mnt/secondary/backup -# Secondary log path +# Secondary log path (optional) SECONDARY_LOG_PATH=/mnt/secondary/log ``` @@ -421,7 +421,9 @@ Additional local storage for redundant backup copies - mounted NAS, USB drives, ### IMPORTANT PATH REQUIREMENTS -- `SECONDARY_PATH` **must be a filesystem-mounted path** (e.g., `/mnt/nas-backup`, `/media/usb-drive`) +- `SECONDARY_PATH` **must be an absolute local filesystem path** (e.g., `/mnt/nas-backup`, `/media/usb-drive`) +- `SECONDARY_LOG_PATH`, when set, must follow the **same absolute local path rules** +- `SECONDARY_LOG_PATH` is optional; when empty, secondary backup copies still run, but secondary log copy/cleanup is disabled - `SECONDARY_PATH` **CANNOT** be a network address (e.g., `192.168.0.10/folder`, `//server/share`) - Network shares **must be mounted first** using standard Linux mounting (NFS/CIFS/SMB) @@ -443,6 +445,7 @@ sudo mount -t cifs //192.168.0.10/backup /mnt/nas-backup -o credentials=/root/.s **2. Then configure SECONDARY_PATH**: ```bash SECONDARY_PATH=/mnt/nas-backup # ✓ Correct - uses mounted path +SECONDARY_LOG_PATH=/mnt/nas-logs # Optional ``` ### What NOT to Do @@ -460,6 +463,7 @@ SECONDARY_PATH=\\192.168.0.10\backup # ✗ WRONG - Windows path - Secondary storage is **non-critical** (failures log warnings, don't abort backup) - Files copied via native Go (no dependency on rclone) - Same retention policy as primary storage +- Invalid configured secondary paths fail fast during configuration loading --- diff --git a/internal/config/config.go b/internal/config/config.go index bb35388e..c2918df7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -344,10 +344,28 @@ func (c *Config) parse() error { if err := c.parseCollectionSettings(); err != nil { return err } + if err := c.validateSecondarySettings(); err != nil { + return err + } c.autoDetectPBSAuth() return nil } +func (c *Config) validateSecondarySettings() error { + if err := ValidateOptionalSecondaryPath(c.SecondaryPath); err != nil { + return err + } + if c.SecondaryEnabled { + if err := ValidateRequiredSecondaryPath(c.SecondaryPath); err != nil { + return err + } + } + if err := ValidateOptionalSecondaryLogPath(c.SecondaryLogPath); err != nil { + return err + } + return nil +} + func (c *Config) parseGeneralSettings() { c.BackupEnabled = c.getBool("BACKUP_ENABLED", true) c.DryRun = c.getBool("DRY_RUN", false) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3fb7ab09..b3611eef 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -288,6 +288,48 @@ func TestLoadConfigNotFound(t *testing.T) { } } +func TestLoadConfigRejectsInvalidSecondaryPathEvenWhenDisabled(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "invalid-secondary.env") + content := `BACKUP_PATH=/test/backup +LOG_PATH=/test/log +SECONDARY_ENABLED=false +SECONDARY_PATH=remote:path +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected LoadConfig to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("LoadConfig() error = %q, want substring %q", got, want) + } +} + +func TestLoadConfigRejectsInvalidSecondaryLogPathWhenConfigured(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "invalid-secondary-log.env") + content := `BACKUP_PATH=/test/backup +LOG_PATH=/test/log +SECONDARY_ENABLED=false +SECONDARY_LOG_PATH=remote:/logs +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected LoadConfig to fail") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("LoadConfig() error = %q, want substring %q", got, want) + } +} + func TestLoadConfigWithQuotes(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test_quotes.env") diff --git a/internal/config/migration.go b/internal/config/migration.go index 653ddd48..d48b9b7f 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -206,8 +206,13 @@ func validateMigratedConfig(cfg *Config) error { if strings.TrimSpace(cfg.LogPath) == "" { return fmt.Errorf("LOG_PATH cannot be empty") } - if cfg.SecondaryEnabled && strings.TrimSpace(cfg.SecondaryPath) == "" { - return fmt.Errorf("SECONDARY_PATH required when SECONDARY_ENABLED=true") + if cfg.SecondaryEnabled { + if err := ValidateRequiredSecondaryPath(cfg.SecondaryPath); err != nil { + return err + } + if err := ValidateOptionalSecondaryLogPath(cfg.SecondaryLogPath); err != nil { + return err + } } if cfg.CloudEnabled && strings.TrimSpace(cfg.CloudRemote) == "" { return fmt.Errorf("CLOUD_REMOTE required when CLOUD_ENABLED=true") diff --git a/internal/config/migration_test.go b/internal/config/migration_test.go index 450d7701..eb300652 100644 --- a/internal/config/migration_test.go +++ b/internal/config/migration_test.go @@ -159,6 +159,8 @@ const baseInstallTemplate = `BACKUP_ENABLED=true BACKUP_PATH=/default/backup LOG_PATH=/default/log SECONDARY_ENABLED=false +SECONDARY_PATH= +SECONDARY_LOG_PATH= CLOUD_ENABLED=false SET_BACKUP_PERMISSIONS=false BACKUP_USER=backup @@ -192,6 +194,29 @@ func TestMigrateLegacyEnvCreatesConfigAndKeepsValues(t *testing.T) { }) } +func TestMigrateLegacyEnvRejectsInvalidSecondaryPath(t *testing.T) { + withTemplate(t, baseInstallTemplate, func() { + tmpDir := t.TempDir() + legacyPath := filepath.Join(tmpDir, "legacy.env") + outputPath := filepath.Join(tmpDir, "backup.env") + legacyContent := strings.Join([]string{ + "ENABLE_SECONDARY_BACKUP=true", + "SECONDARY_BACKUP_PATH=remote:path", + }, "\n") + "\n" + if err := os.WriteFile(legacyPath, []byte(legacyContent), 0600); err != nil { + t.Fatalf("failed to write legacy env: %v", err) + } + + _, err := MigrateLegacyEnv(legacyPath, outputPath) + if err == nil { + t.Fatal("expected migration to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; !strings.Contains(got, want) { + t.Fatalf("MigrateLegacyEnv error = %q, want substring %q", got, want) + } + }) +} + func TestMigrateLegacyEnvCreatesBackupWhenOverwriting(t *testing.T) { withTemplate(t, baseInstallTemplate, func() { tmpDir := t.TempDir() diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index ad5a9b0f..8369ff69 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -93,7 +93,7 @@ LOG_PATH=${BASE_DIR}/log # Primary log storage path # ---------------------------------------------------------------------- # Secondary storage # ---------------------------------------------------------------------- -# IMPORTANT: SECONDARY_PATH must be a filesystem-mounted path (e.g., /mnt/nas-backup) +# IMPORTANT: SECONDARY_PATH must be an absolute local filesystem path (e.g., /mnt/nas-backup) # It CANNOT be a network address like "192.168.0.10/folder" or "//server/share" # # For local network storage (NAS): @@ -111,8 +111,8 @@ LOG_PATH=${BASE_DIR}/log # Primary log storage path # For direct network access without mounting, use CLOUD_REMOTE with rclone instead. # ---------------------------------------------------------------------- SECONDARY_ENABLED=false # true-false = enable disable copy backup on secondary path -SECONDARY_PATH= # Secondary backup storage path -SECONDARY_LOG_PATH= # Secondary log storage path +SECONDARY_PATH= # Required absolute secondary backup path when secondary storage is enabled +SECONDARY_LOG_PATH= # Optional absolute secondary log path (same rules as SECONDARY_PATH) # ---------------------------------------------------------------------- # Cloud storage (rclone) diff --git a/internal/config/validation_secondary.go b/internal/config/validation_secondary.go new file mode 100644 index 00000000..06564208 --- /dev/null +++ b/internal/config/validation_secondary.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "path/filepath" + "strings" +) + +const secondaryPathFormatMessage = "must be an absolute local filesystem path" + +// ValidateRequiredSecondaryPath validates SECONDARY_PATH when secondary storage is enabled. +func ValidateRequiredSecondaryPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_PATH", true) +} + +// ValidateOptionalSecondaryPath validates SECONDARY_PATH when configured but not required. +func ValidateOptionalSecondaryPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_PATH", false) +} + +// ValidateOptionalSecondaryLogPath validates SECONDARY_LOG_PATH when provided. +func ValidateOptionalSecondaryLogPath(path string) error { + return validateSecondaryLocalPath(path, "SECONDARY_LOG_PATH", false) +} + +func validateSecondaryLocalPath(path, fieldName string, required bool) error { + clean := strings.TrimSpace(path) + if clean == "" { + if required { + return fmt.Errorf("%s is required when SECONDARY_ENABLED=true", fieldName) + } + return nil + } + + if isUNCStylePath(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + if strings.Contains(clean, ":") && !filepath.IsAbs(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + if !filepath.IsAbs(clean) { + return fmt.Errorf("%s %s", fieldName, secondaryPathFormatMessage) + } + + return nil +} + +func isUNCStylePath(path string) bool { + if strings.HasPrefix(path, `\\`) { + return true + } + if strings.HasPrefix(path, "//") { + return len(path) == 2 || path[2] != '/' + } + return false +} diff --git a/internal/config/validation_secondary_test.go b/internal/config/validation_secondary_test.go new file mode 100644 index 00000000..b1aeb759 --- /dev/null +++ b/internal/config/validation_secondary_test.go @@ -0,0 +1,99 @@ +package config + +import "testing" + +func TestValidateRequiredSecondaryPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "valid mount path", path: "/mnt/secondary"}, + {name: "valid subdirectory", path: "/mnt/secondary/log"}, + {name: "valid absolute with colon", path: "/mnt/data:archive"}, + {name: "empty", path: "", wantErr: "SECONDARY_PATH is required when SECONDARY_ENABLED=true"}, + {name: "relative", path: "relative/path", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "rclone remote", path: "gdrive:backups", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "host remote", path: "host:/backup", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "unc share", path: "//server/share", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "windows unc share", path: `\\server\share`, wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRequiredSecondaryPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateRequiredSecondaryPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateRequiredSecondaryPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidateOptionalSecondaryLogPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "empty allowed", path: ""}, + {name: "valid path", path: "/mnt/secondary/log"}, + {name: "relative", path: "logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + {name: "remote style", path: "remote:/logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + {name: "unc share", path: "//server/logs", wantErr: "SECONDARY_LOG_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOptionalSecondaryLogPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateOptionalSecondaryLogPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateOptionalSecondaryLogPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidateOptionalSecondaryPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr string + }{ + {name: "empty allowed", path: ""}, + {name: "valid path", path: "/mnt/secondary"}, + {name: "relative", path: "relative/path", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + {name: "remote style", path: "remote:/backup", wantErr: "SECONDARY_PATH must be an absolute local filesystem path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOptionalSecondaryPath(tt.path) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateOptionalSecondaryPath(%q) error = %v", tt.path, err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("ValidateOptionalSecondaryPath(%q) error = %v, want %q", tt.path, err, tt.wantErr) + } + }) + } +} diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index 66484b99..cab928a9 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" "strings" @@ -20,16 +19,16 @@ import ( ) type installWizardPrefill struct { - SecondaryEnabled bool - SecondaryPath string - SecondaryLogPath string - CloudEnabled bool - CloudRemote string - CloudLogPath string - FirewallEnabled bool - TelegramEnabled bool - EmailEnabled bool - EncryptionEnabled bool + SecondaryEnabled bool + SecondaryPath string + SecondaryLogPath string + CloudEnabled bool + CloudRemote string + CloudLogPath string + FirewallEnabled bool + TelegramEnabled bool + EmailEnabled bool + EncryptionEnabled bool } // InstallWizardData holds the collected installation data @@ -327,15 +326,10 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu // Collect data data.EnableSecondaryStorage = secondaryEnabled if secondaryEnabled { - data.SecondaryPath = secondaryPathField.GetText() - data.SecondaryLogPath = secondaryLogField.GetText() - - // Validate paths - if !filepath.IsAbs(data.SecondaryPath) { - return fmt.Errorf("secondary backup path must be absolute") - } - if !filepath.IsAbs(data.SecondaryLogPath) { - return fmt.Errorf("secondary log path must be absolute") + data.SecondaryPath = strings.TrimSpace(secondaryPathField.GetText()) + data.SecondaryLogPath = strings.TrimSpace(secondaryLogField.GetText()) + if err := validateSecondaryInstallData(data); err != nil { + return err } } @@ -491,6 +485,9 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err if strings.TrimSpace(template) == "" { template = config.DefaultEnvTemplate() } + if err := validateSecondaryInstallData(data); err != nil { + return "", err + } // BASE_DIR is auto-detected at runtime from the executable/config location. // Keep it out of backup.env to avoid pinning the installation to a specific path. @@ -502,8 +499,8 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err // Apply secondary storage if data.EnableSecondaryStorage { template = setEnvValue(template, "SECONDARY_ENABLED", "true") - template = setEnvValue(template, "SECONDARY_PATH", data.SecondaryPath) - template = setEnvValue(template, "SECONDARY_LOG_PATH", data.SecondaryLogPath) + template = setEnvValue(template, "SECONDARY_PATH", strings.TrimSpace(data.SecondaryPath)) + template = setEnvValue(template, "SECONDARY_LOG_PATH", strings.TrimSpace(data.SecondaryLogPath)) } else { template = setEnvValue(template, "SECONDARY_ENABLED", "false") } @@ -562,6 +559,19 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err return template, nil } +func validateSecondaryInstallData(data *InstallWizardData) error { + if data == nil || !data.EnableSecondaryStorage { + return nil + } + if err := config.ValidateRequiredSecondaryPath(data.SecondaryPath); err != nil { + return err + } + if err := config.ValidateOptionalSecondaryLogPath(data.SecondaryLogPath); err != nil { + return err + } + return nil +} + // setEnvValue sets or updates an environment variable in the template func setEnvValue(template, key, value string) string { return utils.SetEnvValue(template, key, value) diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index 4a7c9602..8f8641ed 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -102,6 +102,62 @@ func TestApplyInstallDataDefaultsBaseTemplate(t *testing.T) { } } +func TestApplyInstallDataAllowsEmptySecondaryLogPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "/mnt/sec", + SecondaryLogPath: "", + } + + result, err := ApplyInstallData("", data) + if err != nil { + t.Fatalf("ApplyInstallData returned error: %v", err) + } + if !strings.Contains(result, "SECONDARY_ENABLED=true") { + t.Fatalf("expected secondary enabled in result:\n%s", result) + } + if !strings.Contains(result, "SECONDARY_PATH=/mnt/sec") { + t.Fatalf("expected secondary path in result:\n%s", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected empty secondary log path in result:\n%s", result) + } +} + +func TestApplyInstallDataRejectsInvalidSecondaryPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "relative/path", + } + + _, err := ApplyInstallData("", data) + if err == nil { + t.Fatal("expected ApplyInstallData to fail") + } + if got, want := err.Error(), "SECONDARY_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("ApplyInstallData error = %q, want %q", got, want) + } +} + +func TestApplyInstallDataRejectsInvalidSecondaryLogPath(t *testing.T) { + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: true, + SecondaryPath: "/mnt/sec", + SecondaryLogPath: "remote:/logs", + } + + _, err := ApplyInstallData("", data) + if err == nil { + t.Fatal("expected ApplyInstallData to fail") + } + if got, want := err.Error(), "SECONDARY_LOG_PATH must be an absolute local filesystem path"; got != want { + t.Fatalf("ApplyInstallData error = %q, want %q", got, want) + } +} + func TestApplyInstallDataCronAndNotifications(t *testing.T) { baseTemplate := "CRON_SCHEDULE=\nCRON_HOUR=\nCRON_MINUTE=\nTELEGRAM_ENABLED=true\nEMAIL_ENABLED=false\nENCRYPT_ARCHIVE=true\n" data := &InstallWizardData{ From 24f942f0bfee610dfebc9fcec54fe1d7a8631e08 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 12:14:43 +0100 Subject: [PATCH 02/29] Align --new-install confirmation flow across CLI and TUI Refactor new-install to use a shared reset plan and a single source of truth for preserved entries (build/env/identity). Route --new-install --cli through CLI confirmation only, keep TUI confirmation as a pure adapter, and propagate TUI runner errors instead of swallowing them. Update related help/log messaging and add tests for new-install planning, CLI confirm behavior, TUI confirm rendering/error handling, and reset/preserve consistency. --- cmd/proxsave/install.go | 48 +++--- cmd/proxsave/install_test.go | 49 +++++- cmd/proxsave/main.go | 2 +- cmd/proxsave/new_install.go | 81 ++++++++++ cmd/proxsave/new_install_test.go | 188 ++++++++++++++++++++++++ cmd/proxsave/upgrade.go | 2 +- internal/tui/wizard/new_install.go | 25 +++- internal/tui/wizard/new_install_test.go | 41 +++++- 8 files changed, 405 insertions(+), 31 deletions(-) create mode 100644 cmd/proxsave/new_install.go create mode 100644 cmd/proxsave/new_install_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index f6f76364..7a042dad 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -22,6 +22,14 @@ import ( buildinfo "github.com/tis24dev/proxsave/internal/version" ) +var ( + newInstallEnsureInteractiveStdin = ensureInteractiveStdin + newInstallConfirmCLI = confirmNewInstallCLI + newInstallConfirmTUI = wizard.ConfirmNewInstall + newInstallRunInstall = runInstall + newInstallRunInstallTUI = runInstallTUI +) + func runInstall(ctx context.Context, configPath string, bootstrap *logging.BootstrapLogger) (err error) { logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "resolving configuration path") resolvedPath, err := resolveInstallConfigPath(configPath) @@ -361,25 +369,25 @@ func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, func runNewInstall(ctx context.Context, configPath string, bootstrap *logging.BootstrapLogger, useCLI bool) (err error) { done := logging.DebugStartBootstrap(bootstrap, "new-install workflow", "config=%s", configPath) defer func() { done(err) }() - resolvedPath, err := resolveInstallConfigPath(configPath) - if err != nil { - return err - } - - baseDir := deriveBaseDirFromConfig(resolvedPath) logging.DebugStepBootstrap(bootstrap, "new-install workflow", "ensuring interactive stdin") - if err := ensureInteractiveStdin(); err != nil { + if err := newInstallEnsureInteractiveStdin(); err != nil { return err } - buildSig := buildSignature() - if strings.TrimSpace(buildSig) == "" { - buildSig = "n/a" + logging.DebugStepBootstrap(bootstrap, "new-install workflow", "building reset plan") + plan, err := buildNewInstallPlan(configPath) + if err != nil { + return err } logging.DebugStepBootstrap(bootstrap, "new-install workflow", "confirming reset") - confirm, err := wizard.ConfirmNewInstall(baseDir, buildSig) + var confirm bool + if useCLI { + confirm, err = newInstallConfirmCLI(ctx, bufio.NewReader(os.Stdin), plan) + } else { + confirm, err = newInstallConfirmTUI(plan.BaseDir, plan.BuildSignature, plan.PreservedEntries) + } if err != nil { return wrapInstallError(err) } @@ -387,16 +395,18 @@ func runNewInstall(ctx context.Context, configPath string, bootstrap *logging.Bo return wrapInstallError(errInteractiveAborted) } - bootstrap.Info("Resetting %s (preserving env/ and identity/)", baseDir) + if bootstrap != nil { + bootstrap.Info("Resetting %s (preserving %s)", plan.BaseDir, formatNewInstallPreservedEntries(plan.PreservedEntries)) + } logging.DebugStepBootstrap(bootstrap, "new-install workflow", "resetting base dir") - if err := resetInstallBaseDir(baseDir, bootstrap); err != nil { + if err := resetInstallBaseDir(plan.BaseDir, bootstrap); err != nil { return err } if useCLI { - return runInstall(ctx, resolvedPath, bootstrap) + return newInstallRunInstall(ctx, plan.ResolvedConfigPath, bootstrap) } - return runInstallTUI(ctx, resolvedPath, bootstrap) + return newInstallRunInstallTUI(ctx, plan.ResolvedConfigPath, bootstrap) } func printInstallFooter(installErr error, configPath, baseDir, telegramCode, permStatus, permMessage string) { @@ -472,7 +482,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per fmt.Println(" --help - Show all options") fmt.Println(" --dry-run - Test without changes") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --newkey - Generate a new encryption key for backups") fmt.Println(" --decrypt - Decrypt an existing backup archive") @@ -655,11 +665,7 @@ func resetInstallBaseDir(baseDir string, bootstrap *logging.BootstrapLogger) (er return fmt.Errorf("failed to list base directory %s: %w", baseDir, err) } - preserve := map[string]struct{}{ - "env": {}, - "identity": {}, - "build": {}, - } + preserve := newInstallPreserveSet() for _, entry := range entries { name := entry.Name() diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index 2c7f5740..bc618e82 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -105,7 +105,7 @@ func TestIsInstallAbortedError(t *testing.T) { } } -func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { +func TestResetInstallBaseDirPreservesCoreDirectories(t *testing.T) { base := t.TempDir() // setup contents @@ -134,6 +134,15 @@ func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { t.Fatalf("setup identity file: %v", err) } + buildDir := filepath.Join(base, "build") + if err := os.Mkdir(buildDir, 0o755); err != nil { + t.Fatalf("setup build: %v", err) + } + buildFile := filepath.Join(buildDir, "keep.txt") + if err := os.WriteFile(buildFile, []byte("build"), 0o600); err != nil { + t.Fatalf("setup build file: %v", err) + } + logger := logging.NewBootstrapLogger() if err := resetInstallBaseDir(base, logger); err != nil { t.Fatalf("resetInstallBaseDir returned error: %v", err) @@ -157,6 +166,44 @@ func TestResetInstallBaseDirPreservesEnvAndIdentity(t *testing.T) { if _, err := os.Stat(idFile); err != nil { t.Fatalf("identity file should remain: %v", err) } + if _, err := os.Stat(buildDir); err != nil { + t.Fatalf("build dir should remain: %v", err) + } + if _, err := os.Stat(buildFile); err != nil { + t.Fatalf("build file should remain: %v", err) + } +} + +func TestResetInstallBaseDirRespectsSharedPreserveSet(t *testing.T) { + base := t.TempDir() + for _, entry := range newInstallPreservedEntries() { + dirPath := filepath.Join(base, entry) + if err := os.MkdirAll(dirPath, 0o755); err != nil { + t.Fatalf("setup %s: %v", entry, err) + } + filePath := filepath.Join(dirPath, "keep.txt") + if err := os.WriteFile(filePath, []byte(entry), 0o600); err != nil { + t.Fatalf("setup %s file: %v", entry, err) + } + } + if err := os.WriteFile(filepath.Join(base, "drop.txt"), []byte("drop"), 0o600); err != nil { + t.Fatalf("setup drop file: %v", err) + } + + logger := logging.NewBootstrapLogger() + if err := resetInstallBaseDir(base, logger); err != nil { + t.Fatalf("resetInstallBaseDir returned error: %v", err) + } + + for _, entry := range newInstallPreservedEntries() { + filePath := filepath.Join(base, entry, "keep.txt") + if _, err := os.Stat(filePath); err != nil { + t.Fatalf("expected preserved file for %s, got %v", entry, err) + } + } + if _, err := os.Stat(filepath.Join(base, "drop.txt")); !os.IsNotExist(err) { + t.Fatalf("expected drop.txt removed, got err=%v", err) + } } func TestResetInstallBaseDirRefusesRoot(t *testing.T) { diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 73d06bd6..0c758f59 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1637,7 +1637,7 @@ func printFinalSummary(finalExitCode int) { fmt.Println(" --help - Show all options") fmt.Println(" --dry-run - Test without changes") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") diff --git a/cmd/proxsave/new_install.go b/cmd/proxsave/new_install.go new file mode 100644 index 00000000..4ff0fdc9 --- /dev/null +++ b/cmd/proxsave/new_install.go @@ -0,0 +1,81 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "sort" + "strings" +) + +type newInstallPlan struct { + ResolvedConfigPath string + BaseDir string + BuildSignature string + PreservedEntries []string +} + +func buildNewInstallPlan(configPath string) (newInstallPlan, error) { + resolvedPath, err := resolveInstallConfigPath(configPath) + if err != nil { + return newInstallPlan{}, err + } + + buildSig := strings.TrimSpace(buildSignature()) + if buildSig == "" { + buildSig = "n/a" + } + + return newInstallPlan{ + ResolvedConfigPath: resolvedPath, + BaseDir: deriveBaseDirFromConfig(resolvedPath), + BuildSignature: buildSig, + PreservedEntries: newInstallPreservedEntries(), + }, nil +} + +func newInstallPreservedEntries() []string { + preserved := []string{"env", "identity", "build"} + sort.Strings(preserved) + return preserved +} + +func newInstallPreserveSet() map[string]struct{} { + preserved := newInstallPreservedEntries() + result := make(map[string]struct{}, len(preserved)) + for _, entry := range preserved { + result[entry] = struct{}{} + } + return result +} + +func formatNewInstallPreservedEntries(entries []string) string { + formatted := make([]string, 0, len(entries)) + for _, entry := range entries { + trimmed := strings.TrimSpace(entry) + if trimmed == "" { + continue + } + formatted = append(formatted, trimmed+"/") + } + if len(formatted) == 0 { + return "(none)" + } + return strings.Join(formatted, " ") +} + +func confirmNewInstallCLI(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + if reader == nil { + reader = bufio.NewReader(os.Stdin) + } + + fmt.Println() + fmt.Println("--- New installation reset ---") + fmt.Printf("Base directory: %s\n", plan.BaseDir) + fmt.Printf("Build signature: %s\n", plan.BuildSignature) + fmt.Printf("Preserved entries: %s\n", formatNewInstallPreservedEntries(plan.PreservedEntries)) + fmt.Println("Everything else under the base directory will be removed.") + + return promptYesNo(ctx, reader, "Continue? [y/N]: ", false) +} diff --git a/cmd/proxsave/new_install_test.go b/cmd/proxsave/new_install_test.go new file mode 100644 index 00000000..441a463b --- /dev/null +++ b/cmd/proxsave/new_install_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "bufio" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" +) + +func TestNewInstallPreservedEntries(t *testing.T) { + got := newInstallPreservedEntries() + want := []string{"build", "env", "identity"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("newInstallPreservedEntries() = %#v, want %#v", got, want) + } +} + +func TestBuildNewInstallPlan(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + + plan, err := buildNewInstallPlan(configPath) + if err != nil { + t.Fatalf("buildNewInstallPlan error: %v", err) + } + if plan.ResolvedConfigPath != configPath { + t.Fatalf("resolved config path = %q, want %q", plan.ResolvedConfigPath, configPath) + } + if plan.BaseDir != baseDir { + t.Fatalf("base dir = %q, want %q", plan.BaseDir, baseDir) + } + if strings.TrimSpace(plan.BuildSignature) == "" { + t.Fatalf("build signature should not be empty") + } + if !reflect.DeepEqual(plan.PreservedEntries, newInstallPreservedEntries()) { + t.Fatalf("preserved entries = %#v, want %#v", plan.PreservedEntries, newInstallPreservedEntries()) + } +} + +func TestConfirmNewInstallCLIContinue(t *testing.T) { + plan := newInstallPlan{ + BaseDir: "/opt/proxsave", + BuildSignature: "sig-123", + PreservedEntries: []string{"build", "env", "identity"}, + } + + reader := bufio.NewReader(strings.NewReader("y\n")) + var confirmed bool + var err error + output := captureStdout(t, func() { + confirmed, err = confirmNewInstallCLI(context.Background(), reader, plan) + }) + if err != nil { + t.Fatalf("confirmNewInstallCLI error: %v", err) + } + if !confirmed { + t.Fatalf("expected confirmation=true") + } + if !strings.Contains(output, "Preserved entries: build/ env/ identity/") { + t.Fatalf("expected preserved entries output, got %q", output) + } +} + +func TestConfirmNewInstallCLIContextCancelled(t *testing.T) { + plan := newInstallPlan{ + BaseDir: "/opt/proxsave", + BuildSignature: "sig-123", + PreservedEntries: []string{"build", "env", "identity"}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := confirmNewInstallCLI(ctx, bufio.NewReader(strings.NewReader("y\n")), plan) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } +} + +func TestRunNewInstallCLIUsesCLIConfirmOnly(t *testing.T) { + originalEnsure := newInstallEnsureInteractiveStdin + originalConfirmCLI := newInstallConfirmCLI + originalConfirmTUI := newInstallConfirmTUI + originalRunInstall := newInstallRunInstall + originalRunInstallTUI := newInstallRunInstallTUI + defer func() { + newInstallEnsureInteractiveStdin = originalEnsure + newInstallConfirmCLI = originalConfirmCLI + newInstallConfirmTUI = originalConfirmTUI + newInstallRunInstall = originalRunInstall + newInstallRunInstallTUI = originalRunInstallTUI + }() + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + stalePath := filepath.Join(baseDir, "stale.txt") + if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { + t.Fatalf("write stale marker: %v", err) + } + + newInstallEnsureInteractiveStdin = func() error { return nil } + + cliConfirmCalled := false + newInstallConfirmCLI = func(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + cliConfirmCalled = true + if plan.BaseDir != baseDir { + t.Fatalf("plan base dir = %q, want %q", plan.BaseDir, baseDir) + } + return true, nil + } + + newInstallConfirmTUI = func(baseDirArg, buildSig string, preservedEntries []string) (bool, error) { + t.Fatalf("TUI confirmation must not be called in --cli mode") + return false, nil + } + + runInstallCalled := false + newInstallRunInstall = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + runInstallCalled = true + if cfg != configPath { + t.Fatalf("runInstall config path = %q, want %q", cfg, configPath) + } + return nil + } + newInstallRunInstallTUI = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstallTUI must not be called in --cli mode") + return nil + } + + if err := runNewInstall(context.Background(), configPath, logging.NewBootstrapLogger(), true); err != nil { + t.Fatalf("runNewInstall error: %v", err) + } + if !cliConfirmCalled { + t.Fatalf("expected CLI confirmation to be called") + } + if !runInstallCalled { + t.Fatalf("expected runInstall to be called") + } + if _, err := os.Stat(stalePath); !os.IsNotExist(err) { + t.Fatalf("expected stale marker to be removed by reset, got err=%v", err) + } +} + +func TestRunNewInstallCancelSkipsReset(t *testing.T) { + originalEnsure := newInstallEnsureInteractiveStdin + originalConfirmCLI := newInstallConfirmCLI + originalRunInstall := newInstallRunInstall + originalRunInstallTUI := newInstallRunInstallTUI + defer func() { + newInstallEnsureInteractiveStdin = originalEnsure + newInstallConfirmCLI = originalConfirmCLI + newInstallRunInstall = originalRunInstall + newInstallRunInstallTUI = originalRunInstallTUI + }() + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + markerPath := filepath.Join(baseDir, "marker.txt") + if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + + newInstallEnsureInteractiveStdin = func() error { return nil } + newInstallConfirmCLI = func(ctx context.Context, reader *bufio.Reader, plan newInstallPlan) (bool, error) { + return false, nil + } + newInstallRunInstall = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstall must not be called on cancel") + return nil + } + newInstallRunInstallTUI = func(ctx context.Context, cfg string, bootstrap *logging.BootstrapLogger) error { + t.Fatalf("runInstallTUI must not be called on cancel") + return nil + } + + err := runNewInstall(context.Background(), configPath, logging.NewBootstrapLogger(), true) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive abort, got %v", err) + } + if _, statErr := os.Stat(markerPath); statErr != nil { + t.Fatalf("expected marker to remain after cancel, got %v", statErr) + } +} diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 9f4ff1f5..374a3195 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -631,7 +631,7 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --install - Re-run interactive installation/setup") - fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") + fmt.Println(" --new-install - Wipe installation directory (keep build/env/identity) then run installer") fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") fmt.Println() diff --git a/internal/tui/wizard/new_install.go b/internal/tui/wizard/new_install.go index e799db91..ba826a38 100644 --- a/internal/tui/wizard/new_install.go +++ b/internal/tui/wizard/new_install.go @@ -14,10 +14,26 @@ var confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) er return app.SetRoot(root, true).SetFocus(focus).Run() } +func formatPreservedEntries(entries []string) string { + formatted := make([]string, 0, len(entries)) + for _, entry := range entries { + trimmed := strings.TrimSpace(entry) + if trimmed == "" { + continue + } + formatted = append(formatted, trimmed+"/") + } + if len(formatted) == 0 { + return "(none)" + } + return strings.Join(formatted, " ") +} + // ConfirmNewInstall shows a TUI confirmation before wiping baseDir for --new-install. -func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { +func ConfirmNewInstall(baseDir string, buildSig string, preservedEntries []string) (bool, error) { app := tui.NewApp() proceed := false + preservedText := formatPreservedEntries(preservedEntries) // Header text (align with main install wizard) welcomeText := tview.NewTextView(). @@ -51,7 +67,7 @@ func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { // Confirmation modal modal := tview.NewModal(). - SetText(fmt.Sprintf("Base directory to reset:\n[yellow]%s[white]\n\nThis keeps [yellow]build/ env/ identity/[white]\nbut deletes everything else.\n\nContinue?", baseDir)). + SetText(fmt.Sprintf("Base directory to reset:\n[yellow]%s[white]\n\nThis keeps [yellow]%s[white]\nbut deletes everything else.\n\nContinue?", baseDir, preservedText)). AddButtons([]string{"Continue", "Cancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { if buttonLabel == "Continue" { @@ -83,8 +99,9 @@ func ConfirmNewInstall(baseDir string, buildSig string) (bool, error) { SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the app - ignore errors from normal app termination - _ = confirmNewInstallRunner(app, flex, modal) + if err := confirmNewInstallRunner(app, flex, modal); err != nil { + return false, err + } return proceed, nil } diff --git a/internal/tui/wizard/new_install_test.go b/internal/tui/wizard/new_install_test.go index ebe3b18c..a7267fcc 100644 --- a/internal/tui/wizard/new_install_test.go +++ b/internal/tui/wizard/new_install_test.go @@ -1,6 +1,7 @@ package wizard import ( + "errors" "strings" "testing" @@ -19,7 +20,7 @@ func TestConfirmNewInstallContinue(t *testing.T) { return nil } - proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123") + proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -38,7 +39,7 @@ func TestConfirmNewInstallCancel(t *testing.T) { return nil } - proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123") + proceed, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -57,7 +58,7 @@ func TestConfirmNewInstallMessageIncludesBaseDir(t *testing.T) { return nil } - _, err := ConfirmNewInstall("/var/lib/data", "build-sig") + _, err := ConfirmNewInstall("/var/lib/data", "build-sig", []string{"build", "env", "identity"}) if err != nil { t.Fatalf("ConfirmNewInstall error: %v", err) } @@ -65,3 +66,37 @@ func TestConfirmNewInstallMessageIncludesBaseDir(t *testing.T) { t.Fatalf("expected modal text to mention base dir, got %q", captured) } } + +func TestConfirmNewInstallMessageIncludesPreservedEntries(t *testing.T) { + originalRunner := confirmNewInstallRunner + defer func() { confirmNewInstallRunner = originalRunner }() + + var captured string + confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) error { + captured = extractModalText(focus.(*tview.Modal)) + return nil + } + + _, err := ConfirmNewInstall("/var/lib/data", "build-sig", []string{"build", "env", "identity"}) + if err != nil { + t.Fatalf("ConfirmNewInstall error: %v", err) + } + if !strings.Contains(captured, "build/ env/ identity/") { + t.Fatalf("expected modal text to mention preserved entries, got %q", captured) + } +} + +func TestConfirmNewInstallPropagatesRunnerError(t *testing.T) { + originalRunner := confirmNewInstallRunner + defer func() { confirmNewInstallRunner = originalRunner }() + + expectedErr := errors.New("runner failed") + confirmNewInstallRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expectedErr + } + + _, err := ConfirmNewInstall("/opt/proxmox", "sig-123", []string{"build", "env", "identity"}) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected error %v, got %v", expectedErr, err) + } +} From 51e9a6616086791274eae9e1b2b27f1f1224a610 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 14:04:06 +0100 Subject: [PATCH 03/29] Align existing backup.env handling across CLI and TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a shared decision flow for pre-existing backup.env with four explicit actions: Overwrite, Edit existing, Keep existing & continue, and Cancel. Update CLI prompts to support all modes (including Edit existing and explicit Cancel), update TUI action mapping to the same semantics, and treat “keep existing” as continue (not abort). Ensure TUI post-config steps are skipped consistently when configuration wizard is skipped (AGE setup, post-install audit, Telegram pairing), while finalization steps still run. Propagate CheckExistingConfig runner errors instead of swallowing them. Add/adjust unit tests for decision resolution, CLI prompts, TUI actions, runner error propagation, and prepareBaseTemplate behavior. Update INSTALL and CLI_REFERENCE docs to match the new aligned behavior. --- cmd/proxsave/install.go | 26 ++- cmd/proxsave/install_existing_config.go | 110 ++++++++++++ cmd/proxsave/install_existing_config_test.go | 170 +++++++++++++++++++ cmd/proxsave/install_test.go | 33 +++- cmd/proxsave/install_tui.go | 55 +++--- docs/CLI_REFERENCE.md | 8 +- docs/INSTALL.md | 15 +- internal/tui/wizard/install.go | 25 +-- internal/tui/wizard/install_test.go | 32 +++- 9 files changed, 415 insertions(+), 59 deletions(-) create mode 100644 cmd/proxsave/install_existing_config.go create mode 100644 cmd/proxsave/install_existing_config_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 7a042dad..5e971575 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -706,22 +706,18 @@ func printInstallBanner(configPath string) { } func prepareBaseTemplate(ctx context.Context, reader *bufio.Reader, configPath string) (string, bool, error) { - if info, err := os.Stat(configPath); err == nil { - if info.Mode().IsRegular() { - overwrite, err := promptYesNo(ctx, reader, fmt.Sprintf("%s already exists. Overwrite? [y/N]: ", configPath), false) - if err != nil { - return "", false, err - } - if !overwrite { - fmt.Println("Existing configuration detected, keeping current backup.env and skipping configuration wizard.") - return "", true, nil - } - } - } else if !os.IsNotExist(err) { - return "", false, fmt.Errorf("failed to access configuration file: %w", err) + decision, err := prepareExistingConfigDecisionCLI(ctx, reader, configPath) + if err != nil { + return "", false, err } - - return config.DefaultEnvTemplate(), false, nil + if decision.AbortInstall { + return "", false, errInteractiveAborted + } + if decision.SkipConfigWizard { + fmt.Println("Existing configuration detected, keeping current backup.env and skipping configuration wizard.") + return "", true, nil + } + return decision.BaseTemplate, false, nil } func configureSecondaryStorage(ctx context.Context, reader *bufio.Reader, template string) (string, error) { diff --git a/cmd/proxsave/install_existing_config.go b/cmd/proxsave/install_existing_config.go new file mode 100644 index 00000000..a243d1fc --- /dev/null +++ b/cmd/proxsave/install_existing_config.go @@ -0,0 +1,110 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/config" +) + +type existingConfigMode int + +const ( + existingConfigOverwrite existingConfigMode = iota + existingConfigEdit + existingConfigKeepContinue + existingConfigCancel +) + +type existingConfigDecision struct { + BaseTemplate string + SkipConfigWizard bool + AbortInstall bool +} + +func promptExistingConfigModeCLI(ctx context.Context, reader *bufio.Reader, configPath string) (existingConfigMode, error) { + info, err := os.Stat(configPath) + if err != nil { + if os.IsNotExist(err) { + return existingConfigOverwrite, nil + } + return existingConfigCancel, fmt.Errorf("failed to access configuration file: %w", err) + } + if !info.Mode().IsRegular() { + return existingConfigCancel, fmt.Errorf("configuration file path is not a regular file: %s", configPath) + } + + fmt.Printf("%s already exists.\n", configPath) + fmt.Println("Choose how to proceed:") + fmt.Println(" [1] Overwrite (start from embedded template)") + fmt.Println(" [2] Edit existing (use current file as base)") + fmt.Println(" [3] Keep existing & continue (skip configuration wizard)") + fmt.Println(" [0] Cancel installation") + + for { + choice, err := promptOptional(ctx, reader, "Choice [3]: ") + if err != nil { + return existingConfigCancel, err + } + switch strings.TrimSpace(choice) { + case "": + fallthrough + case "3": + return existingConfigKeepContinue, nil + case "1": + return existingConfigOverwrite, nil + case "2": + return existingConfigEdit, nil + case "0": + return existingConfigCancel, nil + default: + fmt.Println("Please enter 1, 2, 3 or 0.") + } + } +} + +func resolveExistingConfigDecision(mode existingConfigMode, configPath string) (existingConfigDecision, error) { + switch mode { + case existingConfigOverwrite: + return existingConfigDecision{ + BaseTemplate: config.DefaultEnvTemplate(), + SkipConfigWizard: false, + AbortInstall: false, + }, nil + case existingConfigEdit: + content, err := os.ReadFile(configPath) + if err != nil { + return existingConfigDecision{}, fmt.Errorf("read existing configuration: %w", err) + } + return existingConfigDecision{ + BaseTemplate: string(content), + SkipConfigWizard: false, + AbortInstall: false, + }, nil + case existingConfigKeepContinue: + return existingConfigDecision{ + BaseTemplate: "", + SkipConfigWizard: true, + AbortInstall: false, + }, nil + case existingConfigCancel: + return existingConfigDecision{ + BaseTemplate: "", + SkipConfigWizard: false, + AbortInstall: true, + }, nil + default: + return existingConfigDecision{}, fmt.Errorf("unsupported existing configuration mode: %d", mode) + } +} + +func prepareExistingConfigDecisionCLI(ctx context.Context, reader *bufio.Reader, configPath string) (existingConfigDecision, error) { + mode, err := promptExistingConfigModeCLI(ctx, reader, configPath) + if err != nil { + return existingConfigDecision{}, err + } + return resolveExistingConfigDecision(mode, configPath) +} diff --git a/cmd/proxsave/install_existing_config_test.go b/cmd/proxsave/install_existing_config_test.go new file mode 100644 index 00000000..8de7a58a --- /dev/null +++ b/cmd/proxsave/install_existing_config_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "bufio" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPromptExistingConfigModeCLIMissingFileDefaultsToOverwrite(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.env") + mode, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("")), missing) + if err != nil { + t.Fatalf("promptExistingConfigModeCLI error: %v", err) + } + if mode != existingConfigOverwrite { + t.Fatalf("expected overwrite mode, got %v", mode) + } +} + +func TestPromptExistingConfigModeCLIOptions(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + tests := []struct { + name string + input string + want existingConfigMode + }{ + {name: "default keep continue", input: "\n", want: existingConfigKeepContinue}, + {name: "overwrite", input: "1\n", want: existingConfigOverwrite}, + {name: "edit", input: "2\n", want: existingConfigEdit}, + {name: "keep continue", input: "3\n", want: existingConfigKeepContinue}, + {name: "cancel", input: "0\n", want: existingConfigCancel}, + {name: "invalid then overwrite", input: "x\n1\n", want: existingConfigOverwrite}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + reader := bufio.NewReader(strings.NewReader(tc.input)) + var mode existingConfigMode + var err error + captureStdout(t, func() { + mode, err = promptExistingConfigModeCLI(context.Background(), reader, cfgFile) + }) + if err != nil { + t.Fatalf("promptExistingConfigModeCLI error: %v", err) + } + if mode != tc.want { + t.Fatalf("mode = %v, want %v", mode, tc.want) + } + }) + } +} + +func TestResolveExistingConfigDecision(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + + overwrite, err := resolveExistingConfigDecision(existingConfigOverwrite, cfgFile) + if err != nil { + t.Fatalf("overwrite decision error: %v", err) + } + if overwrite.SkipConfigWizard || overwrite.AbortInstall { + t.Fatalf("overwrite decision flags are invalid: %+v", overwrite) + } + if strings.TrimSpace(overwrite.BaseTemplate) == "" { + t.Fatalf("overwrite base template should not be empty") + } + + edit, err := resolveExistingConfigDecision(existingConfigEdit, cfgFile) + if err != nil { + t.Fatalf("edit decision error: %v", err) + } + if edit.SkipConfigWizard || edit.AbortInstall { + t.Fatalf("edit decision flags are invalid: %+v", edit) + } + if !strings.Contains(edit.BaseTemplate, "EXISTING=1") { + t.Fatalf("expected existing content, got %q", edit.BaseTemplate) + } + + keep, err := resolveExistingConfigDecision(existingConfigKeepContinue, cfgFile) + if err != nil { + t.Fatalf("keep decision error: %v", err) + } + if !keep.SkipConfigWizard || keep.AbortInstall { + t.Fatalf("keep decision flags are invalid: %+v", keep) + } + + cancel, err := resolveExistingConfigDecision(existingConfigCancel, cfgFile) + if err != nil { + t.Fatalf("cancel decision error: %v", err) + } + if cancel.SkipConfigWizard || !cancel.AbortInstall { + t.Fatalf("cancel decision flags are invalid: %+v", cancel) + } +} + +func TestPrepareExistingConfigDecisionCLICancel(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("0\n")) + decision, err := prepareExistingConfigDecisionCLI(context.Background(), reader, cfgFile) + if err != nil { + t.Fatalf("prepareExistingConfigDecisionCLI error: %v", err) + } + if !decision.AbortInstall { + t.Fatalf("expected abort decision, got %+v", decision) + } +} + +func TestResolveExistingConfigDecisionEditReadError(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "missing.env") + _, err := resolveExistingConfigDecision(existingConfigEdit, cfgFile) + if err == nil { + t.Fatalf("expected read error for missing file") + } +} + +func TestPromptExistingConfigModeCLIPropagatesReadError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cfgFile := createTempFile(t, "EXISTING=1\n") + _, err := promptExistingConfigModeCLI(ctx, bufio.NewReader(strings.NewReader("1\n")), cfgFile) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive aborted error, got %v", err) + } +} + +func TestPromptExistingConfigModeCLINonRegularFile(t *testing.T) { + dirPath := t.TempDir() + _, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("1\n")), dirPath) + if err == nil { + t.Fatalf("expected error for non-regular file") + } + if !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestResolveExistingConfigDecisionUnsupportedMode(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + _, err := resolveExistingConfigDecision(existingConfigMode(99), cfgFile) + if err == nil { + t.Fatalf("expected unsupported mode error") + } +} + +func TestPromptExistingConfigModeCLIStatError(t *testing.T) { + pathWithNul := string([]byte{0}) + _, err := promptExistingConfigModeCLI(context.Background(), bufio.NewReader(strings.NewReader("1\n")), pathWithNul) + if err == nil { + t.Fatalf("expected stat error") + } +} + +func TestResolveExistingConfigDecisionEditExistingContentExact(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "backup.env") + content := "KEY=VALUE\nANOTHER=1\n" + if err := os.WriteFile(cfg, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + decision, err := resolveExistingConfigDecision(existingConfigEdit, cfg) + if err != nil { + t.Fatalf("resolveExistingConfigDecision error: %v", err) + } + if decision.BaseTemplate != content { + t.Fatalf("expected exact content, got %q", decision.BaseTemplate) + } +} diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index bc618e82..525b0ad6 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -215,7 +215,7 @@ func TestResetInstallBaseDirRefusesRoot(t *testing.T) { func TestPrepareBaseTemplateExistingSkip(t *testing.T) { cfgFile := createTempFile(t, "existing config") - reader := bufio.NewReader(strings.NewReader("n\n")) + reader := bufio.NewReader(strings.NewReader("3\n")) var tmpl string var skip bool var err error @@ -235,7 +235,7 @@ func TestPrepareBaseTemplateExistingSkip(t *testing.T) { func TestPrepareBaseTemplateOverwrite(t *testing.T) { cfgFile := createTempFile(t, "old") - reader := bufio.NewReader(strings.NewReader("y\n")) + reader := bufio.NewReader(strings.NewReader("1\n")) var tmpl string var skip bool var err error @@ -253,6 +253,35 @@ func TestPrepareBaseTemplateOverwrite(t *testing.T) { } } +func TestPrepareBaseTemplateEditExisting(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("2\n")) + var tmpl string + var skip bool + var err error + captureStdout(t, func() { + tmpl, skip, err = prepareBaseTemplate(context.Background(), reader, cfgFile) + }) + if err != nil { + t.Fatalf("prepareBaseTemplate error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for edit existing") + } + if !strings.Contains(tmpl, "EXISTING=1") { + t.Fatalf("expected existing template content, got %q", tmpl) + } +} + +func TestPrepareBaseTemplateCancel(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + reader := bufio.NewReader(strings.NewReader("0\n")) + _, _, err := prepareBaseTemplate(context.Background(), reader, cfgFile) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected interactive abort, got %v", err) + } +} + func TestConfigureSecondaryStorageEnabled(t *testing.T) { var result string var err error diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 93fb39c7..de2d841a 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -75,9 +75,12 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo baseTemplate := "" switch existingAction { - case wizard.ExistingConfigSkip: - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "user skipped configuration") + case wizard.ExistingConfigCancel: + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "user cancelled installation") return wrapInstallError(errInteractiveAborted) + case wizard.ExistingConfigKeepContinue: + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "using existing configuration and skipping wizard") + skipConfigWizard = true case wizard.ExistingConfigEdit: logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "editing existing configuration") content, readErr := os.ReadFile(configPath) @@ -181,28 +184,30 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo // Optional post-install audit: run a dry-run and offer to disable unused collectors // based on actionable warning hints like "set BACKUP_*=false to disable". - auditRes, auditErr := wizard.RunPostInstallAuditWizard(ctx, execInfo.ExecPath, configPath, buildSig) - if bootstrap != nil { - if auditErr != nil { - bootstrap.Warning("Post-install check failed (non-blocking): %v", auditErr) - } else { - switch { - case !auditRes.Ran: - bootstrap.Info("Post-install audit: skipped by user") - case auditRes.CollectErr != nil: - bootstrap.Warning("Post-install audit failed (non-blocking): %v", auditRes.CollectErr) - case len(auditRes.Suggestions) == 0: - bootstrap.Info("Post-install audit: no unused components detected") - default: - keys := make([]string, 0, len(auditRes.Suggestions)) - for _, s := range auditRes.Suggestions { - keys = append(keys, s.Key) - } - bootstrap.Info("Post-install audit: suggested disables (%d): %s", len(keys), strings.Join(keys, ", ")) - if len(auditRes.AppliedKeys) > 0 { - bootstrap.Info("Post-install audit: disabled (%d): %s", len(auditRes.AppliedKeys), strings.Join(auditRes.AppliedKeys, ", ")) - } else { - bootstrap.Info("Post-install audit: no disables applied") + if !skipConfigWizard { + auditRes, auditErr := wizard.RunPostInstallAuditWizard(ctx, execInfo.ExecPath, configPath, buildSig) + if bootstrap != nil { + if auditErr != nil { + bootstrap.Warning("Post-install check failed (non-blocking): %v", auditErr) + } else { + switch { + case !auditRes.Ran: + bootstrap.Info("Post-install audit: skipped by user") + case auditRes.CollectErr != nil: + bootstrap.Warning("Post-install audit failed (non-blocking): %v", auditRes.CollectErr) + case len(auditRes.Suggestions) == 0: + bootstrap.Info("Post-install audit: no unused components detected") + default: + keys := make([]string, 0, len(auditRes.Suggestions)) + for _, s := range auditRes.Suggestions { + keys = append(keys, s.Key) + } + bootstrap.Info("Post-install audit: suggested disables (%d): %s", len(keys), strings.Join(keys, ", ")) + if len(auditRes.AppliedKeys) > 0 { + bootstrap.Info("Post-install audit: disabled (%d): %s", len(auditRes.AppliedKeys), strings.Join(auditRes.AppliedKeys, ", ")) + } else { + bootstrap.Info("Post-install audit: no disables applied") + } } } } @@ -210,7 +215,7 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo // Telegram setup (centralized bot): if enabled during install, guide the user through // pairing and allow an explicit verification step with retry + skip. - if wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") { + if !skipConfigWizard && wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") { telegramRes, telegramErr := wizard.RunTelegramSetupWizard(ctx, baseDir, configPath, buildSig) if telegramErr != nil && bootstrap != nil { bootstrap.Warning("Telegram setup failed (non-blocking): %v", telegramErr) diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 5b69b537..0b62735b 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -131,8 +131,12 @@ Some interactive commands support two interface modes: **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **Existing configuration**: -- If the configuration file already exists, the **TUI wizard** prompts you to **Overwrite**, **Edit existing** (uses the current file as base and pre-fills the wizard fields), or **Keep & exit**. -- In **CLI mode** (`--cli`), you will be prompted to overwrite; choosing "No" keeps the file and skips the configuration wizard. +- If the configuration file already exists, **both TUI and CLI** now offer the same choices: + - **Overwrite** (start from embedded template) + - **Edit existing** (use current file as base and pre-fill wizard fields) + - **Keep existing & continue** (leave file untouched and skip configuration wizard) + - **Cancel** (abort installation) +- In **Keep existing & continue** mode, config-dependent post-steps are skipped (encryption setup, post-install audit, Telegram pairing), while finalization steps still run (docs install, symlink/cron finalization, permissions normalization). **Wizard workflow**: 1. Generates/updates the configuration file (`configs/backup.env` by default) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 43b7954e..fe02893b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -207,10 +207,21 @@ The installation wizard creates your configuration file interactively: ./build/proxsave --new-install ``` -If the configuration file already exists, the **TUI wizard** will ask whether to: +If the configuration file already exists, **both TUI and CLI** ask whether to: - **Overwrite** (start from the embedded template) - **Edit existing** (use the current file as base and pre-fill the wizard fields) -- **Keep & exit** (leave the file untouched and exit) +- **Keep existing & continue** (leave the file untouched and skip the configuration wizard) +- **Cancel** (exit installation) + +In **Keep existing & continue** mode, config-dependent post-steps are skipped: +- AGE setup +- Post-install check wizard +- Telegram pairing wizard + +Final install steps still run: +- Support docs installation +- Symlink and cron finalization +- Permission normalization **Wizard prompts:** diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index cab928a9..b81269d9 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -51,9 +51,10 @@ type InstallWizardData struct { type ExistingConfigAction int const ( - ExistingConfigOverwrite ExistingConfigAction = iota // Start from embedded template (overwrite) - ExistingConfigEdit // Keep existing file as base and edit - ExistingConfigSkip // Leave the file untouched and skip wizard + ExistingConfigOverwrite ExistingConfigAction = iota // Start from embedded template (overwrite) + ExistingConfigEdit // Keep existing file as base and edit + ExistingConfigKeepContinue // Leave file untouched and continue installation + ExistingConfigCancel // Abort installation ) var ( @@ -698,7 +699,7 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi if _, err := os.Stat(configPath); err == nil { // File exists, ask how to proceed app := tui.NewApp() - action := ExistingConfigSkip + action := ExistingConfigCancel // Welcome text (same as main wizard) welcomeText := tview.NewTextView(). @@ -737,16 +738,19 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi "Choose how to proceed:\n"+ "[yellow]Overwrite[white] - Start from embedded template\n"+ "[yellow]Edit existing[white] - Keep current file as base\n"+ - "[yellow]Keep & exit[white] - Leave file untouched, exit wizard", configPath)). - AddButtons([]string{"Overwrite", "Edit existing", "Keep & exit"}). + "[yellow]Keep & continue[white] - Leave file untouched, continue install\n"+ + "[yellow]Cancel[white] - Exit installation", configPath)). + AddButtons([]string{"Overwrite", "Edit existing", "Keep & continue", "Cancel"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonLabel { case "Overwrite": action = ExistingConfigOverwrite case "Edit existing": action = ExistingConfigEdit + case "Keep & continue": + action = ExistingConfigKeepContinue default: - action = ExistingConfigSkip + action = ExistingConfigCancel } app.Stop() }) @@ -774,12 +778,13 @@ func CheckExistingConfig(configPath string, buildSig string) (ExistingConfigActi SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the modal - ignore errors from normal app termination - _ = checkExistingConfigRunner(app, flex, modal) + if err := checkExistingConfigRunner(app, flex, modal); err != nil { + return ExistingConfigCancel, err + } return action, nil } else if !os.IsNotExist(err) { - return ExistingConfigSkip, err + return ExistingConfigCancel, err } return ExistingConfigOverwrite, nil // File doesn't exist, proceed diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index 8f8641ed..e2a04076 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -1,6 +1,7 @@ package wizard import ( + "errors" "os" "path/filepath" "strings" @@ -205,7 +206,8 @@ func TestCheckExistingConfigActions(t *testing.T) { }{ {name: "overwrite", button: "Overwrite", want: ExistingConfigOverwrite}, {name: "edit existing", button: "Edit existing", want: ExistingConfigEdit}, - {name: "keep", button: "Keep & exit", want: ExistingConfigSkip}, + {name: "keep continue", button: "Keep & continue", want: ExistingConfigKeepContinue}, + {name: "cancel", button: "Cancel", want: ExistingConfigCancel}, } for _, tc := range tests { @@ -245,7 +247,31 @@ func TestCheckExistingConfigPropagatesStatErrors(t *testing.T) { if err == nil { t.Fatalf("expected error for invalid path") } - if action != ExistingConfigSkip { - t.Fatalf("expected skip action on stat error, got %v", action) + if action != ExistingConfigCancel { + t.Fatalf("expected cancel action on stat error, got %v", action) + } +} + +func TestCheckExistingConfigPropagatesRunnerErrors(t *testing.T) { + tmp := t.TempDir() + configPath := filepath.Join(tmp, "prox.env") + if err := os.WriteFile(configPath, []byte("base"), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + originalRunner := checkExistingConfigRunner + t.Cleanup(func() { checkExistingConfigRunner = originalRunner }) + + expectedErr := errors.New("ui runner failure") + checkExistingConfigRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expectedErr + } + + action, err := CheckExistingConfig(configPath, "sig") + if !errors.Is(err, expectedErr) { + t.Fatalf("expected runner error %v, got %v", expectedErr, err) + } + if action != ExistingConfigCancel { + t.Fatalf("expected cancel action on runner error, got %v", action) } } From 62734f463680b5ce08e2b7c8e73656994731b6fd Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 15:05:20 +0100 Subject: [PATCH 04/29] Fix AGE setup validation and install TUI messaging alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten AGE setup consistency after the shared CLI/TUI refactor. Reuse a shared private-key validator so the TUI rejects malformed AGE identities before they reach the orchestrator, eliminating silent retry loops. Extend the AGE setup workflow to return explicit outcome details (recipient path, wrote file vs reused existing recipients) and update install TUI messaging to report “saved” only on real writes, while showing reuse clearly when existing recipient configuration is kept. Add regression coverage for private-key validation, reuse-vs-write setup results, and the updated TUI wizard behavior. --- cmd/proxsave/encryption_setup.go | 60 +++++ cmd/proxsave/encryption_setup_test.go | 198 ++++++++++++++++ cmd/proxsave/install.go | 22 -- cmd/proxsave/install_tui.go | 68 +----- cmd/proxsave/newkey.go | 103 +++------ docs/CLI_REFERENCE.md | 4 +- docs/ENCRYPTION.md | 4 +- internal/orchestrator/age_setup_ui.go | 24 ++ internal/orchestrator/age_setup_ui_cli.go | 86 +++++++ internal/orchestrator/age_setup_workflow.go | 214 ++++++++++++++++++ .../orchestrator/age_setup_workflow_test.go | 135 +++++++++++ internal/orchestrator/encryption.go | 171 ++++---------- .../orchestrator/encryption_exported_test.go | 14 +- internal/tui/wizard/age.go | 9 +- internal/tui/wizard/age_test.go | 32 ++- internal/tui/wizard/age_ui_adapter.go | 62 +++++ internal/tui/wizard/age_ui_adapter_test.go | 50 ++++ 17 files changed, 954 insertions(+), 302 deletions(-) create mode 100644 cmd/proxsave/encryption_setup.go create mode 100644 cmd/proxsave/encryption_setup_test.go create mode 100644 internal/orchestrator/age_setup_ui.go create mode 100644 internal/orchestrator/age_setup_ui_cli.go create mode 100644 internal/orchestrator/age_setup_workflow.go create mode 100644 internal/orchestrator/age_setup_workflow_test.go create mode 100644 internal/tui/wizard/age_ui_adapter.go create mode 100644 internal/tui/wizard/age_ui_adapter_test.go diff --git a/cmd/proxsave/encryption_setup.go b/cmd/proxsave/encryption_setup.go new file mode 100644 index 00000000..0d493c36 --- /dev/null +++ b/cmd/proxsave/encryption_setup.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type encryptionSetupResult struct { + Config *config.Config + RecipientPath string + WroteRecipientFile bool + ReusedExistingRecipients bool +} + +func runInitialEncryptionSetupWithUI(ctx context.Context, configPath string, ui orchestrator.AgeSetupUI) (*encryptionSetupResult, error) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return nil, fmt.Errorf("failed to reload configuration after install: %w", err) + } + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + + orch := orchestrator.New(logger, false) + orch.SetConfig(cfg) + + var setupResult *orchestrator.AgeRecipientSetupResult + if ui != nil { + setupResult, err = orch.EnsureAgeRecipientsReadyWithUIDetails(ctx, ui) + } else { + setupResult, err = orch.EnsureAgeRecipientsReadyWithDetails(ctx) + } + if err != nil { + if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + return nil, fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) + } + return nil, fmt.Errorf("encryption setup failed: %w", err) + } + + result := &encryptionSetupResult{Config: cfg} + if setupResult != nil { + result.RecipientPath = setupResult.RecipientPath + result.WroteRecipientFile = setupResult.WroteRecipientFile + result.ReusedExistingRecipients = setupResult.ReusedExistingRecipients + } + + return result, nil +} + +func runInitialEncryptionSetup(ctx context.Context, configPath string) error { + _, err := runInitialEncryptionSetupWithUI(ctx, configPath, nil) + return err +} diff --git a/cmd/proxsave/encryption_setup_test.go b/cmd/proxsave/encryption_setup_test.go new file mode 100644 index 00000000..df348d02 --- /dev/null +++ b/cmd/proxsave/encryption_setup_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +type testAgeSetupUI struct { + overwrite bool + drafts []*orchestrator.AgeRecipientDraft + addMore []bool +} + +func (u *testAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + return u.overwrite, nil +} + +func (u *testAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*orchestrator.AgeRecipientDraft, error) { + if len(u.drafts) == 0 { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + draft := u.drafts[0] + u.drafts = u.drafts[1:] + return draft, nil +} + +func (u *testAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + if len(u.addMore) == 0 { + return false, nil + } + next := u.addMore[0] + u.addMore = u.addMore[1:] + return next, nil +} + +func TestRunInitialEncryptionSetupWithUIReloadsConfig(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT=" + id.Recipient().String() + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, nil) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + if result == nil || result.Config == nil { + t.Fatalf("expected config result") + } + if len(result.Config.AgeRecipients) != 1 || result.Config.AgeRecipients[0] != id.Recipient().String() { + t.Fatalf("AgeRecipients=%v; want [%s]", result.Config.AgeRecipients, id.Recipient().String()) + } + if !result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=true") + } + if result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=false") + } + if result.RecipientPath != "" { + t.Fatalf("RecipientPath=%q; want empty for reuse-only result", result.RecipientPath) + } +} + +func TestRunInitialEncryptionSetupWithUIUsesProvidedUI(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ui := &testAgeSetupUI{ + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, ui) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + + expectedPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if result == nil || result.Config == nil { + t.Fatalf("expected setup result with config") + } + if result.RecipientPath != expectedPath { + t.Fatalf("RecipientPath=%q; want %q", result.RecipientPath, expectedPath) + } + if !result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=true") + } + if result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=false") + } + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected recipient file at %s: %v", expectedPath, err) + } +} + +func TestRunInitialEncryptionSetupWithUIReusesExistingFileWithoutReportingWrite(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if err := os.MkdirAll(filepath.Dir(recipientPath), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(recipientPath, []byte(id.Recipient().String()+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", recipientPath, err) + } + + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT_FILE=" + recipientPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + result, err := runInitialEncryptionSetupWithUI(context.Background(), configPath, nil) + if err != nil { + t.Fatalf("runInitialEncryptionSetupWithUI error: %v", err) + } + + if result == nil || result.Config == nil { + t.Fatalf("expected setup result with config") + } + if !result.ReusedExistingRecipients { + t.Fatalf("expected ReusedExistingRecipients=true") + } + if result.WroteRecipientFile { + t.Fatalf("expected WroteRecipientFile=false") + } + if result.RecipientPath != "" { + t.Fatalf("RecipientPath=%q; want empty for reuse-only result", result.RecipientPath) + } +} + +func TestRunNewKeySetupKeepsDefaultRecipientPathContract(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + ui := &testAgeSetupUI{ + overwrite: true, + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + if err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui); err != nil { + t.Fatalf("runNewKeySetup error: %v", err) + } + + target := filepath.Join(baseDir, "identity", "age", "recipient.txt") + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%s): %v", target, err) + } + if got := string(content); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } +} diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 5e971575..dd8d7168 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -16,9 +15,7 @@ import ( "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/notify" - "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui/wizard" - "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -870,25 +867,6 @@ func writeConfigFile(configPath, tmpConfigPath, content string) error { return nil } -func runInitialEncryptionSetup(ctx context.Context, configPath string) error { - cfg, err := config.LoadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to reload configuration after install: %w", err) - } - logger := logging.New(types.LogLevelError, false) - logger.SetOutput(io.Discard) - orch := orchestrator.New(logger, false) - orch.SetConfig(cfg) - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { - if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - // Treat AGE wizard abort as an interactive abort for install UX - return fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) - } - return fmt.Errorf("encryption setup failed: %w", err) - } - return nil -} - func wrapInstallError(err error) error { if err == nil { return nil diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index de2d841a..441463e0 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -5,14 +5,10 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" - "filippo.io/age" - "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui/wizard" ) @@ -139,46 +135,18 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo if bootstrap != nil { bootstrap.Info("Running initial encryption setup (AGE recipients)") } - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "running AGE setup wizard") - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") - ageData, err := wizard.RunAgeSetupWizard(ctx, recipientPath, configPath, buildSig) + logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "running AGE setup via orchestrator") + setupResult, err := runInitialEncryptionSetupWithUI(ctx, configPath, wizard.NewAgeSetupUI(configPath, buildSig)) if err != nil { - if errors.Is(err, wizard.ErrAgeSetupCancelled) { - return fmt.Errorf("encryption setup aborted by user: %w", errInteractiveAborted) - } else { - return fmt.Errorf("AGE setup failed: %w", err) - } - } - - // Process the AGE data based on setup type - var recipientKey string - switch ageData.SetupType { - case "existing": - recipientKey = ageData.PublicKey - case "passphrase": - // Derive recipient from passphrase - recipient, err := deriveRecipientFromPassphrase(ageData.Passphrase) - if err != nil { - return fmt.Errorf("failed to derive recipient from passphrase: %w", err) - } - recipientKey = recipient - case "privatekey": - // Derive recipient from private key - recipient, err := deriveRecipientFromPrivateKey(ageData.PrivateKey) - if err != nil { - return fmt.Errorf("failed to derive recipient from private key: %w", err) - } - recipientKey = recipient - } - - // Save the recipient - logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "saving AGE recipient") - if err := wizard.SaveAgeRecipient(recipientPath, recipientKey); err != nil { - return fmt.Errorf("failed to save AGE recipient: %w", err) + return err } bootstrap.Info("AGE encryption configured successfully") - bootstrap.Info("Recipient saved to: %s", recipientPath) + if setupResult.WroteRecipientFile && setupResult.RecipientPath != "" { + bootstrap.Info("Recipient saved to: %s", setupResult.RecipientPath) + } else if setupResult.ReusedExistingRecipients { + bootstrap.Info("Using existing AGE recipient configuration") + } bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") } @@ -280,23 +248,3 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo return nil } - -// deriveRecipientFromPassphrase derives a deterministic AGE recipient from a passphrase -func deriveRecipientFromPassphrase(passphrase string) (string, error) { - return orchestrator.DeriveDeterministicRecipientFromPassphrase(passphrase) -} - -// deriveRecipientFromPrivateKey derives the recipient (public key) from an AGE private key -func deriveRecipientFromPrivateKey(privateKey string) (string, error) { - privateKey = strings.TrimSpace(privateKey) - if privateKey == "" { - return "", fmt.Errorf("private key cannot be empty") - } - - identity, err := age.ParseX25519Identity(privateKey) - if err != nil { - return "", fmt.Errorf("invalid AGE private key: %w", err) - } - - return identity.Recipient().String(), nil -} diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go index 9099f652..6a680658 100644 --- a/cmd/proxsave/newkey.go +++ b/cmd/proxsave/newkey.go @@ -83,75 +83,21 @@ func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *lo done := logging.DebugStartBootstrap(bootstrap, "newkey workflow (tui)", "recipient=%s", recipientPath) defer func() { done(err) }() - // If a recipient already exists, ask for confirmation before overwriting - if _, err := os.Stat(recipientPath); err == nil { - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "existing recipient found") - confirm, err := wizard.ConfirmRecipientOverwrite(recipientPath, configPath, sig) - if err != nil { - return err - } - if !confirm { - return wrapInstallError(errInteractiveAborted) - } - if err := orchestrator.BackupAgeRecipientFile(recipientPath); err != nil && bootstrap != nil { - bootstrap.Warning("WARNING: %v", err) - } + logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "running AGE setup via orchestrator") + if err := runNewKeySetup(ctx, configPath, baseDir, logging.GetDefaultLogger(), wizard.NewAgeSetupUI(configPath, sig)); err != nil { + return err } - recipients := make([]string, 0, 2) - for { - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "running AGE setup wizard") - ageData, err := wizard.RunAgeSetupWizard(ctx, recipientPath, configPath, sig) - if err != nil { - if errors.Is(err, wizard.ErrAgeSetupCancelled) { - return wrapInstallError(errInteractiveAborted) - } - return fmt.Errorf("AGE setup failed: %w", err) - } - - // Process the AGE data based on setup type - var recipientKey string - switch ageData.SetupType { - case "existing": - recipientKey = ageData.PublicKey - case "passphrase": - recipient, err := deriveRecipientFromPassphrase(ageData.Passphrase) - if err != nil { - return fmt.Errorf("failed to derive recipient from passphrase: %w", err) - } - recipientKey = recipient - case "privatekey": - recipient, err := deriveRecipientFromPrivateKey(ageData.PrivateKey) - if err != nil { - return fmt.Errorf("failed to derive recipient from private key: %w", err) - } - recipientKey = recipient - default: - return fmt.Errorf("unknown AGE setup type: %s", ageData.SetupType) - } - - if err := orchestrator.ValidateRecipientString(recipientKey); err != nil { - return fmt.Errorf("invalid recipient: %w", err) - } - recipients = append(recipients, recipientKey) + bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) + bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "recipient count=%d", len(recipients)) - addMore, err := wizard.ConfirmAddRecipient(configPath, sig, len(recipients)) - if err != nil { - return err - } - if !addMore { - break - } - } + return nil +} - recipients = orchestrator.DedupeRecipientStrings(recipients) - if len(recipients) == 0 { - return fmt.Errorf("no AGE recipients provided") - } - logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "saving recipients") - if err := orchestrator.WriteRecipientFile(recipientPath, recipients); err != nil { - return fmt.Errorf("failed to save AGE recipients: %w", err) +func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *logging.Logger, bootstrap *logging.BootstrapLogger) error { + recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if err := runNewKeySetup(ctx, configPath, baseDir, logger, nil); err != nil { + return err } bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) @@ -160,7 +106,14 @@ func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *lo return nil } -func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *logging.Logger, bootstrap *logging.BootstrapLogger) error { +func modeLabel(useCLI bool) string { + if useCLI { + return "cli" + } + return "tui" +} + +func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *logging.Logger, ui orchestrator.AgeSetupUI) error { recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") cfg := &config.Config{ @@ -179,22 +132,18 @@ func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *loggi orch.SetConfig(cfg) orch.SetForceNewAgeRecipient(true) - if err := orch.EnsureAgeRecipientsReady(ctx); err != nil { + var err error + if ui != nil { + err = orch.EnsureAgeRecipientsReadyWithUI(ctx, ui) + } else { + err = orch.EnsureAgeRecipientsReady(ctx) + } + if err != nil { if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { return wrapInstallError(errInteractiveAborted) } return fmt.Errorf("AGE setup failed: %w", err) } - bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") - return nil } - -func modeLabel(useCLI bool) string { - if useCLI { - return "cli" - } - return "tui" -} diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 0b62735b..7e71de41 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -350,7 +350,7 @@ Next step: ./build/proxsave --dry-run # TUI mode (default) - terminal interface ./build/proxsave --newkey -# CLI mode - text prompts (for debugging) +# CLI mode - text prompts (for debugging or when TUI rendering is unavailable) ./build/proxsave --newkey --cli ``` @@ -364,7 +364,7 @@ Next step: ./build/proxsave --dry-run - **Private key-derived**: paste an `AGE-SECRET-KEY-...` key (not stored; proxsave stores only the derived public recipient) 3. Writes/overwrites the recipient file after confirmation -**Note**: In `--cli` mode (text prompts), you can add multiple recipients. The default TUI flow saves a single recipient; you can always add more by editing the recipient file (one per line). +**Note**: Both CLI and TUI `--newkey` flows support adding multiple recipients and de-duplicate repeated entries before saving. **For complete encryption guide**, see: **[Encryption Guide](ENCRYPTION.md)** diff --git a/docs/ENCRYPTION.md b/docs/ENCRYPTION.md index 63dcad13..99fffc35 100644 --- a/docs/ENCRYPTION.md +++ b/docs/ENCRYPTION.md @@ -133,7 +133,7 @@ You can create/update recipients in two ways: # Dedicated wizard (TUI by default) ./build/proxsave --newkey -# Use CLI prompts instead of TUI (useful for debugging and multi-recipient setups) +# Use CLI prompts instead of TUI (useful for debugging or when TUI rendering is unavailable) ./build/proxsave --newkey --cli ``` @@ -147,7 +147,7 @@ If `ENCRYPT_ARCHIVE=true` and no recipients are configured, proxsave will start **Notes**: - Proxsave stores **only recipients** (public keys) in `${BASE_DIR}/identity/age/recipient.txt`. Keep private keys and passphrases offline. - `AGE_RECIPIENT` and `AGE_RECIPIENT_FILE` are **merged and de-duplicated**. -- The CLI setup supports multiple recipients; otherwise you can add multiple recipients by editing the file (one per line). +- Both TUI and CLI setup flows support multiple recipients and de-duplicate repeated entries before saving. --- diff --git a/internal/orchestrator/age_setup_ui.go b/internal/orchestrator/age_setup_ui.go new file mode 100644 index 00000000..172d2f0c --- /dev/null +++ b/internal/orchestrator/age_setup_ui.go @@ -0,0 +1,24 @@ +package orchestrator + +import "context" + +type AgeRecipientInputKind int + +const ( + AgeRecipientInputExisting AgeRecipientInputKind = iota + AgeRecipientInputPassphrase + AgeRecipientInputPrivateKey +) + +type AgeRecipientDraft struct { + Kind AgeRecipientInputKind + PublicKey string + Passphrase string + PrivateKey string +} + +type AgeSetupUI interface { + ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) + CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) + ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) +} diff --git a/internal/orchestrator/age_setup_ui_cli.go b/internal/orchestrator/age_setup_ui_cli.go new file mode 100644 index 00000000..b6a9b4a0 --- /dev/null +++ b/internal/orchestrator/age_setup_ui_cli.go @@ -0,0 +1,86 @@ +package orchestrator + +import ( + "bufio" + "context" + "fmt" + "os" + + "github.com/tis24dev/proxsave/internal/logging" +) + +type cliAgeSetupUI struct { + reader *bufio.Reader + logger *logging.Logger +} + +func newCLIAgeSetupUI(reader *bufio.Reader, logger *logging.Logger) AgeSetupUI { + if reader == nil { + reader = bufio.NewReader(os.Stdin) + } + return &cliAgeSetupUI{ + reader: reader, + logger: logger, + } +} + +func (u *cliAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + fmt.Printf("WARNING: this will remove the existing AGE recipients stored at %s. Existing backups remain decryptable with your old private key.\n", recipientPath) + return promptYesNoAge(ctx, u.reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) +} + +func (u *cliAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) { + for { + fmt.Println("\n[1] Use an existing AGE public key") + fmt.Println("[2] Generate an AGE public key using a personal passphrase/password - not stored on the server") + fmt.Println("[3] Generate an AGE public key from an existing personal private key - not stored on the server") + fmt.Println("[4] Exit setup") + + option, err := promptOptionAge(ctx, u.reader, "Select an option [1-4]: ") + if err != nil { + return nil, err + } + if option == "4" { + return nil, ErrAgeRecipientSetupAborted + } + + switch option { + case "1": + value, err := promptPublicRecipientAge(ctx, u.reader) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputExisting, PublicKey: value}, nil + case "2": + passphrase, err := promptAndConfirmPassphraseAge(ctx) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputPassphrase, Passphrase: passphrase}, nil + case "3": + privateKey, err := promptPrivateKeyValueAge(ctx) + if err != nil { + u.warn(err) + continue + } + return &AgeRecipientDraft{Kind: AgeRecipientInputPrivateKey, PrivateKey: privateKey}, nil + } + } +} + +func (u *cliAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + return promptYesNoAge(ctx, u.reader, "Add another recipient? [y/N]: ") +} + +func (u *cliAgeSetupUI) warn(err error) { + if err == nil { + return + } + if u.logger != nil { + u.logger.Warning("Encryption setup: %v", err) + return + } + fmt.Printf("WARNING: %v\n", err) +} diff --git a/internal/orchestrator/age_setup_workflow.go b/internal/orchestrator/age_setup_workflow.go new file mode 100644 index 00000000..e4a344e0 --- /dev/null +++ b/internal/orchestrator/age_setup_workflow.go @@ -0,0 +1,214 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "filippo.io/age" +) + +type AgeRecipientSetupResult struct { + RecipientPath string + WroteRecipientFile bool + ReusedExistingRecipients bool +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithUI(ctx context.Context, ui AgeSetupUI) error { + if o == nil || o.cfg == nil || !o.cfg.EncryptArchive { + return nil + } + _, _, err := o.prepareAgeRecipientsWithUI(ctx, ui) + return err +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithUIDetails(ctx context.Context, ui AgeSetupUI) (*AgeRecipientSetupResult, error) { + if o == nil || o.cfg == nil || !o.cfg.EncryptArchive { + return nil, nil + } + _, result, err := o.prepareAgeRecipientsWithUI(ctx, ui) + return result, err +} + +func (o *Orchestrator) EnsureAgeRecipientsReadyWithDetails(ctx context.Context) (*AgeRecipientSetupResult, error) { + return o.EnsureAgeRecipientsReadyWithUIDetails(ctx, nil) +} + +func (o *Orchestrator) prepareAgeRecipientsWithUI(ctx context.Context, ui AgeSetupUI) ([]age.Recipient, *AgeRecipientSetupResult, error) { + if o.cfg == nil || !o.cfg.EncryptArchive { + return nil, nil, nil + } + + if o.ageRecipientCache != nil && !o.forceNewAgeRecipient { + return cloneRecipients(o.ageRecipientCache), &AgeRecipientSetupResult{ReusedExistingRecipients: true}, nil + } + + recipients, candidatePath, err := o.collectRecipientStrings() + if err != nil { + return nil, nil, err + } + + result := &AgeRecipientSetupResult{} + if len(recipients) > 0 && !o.forceNewAgeRecipient { + result.ReusedExistingRecipients = true + } + + if len(recipients) == 0 { + if ui == nil { + if !o.isInteractiveShell() { + if o.logger != nil { + o.logger.Error("Encryption setup requires interaction. Run the script interactively to complete the AGE recipient setup, then re-run in automated mode.") + o.logger.Debug("HINT Set AGE_RECIPIENT or AGE_RECIPIENT_FILE to bypass the interactive setup and re-run.") + } + return nil, nil, fmt.Errorf("age recipients not configured") + } + ui = newCLIAgeSetupUI(nil, o.logger) + } + + wizardRecipients, setupResult, err := o.runAgeSetupWorkflow(ctx, candidatePath, ui) + if err != nil { + return nil, nil, err + } + recipients = append(recipients, wizardRecipients...) + result = setupResult + if o.cfg.AgeRecipientFile == "" { + o.cfg.AgeRecipientFile = setupResult.RecipientPath + } + } + + if len(recipients) == 0 { + return nil, nil, fmt.Errorf("no AGE recipients configured after setup") + } + + parsed, err := parseRecipientStrings(recipients) + if err != nil { + return nil, nil, err + } + o.ageRecipientCache = cloneRecipients(parsed) + o.forceNewAgeRecipient = false + return cloneRecipients(parsed), result, nil +} + +func (o *Orchestrator) runAgeSetupWorkflow(ctx context.Context, candidatePath string, ui AgeSetupUI) ([]string, *AgeRecipientSetupResult, error) { + targetPath := strings.TrimSpace(candidatePath) + if targetPath == "" { + targetPath = o.defaultAgeRecipientFile() + } + if targetPath == "" { + return nil, nil, fmt.Errorf("unable to determine default path for AGE recipients") + } + + if o.logger != nil { + o.logger.Info("Encryption setup: no AGE recipients found, starting interactive wizard") + } + + if o.forceNewAgeRecipient { + if _, err := os.Stat(targetPath); err == nil { + confirm, err := ui.ConfirmOverwriteExistingRecipient(ctx, targetPath) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if !confirm { + return nil, nil, ErrAgeRecipientSetupAborted + } + if err := backupExistingRecipientFile(targetPath); err != nil && o.logger != nil { + o.logger.Warning("NOTE: %v", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, nil, fmt.Errorf("failed to inspect existing AGE recipients at %s: %w", targetPath, err) + } + } + + recipients := make([]string, 0) + for { + draft, err := ui.CollectRecipientDraft(ctx, targetPath) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if draft == nil { + return nil, nil, ErrAgeRecipientSetupAborted + } + + value, err := resolveAgeRecipientDraft(draft) + if err != nil { + if o.logger != nil { + o.logger.Warning("Encryption setup: %v", err) + } + continue + } + recipients = append(recipients, value) + + more, err := ui.ConfirmAddAnotherRecipient(ctx, len(recipients)) + if err != nil { + return nil, nil, mapAgeSetupAbort(err) + } + if !more { + break + } + } + + recipients = dedupeRecipientStrings(recipients) + if len(recipients) == 0 { + return nil, nil, fmt.Errorf("no recipients provided") + } + + if err := writeRecipientFile(targetPath, recipients); err != nil { + return nil, nil, err + } + + if o.logger != nil { + o.logger.Info("Saved AGE recipient to %s", targetPath) + o.logger.Info("Reminder: keep the AGE private key offline; the server stores only recipients.") + } + return recipients, &AgeRecipientSetupResult{ + RecipientPath: targetPath, + WroteRecipientFile: true, + }, nil +} + +func resolveAgeRecipientDraft(draft *AgeRecipientDraft) (string, error) { + if draft == nil { + return "", fmt.Errorf("recipient draft is required") + } + + switch draft.Kind { + case AgeRecipientInputExisting: + value := strings.TrimSpace(draft.PublicKey) + if err := ValidateRecipientString(value); err != nil { + return "", err + } + return value, nil + case AgeRecipientInputPassphrase: + passphrase := strings.TrimSpace(draft.Passphrase) + defer resetString(&passphrase) + if passphrase == "" { + return "", fmt.Errorf("passphrase cannot be empty") + } + if err := validatePassphraseStrength([]byte(passphrase)); err != nil { + return "", err + } + recipient, err := deriveDeterministicRecipientFromPassphrase(passphrase) + if err != nil { + return "", err + } + return recipient, nil + case AgeRecipientInputPrivateKey: + privateKey := strings.TrimSpace(draft.PrivateKey) + defer resetString(&privateKey) + return ParseAgePrivateKeyRecipient(privateKey) + default: + return "", fmt.Errorf("unsupported AGE setup input kind: %d", draft.Kind) + } +} + +func mapAgeSetupAbort(err error) error { + if err == nil { + return nil + } + if errors.Is(err, ErrAgeRecipientSetupAborted) { + return ErrAgeRecipientSetupAborted + } + return err +} diff --git a/internal/orchestrator/age_setup_workflow_test.go b/internal/orchestrator/age_setup_workflow_test.go new file mode 100644 index 00000000..33a6730d --- /dev/null +++ b/internal/orchestrator/age_setup_workflow_test.go @@ -0,0 +1,135 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + + "github.com/tis24dev/proxsave/internal/config" +) + +type mockAgeSetupUI struct { + overwrite bool + drafts []*AgeRecipientDraft + addMore []bool + + overwriteCalls int + collectCalls int + addCalls int +} + +func (m *mockAgeSetupUI) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + m.overwriteCalls++ + return m.overwrite, nil +} + +func (m *mockAgeSetupUI) CollectRecipientDraft(ctx context.Context, recipientPath string) (*AgeRecipientDraft, error) { + m.collectCalls++ + if len(m.drafts) == 0 { + return nil, ErrAgeRecipientSetupAborted + } + draft := m.drafts[0] + m.drafts = m.drafts[1:] + return draft, nil +} + +func (m *mockAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + m.addCalls++ + if len(m.addMore) == 0 { + return false, nil + } + next := m.addMore[0] + m.addMore = m.addMore[1:] + return next, nil +} + +func TestEnsureAgeRecipientsReadyWithUI_ReusesConfiguredRecipientsWithoutPrompting(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + ui := &mockAgeSetupUI{} + orch := newEncryptionTestOrchestrator(&config.Config{ + EncryptArchive: true, + BaseDir: t.TempDir(), + AgeRecipients: []string{id.Recipient().String()}, + }) + + if err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui); err != nil { + t.Fatalf("EnsureAgeRecipientsReadyWithUI error: %v", err) + } + if ui.collectCalls != 0 || ui.overwriteCalls != 0 || ui.addCalls != 0 { + t.Fatalf("UI should not have been used when recipients already exist: %#v", ui) + } +} + +func TestEnsureAgeRecipientsReadyWithUI_ConfiguresRecipientsWithoutTTY(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + tmp := t.TempDir() + ui := &mockAgeSetupUI{ + drafts: []*AgeRecipientDraft{ + {Kind: AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + cfg := &config.Config{EncryptArchive: true, BaseDir: tmp} + orch := newEncryptionTestOrchestrator(cfg) + + if err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui); err != nil { + t.Fatalf("EnsureAgeRecipientsReadyWithUI error: %v", err) + } + + target := filepath.Join(tmp, "identity", "age", "recipient.txt") + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%s): %v", target, err) + } + if got := string(content); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } + if cfg.AgeRecipientFile != target { + t.Fatalf("AgeRecipientFile=%q; want %q", cfg.AgeRecipientFile, target) + } +} + +func TestEnsureAgeRecipientsReadyWithUI_ForceNewRecipientDeclineReturnsAbort(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "identity", "age", "recipient.txt") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ui := &mockAgeSetupUI{overwrite: false} + orch := newEncryptionTestOrchestrator(&config.Config{ + EncryptArchive: true, + BaseDir: tmp, + AgeRecipientFile: target, + }) + orch.SetForceNewAgeRecipient(true) + + err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui) + if !errors.Is(err, ErrAgeRecipientSetupAborted) { + t.Fatalf("err=%v; want %v", err, ErrAgeRecipientSetupAborted) + } + if ui.overwriteCalls != 1 { + t.Fatalf("overwriteCalls=%d; want 1", ui.overwriteCalls) + } + if ui.collectCalls != 0 { + t.Fatalf("collectCalls=%d; want 0", ui.collectCalls) + } + if _, statErr := os.Stat(target); statErr != nil { + t.Fatalf("recipient file should remain in place, stat err=%v", statErr) + } +} diff --git a/internal/orchestrator/encryption.go b/internal/orchestrator/encryption.go index aacfbb46..4d156769 100644 --- a/internal/orchestrator/encryption.go +++ b/internal/orchestrator/encryption.go @@ -58,47 +58,8 @@ func (o *Orchestrator) EnsureAgeRecipientsReady(ctx context.Context) error { } func (o *Orchestrator) prepareAgeRecipients(ctx context.Context) ([]age.Recipient, error) { - if o.cfg == nil || !o.cfg.EncryptArchive { - return nil, nil - } - - if o.ageRecipientCache != nil && !o.forceNewAgeRecipient { - return cloneRecipients(o.ageRecipientCache), nil - } - - recipients, candidatePath, err := o.collectRecipientStrings() - if err != nil { - return nil, err - } - - if len(recipients) == 0 { - if !o.isInteractiveShell() { - o.logger.Error("Encryption setup requires interaction. Run the script interactively to complete the AGE recipient setup, then re-run in automated mode.") - o.logger.Debug("HINT Set AGE_RECIPIENT or AGE_RECIPIENT_FILE to bypass the interactive setup and re-run.") - return nil, fmt.Errorf("age recipients not configured") - } - - wizardRecipients, savedPath, err := o.runAgeSetupWizard(ctx, candidatePath) - if err != nil { - return nil, err - } - recipients = append(recipients, wizardRecipients...) - if o.cfg.AgeRecipientFile == "" { - o.cfg.AgeRecipientFile = savedPath - } - } - - if len(recipients) == 0 { - return nil, fmt.Errorf("no AGE recipients configured after setup") - } - - parsed, err := parseRecipientStrings(recipients) - if err != nil { - return nil, err - } - o.ageRecipientCache = cloneRecipients(parsed) - o.forceNewAgeRecipient = false - return cloneRecipients(parsed), nil + recipients, _, err := o.prepareAgeRecipientsWithUI(ctx, nil) + return recipients, err } func (o *Orchestrator) collectRecipientStrings() ([]string, string, error) { @@ -129,94 +90,22 @@ func (o *Orchestrator) collectRecipientStrings() ([]string, string, error) { // runAgeSetupWizard collects AGE recipients interactively. // Returns (fileRecipients, savedPath, error) func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath string) ([]string, string, error) { - reader := bufio.NewReader(os.Stdin) - targetPath := candidatePath - if targetPath == "" { - targetPath = o.defaultAgeRecipientFile() - } - - o.logger.Info("Encryption setup: no AGE recipients found, starting interactive wizard") - if targetPath == "" { - return nil, "", fmt.Errorf("unable to determine default path for AGE recipients") + if o == nil { + return nil, "", fmt.Errorf("orchestrator is required") } - // Create a child context for the wizard to handle Ctrl+C locally wizardCtx, wizardCancel := context.WithCancel(ctx) defer wizardCancel() - recipientPath := targetPath - if o.forceNewAgeRecipient && recipientPath != "" { - if _, err := os.Stat(recipientPath); err == nil { - fmt.Printf("WARNING: this will remove the existing AGE recipients stored at %s. Existing backups remain decryptable with your old private key.\n", recipientPath) - confirm, errPrompt := promptYesNoAge(wizardCtx, reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) - if errPrompt != nil { - return nil, "", errPrompt - } - if !confirm { - return nil, "", fmt.Errorf("operation aborted by user") - } - if err := backupExistingRecipientFile(recipientPath); err != nil { - fmt.Printf("NOTE: %v\n", err) - } - } else if !errors.Is(err, os.ErrNotExist) { - return nil, "", fmt.Errorf("failed to inspect existing AGE recipients at %s: %w", recipientPath, err) - } - } - - recipients := make([]string, 0) - for { - fmt.Println("\n[1] Use an existing AGE public key") - fmt.Println("[2] Generate an AGE public key using a personal passphrase/password — not stored on the server") - fmt.Println("[3] Generate an AGE public key from an existing personal private key — not stored on the server") - fmt.Println("[4] Exit setup") - option, err := promptOptionAge(wizardCtx, reader, "Select an option [1-4]: ") - if err != nil { - return nil, "", err - } - if option == "4" { - return nil, "", ErrAgeRecipientSetupAborted - } - - var value string - switch option { - case "1": - value, err = promptPublicRecipientAge(wizardCtx, reader) - case "2": - value, err = promptPassphraseRecipientAge(wizardCtx) - if err == nil { - o.logger.Info("Derived deterministic AGE public key from passphrase (no secrets stored)") - } - case "3": - value, err = promptPrivateKeyRecipientAge(wizardCtx) - } - if err != nil { - o.logger.Warning("Encryption setup: %v", err) - continue - } - if value != "" { - recipients = append(recipients, value) - } - - more, err := promptYesNoAge(wizardCtx, reader, "Add another recipient? [y/N]: ") - if err != nil { - return nil, "", err - } - if !more { - break - } - } - - if len(recipients) == 0 { - return nil, "", fmt.Errorf("no recipients provided") - } - - if err := writeRecipientFile(targetPath, dedupeRecipientStrings(recipients)); err != nil { + recipients, result, err := o.runAgeSetupWorkflow(wizardCtx, candidatePath, newCLIAgeSetupUI(bufio.NewReader(os.Stdin), o.logger)) + if err != nil { return nil, "", err } - - o.logger.Info("Saved AGE recipient to %s", targetPath) - o.logger.Info("Reminder: keep the AGE private key offline; the server stores only recipients.") - return recipients, targetPath, nil + savedPath := "" + if result != nil { + savedPath = result.RecipientPath + } + return recipients, savedPath, nil } func (o *Orchestrator) defaultAgeRecipientFile() string { @@ -262,6 +151,16 @@ func promptPublicRecipientAge(ctx context.Context, reader *bufio.Reader) (string } func promptPrivateKeyRecipientAge(ctx context.Context) (string, error) { + secret, err := promptPrivateKeyValueAge(ctx) + if err != nil { + return "", err + } + defer resetString(&secret) + + return ParseAgePrivateKeyRecipient(secret) +} + +func promptPrivateKeyValueAge(ctx context.Context) (string, error) { fmt.Print("Paste your AGE private key (not stored; input is not echoed). Press Enter when done: ") secretBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() @@ -271,15 +170,14 @@ func promptPrivateKeyRecipientAge(ctx context.Context) (string, error) { defer zeroBytes(secretBytes) secret := strings.TrimSpace(string(secretBytes)) - defer resetString(&secret) if secret == "" { return "", fmt.Errorf("private key cannot be empty") } - identity, err := age.ParseX25519Identity(secret) - if err != nil { - return "", fmt.Errorf("invalid AGE private key: %w", err) + if err := ValidateAgePrivateKeyString(secret); err != nil { + resetString(&secret) + return "", err } - return identity.Recipient().String(), nil + return secret, nil } // promptPassphraseRecipient derives a deterministic AGE public key from a passphrase @@ -495,6 +393,25 @@ func ValidateRecipientString(value string) error { return err } +// ValidateAgePrivateKeyString checks whether a private AGE identity is valid. +func ValidateAgePrivateKeyString(value string) error { + _, err := ParseAgePrivateKeyRecipient(value) + return err +} + +// ParseAgePrivateKeyRecipient validates a private AGE identity and returns its public recipient. +func ParseAgePrivateKeyRecipient(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("private key cannot be empty") + } + identity, err := age.ParseX25519Identity(trimmed) + if err != nil { + return "", fmt.Errorf("invalid AGE private key: %w", err) + } + return identity.Recipient().String(), nil +} + // DedupeRecipientStrings removes empty values and duplicates from recipient strings. func DedupeRecipientStrings(values []string) []string { return dedupeRecipientStrings(values) diff --git a/internal/orchestrator/encryption_exported_test.go b/internal/orchestrator/encryption_exported_test.go index 912f991f..096b8f45 100644 --- a/internal/orchestrator/encryption_exported_test.go +++ b/internal/orchestrator/encryption_exported_test.go @@ -249,9 +249,13 @@ func TestRunAgeSetupWizard_ExitReturnsAborted(t *testing.T) { func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) { tmp := t.TempDir() + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } inputFile := filepath.Join(tmp, "stdin.txt") // Option 1 -> recipient -> no more recipients. - if err := os.WriteFile(inputFile, []byte("1\nage1alpha\nn\n"), 0o600); err != nil { + if err := os.WriteFile(inputFile, []byte("1\n"+id.Recipient().String()+"\nn\n"), 0o600); err != nil { t.Fatalf("write stdin: %v", err) } f, err := os.Open(inputFile) @@ -272,14 +276,14 @@ func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) { if savedPath == "" { t.Fatalf("expected saved path") } - if len(out) != 1 || out[0] != "age1alpha" { - t.Fatalf("out=%v; want %v", out, []string{"age1alpha"}) + if len(out) != 1 || out[0] != id.Recipient().String() { + t.Fatalf("out=%v; want %v", out, []string{id.Recipient().String()}) } data, err := os.ReadFile(savedPath) if err != nil { t.Fatalf("read saved: %v", err) } - if string(data) != "age1alpha\n" { - t.Fatalf("saved content=%q; want %q", string(data), "age1alpha\n") + if string(data) != id.Recipient().String()+"\n" { + t.Fatalf("saved content=%q; want %q", string(data), id.Recipient().String()+"\n") } } diff --git a/internal/tui/wizard/age.go b/internal/tui/wizard/age.go index 524fcf1c..35afd186 100644 --- a/internal/tui/wizard/age.go +++ b/internal/tui/wizard/age.go @@ -70,8 +70,8 @@ func validatePrivateKey(value string) (string, error) { if key == "" { return "", fmt.Errorf("private key cannot be empty") } - if !strings.HasPrefix(key, "AGE-SECRET-KEY-1") { - return "", fmt.Errorf("private key must start with 'AGE-SECRET-KEY-1'") + if err := orchestrator.ValidateAgePrivateKeyString(key); err != nil { + return "", err } return key, nil } @@ -486,8 +486,9 @@ func RunAgeSetupWizard(ctx context.Context, recipientPath, configPath, buildSig form.AddSubmitButton("Continue") form.AddCancelButton("Cancel") - // Run the app - ignore errors from normal app termination - _ = ageWizardRunner(app, flex, form.Form) + if err := ageWizardRunner(app, flex, form.Form); err != nil { + return nil, err + } if data == nil { return nil, ErrAgeSetupCancelled diff --git a/internal/tui/wizard/age_test.go b/internal/tui/wizard/age_test.go index 1a64a1fc..0e30c795 100644 --- a/internal/tui/wizard/age_test.go +++ b/internal/tui/wizard/age_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "filippo.io/age" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "golang.org/x/crypto/ssh" @@ -82,15 +83,21 @@ func TestValidatePassphrase(t *testing.T) { } func TestValidatePrivateKey(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + cases := []struct { name string input string want string wantErr bool }{ - {name: "valid", input: " AGE-SECRET-KEY-1abc ", want: "AGE-SECRET-KEY-1abc"}, + {name: "valid", input: " " + identity.String() + " ", want: identity.String()}, {name: "empty", input: "", wantErr: true}, {name: "wrong prefix", input: "SECRET", wantErr: true}, + {name: "invalid body", input: "AGE-SECRET-KEY-1invalid", wantErr: true}, } for _, tc := range cases { @@ -366,11 +373,16 @@ func TestRunAgeSetupWizardPassphrase(t *testing.T) { } func TestRunAgeSetupWizardPrivateKey(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + data, err := runAgeWizardTest(t, func(form *tview.Form) { drop := form.GetFormItem(0).(*tview.DropDown) drop.SetCurrentOption(2) privateField := form.GetFormItem(4).(*tview.InputField) - privateField.SetText("AGE-SECRET-KEY-1valid") + privateField.SetText(identity.String()) pressFormButton(t, form, "Continue") }) if err != nil { @@ -379,7 +391,7 @@ func TestRunAgeSetupWizardPrivateKey(t *testing.T) { if data.SetupType != "privatekey" { t.Fatalf("unexpected setup type: %s", data.SetupType) } - if data.PrivateKey != "AGE-SECRET-KEY-1valid" { + if data.PrivateKey != identity.String() { t.Fatalf("expected private key saved, got %q", data.PrivateKey) } if data.Passphrase != "" || data.PublicKey != "" { @@ -399,6 +411,20 @@ func TestRunAgeSetupWizardCancel(t *testing.T) { } } +func TestRunAgeSetupWizardRunnerError(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + expected := errors.New("boom") + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expected + } + + if _, err := RunAgeSetupWizard(context.Background(), "/tmp/recipient.age", "/etc/proxsave/config.env", "sig-test"); !errors.Is(err, expected) { + t.Fatalf("err=%v; want %v", err, expected) + } +} + func runAgeWizardTest(t *testing.T, configure func(form *tview.Form)) (*AgeSetupData, error) { t.Helper() originalRunner := ageWizardRunner diff --git a/internal/tui/wizard/age_ui_adapter.go b/internal/tui/wizard/age_ui_adapter.go new file mode 100644 index 00000000..cca07199 --- /dev/null +++ b/internal/tui/wizard/age_ui_adapter.go @@ -0,0 +1,62 @@ +package wizard + +import ( + "context" + "errors" + "fmt" + + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +type ageSetupUIAdapter struct { + configPath string + buildSig string +} + +func NewAgeSetupUI(configPath, buildSig string) orchestrator.AgeSetupUI { + return &ageSetupUIAdapter{ + configPath: configPath, + buildSig: buildSig, + } +} + +func (a *ageSetupUIAdapter) ConfirmOverwriteExistingRecipient(ctx context.Context, recipientPath string) (bool, error) { + return ConfirmRecipientOverwrite(recipientPath, a.configPath, a.buildSig) +} + +func (a *ageSetupUIAdapter) CollectRecipientDraft(ctx context.Context, recipientPath string) (*orchestrator.AgeRecipientDraft, error) { + data, err := RunAgeSetupWizard(ctx, recipientPath, a.configPath, a.buildSig) + if err != nil { + if errors.Is(err, ErrAgeSetupCancelled) { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + return nil, err + } + if data == nil { + return nil, orchestrator.ErrAgeRecipientSetupAborted + } + + switch data.SetupType { + case "existing": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputExisting, + PublicKey: data.PublicKey, + }, nil + case "passphrase": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputPassphrase, + Passphrase: data.Passphrase, + }, nil + case "privatekey": + return &orchestrator.AgeRecipientDraft{ + Kind: orchestrator.AgeRecipientInputPrivateKey, + PrivateKey: data.PrivateKey, + }, nil + default: + return nil, fmt.Errorf("unknown AGE setup type: %s", data.SetupType) + } +} + +func (a *ageSetupUIAdapter) ConfirmAddAnotherRecipient(ctx context.Context, currentCount int) (bool, error) { + return ConfirmAddRecipient(a.configPath, a.buildSig, currentCount) +} diff --git a/internal/tui/wizard/age_ui_adapter_test.go b/internal/tui/wizard/age_ui_adapter_test.go new file mode 100644 index 00000000..1babaf35 --- /dev/null +++ b/internal/tui/wizard/age_ui_adapter_test.go @@ -0,0 +1,50 @@ +package wizard + +import ( + "context" + "errors" + "testing" + + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/tui" +) + +func TestAgeSetupUIAdapterCollectRecipientDraftCancelMapsAbort(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form, ok := focus.(*tview.Form) + if !ok { + t.Fatalf("expected *tview.Form focus, got %T", focus) + } + pressFormButton(t, form, "Cancel") + return nil + } + + ui := NewAgeSetupUI("/etc/proxsave/config.env", "sig-test") + draft, err := ui.CollectRecipientDraft(context.Background(), "/tmp/recipient.age") + if !errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { + t.Fatalf("err=%v; want %v", err, orchestrator.ErrAgeRecipientSetupAborted) + } + if draft != nil { + t.Fatalf("draft=%+v; want nil", draft) + } +} + +func TestAgeSetupUIAdapterCollectRecipientDraftRunnerError(t *testing.T) { + originalRunner := ageWizardRunner + defer func() { ageWizardRunner = originalRunner }() + + expected := errors.New("boom") + ageWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return expected + } + + ui := NewAgeSetupUI("/etc/proxsave/config.env", "sig-test") + if _, err := ui.CollectRecipientDraft(context.Background(), "/tmp/recipient.age"); !errors.Is(err, expected) { + t.Fatalf("err=%v; want %v", err, expected) + } +} From 18803d52e95de6a55d99e42f91233539dcaea95d Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 17:01:25 +0100 Subject: [PATCH 05/29] Align Telegram setup flow across CLI and TUI Introduce a shared Telegram setup bootstrap so CLI and TUI use the same eligibility rules before showing pairing steps. Stop the TUI from falling back to raw backup.env parsing, skip Telegram setup consistently when config loading fails, personal mode is selected, or no Server ID is available, and centralize skip-reason logging in the command layer. Update the TUI install flow to log shared Telegram bootstrap outcomes, add dedicated tests for bootstrap/CLI/TUI behavior, align user-facing docs, and remove now-unreachable TUI branches left over from the old local decision logic. --- cmd/proxsave/install.go | 118 ------- cmd/proxsave/install_tui.go | 13 +- cmd/proxsave/telegram_setup_cli.go | 107 ++++++ cmd/proxsave/telegram_setup_cli_test.go | 184 +++++++++++ docs/CLI_REFERENCE.md | 2 +- docs/CONFIGURATION.md | 4 +- docs/INSTALL.md | 18 +- .../orchestrator/telegram_setup_bootstrap.go | 104 ++++++ .../telegram_setup_bootstrap_test.go | 212 ++++++++++++ internal/tui/wizard/telegram_setup_tui.go | 150 ++------- .../tui/wizard/telegram_setup_tui_test.go | 309 ++++++------------ 11 files changed, 749 insertions(+), 472 deletions(-) create mode 100644 cmd/proxsave/telegram_setup_cli.go create mode 100644 cmd/proxsave/telegram_setup_cli_test.go create mode 100644 internal/orchestrator/telegram_setup_bootstrap.go create mode 100644 internal/orchestrator/telegram_setup_bootstrap_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index dd8d7168..3feb3327 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -14,7 +14,6 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/notify" "github.com/tis24dev/proxsave/internal/tui/wizard" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -130,123 +129,6 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots return nil } -func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error { - cfg, err := config.LoadConfig(configPath) - if err != nil { - if bootstrap != nil { - bootstrap.Warning("Telegram setup: unable to load config (skipping): %v", err) - } - return nil - } - if cfg == nil || !cfg.TelegramEnabled { - return nil - } - - mode := strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) - if mode == "" { - mode = "centralized" - } - if mode == "personal" { - // No centralized pairing check exists for personal mode. - if bootstrap != nil { - bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") - } - return nil - } - - fmt.Println("\n--- Telegram setup (optional) ---") - fmt.Println("You enabled Telegram notifications (centralized bot).") - - info, idErr := identity.Detect(baseDir, nil) - if idErr != nil { - fmt.Printf("WARNING: Unable to compute server identity (non-blocking): %v\n", idErr) - if bootstrap != nil { - bootstrap.Warning("Telegram setup: identity detection failed (non-blocking): %v", idErr) - } - return nil - } - - serverID := "" - if info != nil { - serverID = strings.TrimSpace(info.ServerID) - } - if serverID == "" { - fmt.Println("WARNING: Server ID unavailable; skipping Telegram setup.") - if bootstrap != nil { - bootstrap.Warning("Telegram setup: server ID unavailable; skipping") - } - return nil - } - - fmt.Printf("Server ID: %s\n", serverID) - if info != nil && strings.TrimSpace(info.IdentityFile) != "" { - fmt.Printf("Identity file: %s\n", strings.TrimSpace(info.IdentityFile)) - } - fmt.Println() - fmt.Println("1) Open Telegram and start @ProxmoxAN_bot") - fmt.Println("2) Send the Server ID above (digits only)") - fmt.Println("3) Verify pairing (recommended)") - fmt.Println() - - check, err := promptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true) - if err != nil { - return wrapInstallError(err) - } - if !check { - fmt.Println("Skipped verification. You can verify later by running proxsave.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: verification skipped by user") - } - return nil - } - - serverHost := strings.TrimSpace(cfg.TelegramServerAPIHost) - if serverHost == "" { - serverHost = "https://bot.tis24.it:1443" - } - - attempts := 0 - for { - attempts++ - status := notify.CheckTelegramRegistration(ctx, serverHost, serverID, nil) - if status.Code == 200 && status.Error == nil { - fmt.Println("✓ Telegram linked successfully.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: verified (attempts=%d)", attempts) - } - return nil - } - - msg := strings.TrimSpace(status.Message) - if msg == "" { - msg = "Registration not active yet" - } - fmt.Printf("Telegram: %s\n", msg) - switch status.Code { - case 403, 409: - fmt.Println("Hint: Start the bot, send the Server ID, then retry.") - case 422: - fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.") - default: - if status.Error != nil { - fmt.Printf("Hint: Check failed: %v\n", status.Error) - } - } - - retry, err := promptYesNo(ctx, reader, "Check again? [y/N]: ", false) - if err != nil { - return wrapInstallError(err) - } - if !retry { - fmt.Println("Verification not completed. You can retry later by running proxsave.") - if bootstrap != nil { - bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg) - } - return nil - } - } -} - func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, configPath string, bootstrap *logging.BootstrapLogger) error { fmt.Println("\n--- Post-install check (optional) ---") run, err := promptYesNo(ctx, reader, "Run a dry-run to detect unused components and reduce warnings? [Y/n]: ", true) diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 441463e0..696727b4 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -188,16 +188,11 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo if telegramErr != nil && bootstrap != nil { bootstrap.Warning("Telegram setup failed (non-blocking): %v", telegramErr) } + if bootstrap != nil && telegramErr == nil { + logTelegramSetupBootstrapOutcome(bootstrap, telegramRes.TelegramSetupBootstrap) + } if bootstrap != nil && telegramRes.Shown { - if telegramRes.ConfigError != "" { - bootstrap.Warning("Telegram setup: failed to load config (non-blocking): %s", telegramRes.ConfigError) - } - if telegramRes.IdentityDetectError != "" { - bootstrap.Warning("Telegram setup: identity detection issue (non-blocking): %s", telegramRes.IdentityDetectError) - } - if telegramRes.TelegramMode == "personal" { - bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") - } else if telegramRes.Verified { + if telegramRes.Verified { bootstrap.Info("Telegram setup: verified (code=%d)", telegramRes.LastStatusCode) } else if telegramRes.SkippedVerification { bootstrap.Info("Telegram setup: verification skipped by user") diff --git a/cmd/proxsave/telegram_setup_cli.go b/cmd/proxsave/telegram_setup_cli.go new file mode 100644 index 00000000..95115657 --- /dev/null +++ b/cmd/proxsave/telegram_setup_cli.go @@ -0,0 +1,107 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +var ( + telegramSetupBuildBootstrap = orchestrator.BuildTelegramSetupBootstrap + telegramSetupCheckRegistration = notify.CheckTelegramRegistration + telegramSetupPromptYesNo = promptYesNo +) + +func logTelegramSetupBootstrapOutcome(bootstrap *logging.BootstrapLogger, state orchestrator.TelegramSetupBootstrap) { + switch state.Eligibility { + case orchestrator.TelegramSetupSkipConfigError: + if strings.TrimSpace(state.ConfigError) != "" { + logBootstrapWarning(bootstrap, "Telegram setup: unable to load config (skipping): %s", state.ConfigError) + } + case orchestrator.TelegramSetupSkipPersonalMode: + logBootstrapInfo(bootstrap, "Telegram setup: personal mode selected (no centralized pairing check)") + case orchestrator.TelegramSetupSkipIdentityUnavailable: + if strings.TrimSpace(state.IdentityDetectError) != "" { + logBootstrapWarning(bootstrap, "Telegram setup: identity detection failed (non-blocking): %s", state.IdentityDetectError) + return + } + logBootstrapWarning(bootstrap, "Telegram setup: server ID unavailable; skipping") + } +} + +func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error { + state, err := telegramSetupBuildBootstrap(configPath, baseDir) + if err != nil { + logBootstrapWarning(bootstrap, "Telegram setup bootstrap failed (non-blocking): %v", err) + return nil + } + + logTelegramSetupBootstrapOutcome(bootstrap, state) + if state.Eligibility != orchestrator.TelegramSetupEligibleCentralized { + return nil + } + + fmt.Println("\n--- Telegram setup (optional) ---") + fmt.Println("You enabled Telegram notifications (centralized bot).") + fmt.Printf("Server ID: %s\n", state.ServerID) + if strings.TrimSpace(state.IdentityFile) != "" { + fmt.Printf("Identity file: %s\n", strings.TrimSpace(state.IdentityFile)) + } + fmt.Println() + fmt.Println("1) Open Telegram and start @ProxmoxAN_bot") + fmt.Println("2) Send the Server ID above (digits only)") + fmt.Println("3) Verify pairing (recommended)") + fmt.Println() + + check, err := telegramSetupPromptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true) + if err != nil { + return wrapInstallError(err) + } + if !check { + fmt.Println("Skipped verification. You can verify later by running proxsave.") + logBootstrapInfo(bootstrap, "Telegram setup: verification skipped by user") + return nil + } + + attempts := 0 + for { + attempts++ + status := telegramSetupCheckRegistration(ctx, state.ServerAPIHost, state.ServerID, nil) + if status.Code == 200 && status.Error == nil { + fmt.Println("✓ Telegram linked successfully.") + logBootstrapInfo(bootstrap, "Telegram setup: verified (attempts=%d)", attempts) + return nil + } + + msg := strings.TrimSpace(status.Message) + if msg == "" { + msg = "Registration not active yet" + } + fmt.Printf("Telegram: %s\n", msg) + switch status.Code { + case 403, 409: + fmt.Println("Hint: Start the bot, send the Server ID, then retry.") + case 422: + fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.") + default: + if status.Error != nil { + fmt.Printf("Hint: Check failed: %v\n", status.Error) + } + } + + retry, err := telegramSetupPromptYesNo(ctx, reader, "Check again? [y/N]: ", false) + if err != nil { + return wrapInstallError(err) + } + if !retry { + fmt.Println("Verification not completed. You can retry later by running proxsave.") + logBootstrapInfo(bootstrap, "Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg) + return nil + } + } +} diff --git a/cmd/proxsave/telegram_setup_cli_test.go b/cmd/proxsave/telegram_setup_cli_test.go new file mode 100644 index 00000000..bd018359 --- /dev/null +++ b/cmd/proxsave/telegram_setup_cli_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "bufio" + "context" + "errors" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" +) + +func stubTelegramSetupCLIDeps(t *testing.T) { + t.Helper() + + origBuildBootstrap := telegramSetupBuildBootstrap + origCheckRegistration := telegramSetupCheckRegistration + origPromptYesNo := telegramSetupPromptYesNo + + t.Cleanup(func() { + telegramSetupBuildBootstrap = origBuildBootstrap + telegramSetupCheckRegistration = origCheckRegistration + telegramSetupPromptYesNo = origPromptYesNo + }) +} + +func TestRunTelegramSetupCLI_SkipOnConfigError(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipConfigError, + ConfigError: "parse failed", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run for config skip") + return false, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + t.Fatalf("registration check should not run for config skip") + return notify.TelegramRegistrationStatus{} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_SkipOnPersonalMode(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipPersonalMode, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "personal", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run for personal mode") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_SkipOnMissingIdentity(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipIdentityUnavailable, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + IdentityDetectError: "detect failed", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run when identity is unavailable") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_DeclineVerification(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + if !strings.Contains(question, "Check Telegram pairing now?") { + t.Fatalf("unexpected question: %s", question) + } + return false, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + t.Fatalf("registration check should not run when user declines") + return notify.TelegramRegistrationStatus{} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} + +func TestRunTelegramSetupCLI_VerifiesSuccessfully(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + var promptCalls int + var checkCalls int + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + }, nil + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + promptCalls++ + if promptCalls != 1 { + t.Fatalf("unexpected prompt call count: %d", promptCalls) + } + return true, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + checkCalls++ + if serverAPIHost != "https://api.example.test" { + t.Fatalf("serverAPIHost=%q, want https://api.example.test", serverAPIHost) + } + if serverID != "123456789" { + t.Fatalf("serverID=%q, want 123456789", serverID) + } + return notify.TelegramRegistrationStatus{Code: 200, Message: "ok"} + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } + if promptCalls != 1 { + t.Fatalf("promptCalls=%d, want 1", promptCalls) + } + if checkCalls != 1 { + t.Fatalf("checkCalls=%d, want 1", checkCalls) + } +} + +func TestRunTelegramSetupCLI_BootstrapErrorNonBlocking(t *testing.T) { + stubTelegramSetupCLIDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{}, errors.New("boom") + } + telegramSetupPromptYesNo = func(ctx context.Context, reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + t.Fatalf("prompt should not run on bootstrap error") + return false, nil + } + + if err := runTelegramSetupCLI(context.Background(), bufio.NewReader(strings.NewReader("")), t.TempDir(), "/fake/backup.env", logging.NewBootstrapLogger()); err != nil { + t.Fatalf("runTelegramSetupCLI error: %v", err) + } +} diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 7e71de41..83a6be81 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -147,7 +147,7 @@ Some interactive commands support two interface modes: 6. Optionally configures encryption (AGE setup) 7. (TUI) Optionally selects a cron time (HH:MM) for the `proxsave` cron entry 8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`) -9. (If Telegram enabled) Shows Server ID and offers pairing verification (retry/skip supported) +9. (If Telegram centralized mode is enabled and config + Server ID resolve successfully) Shows Server ID and offers pairing verification (retry/skip supported); otherwise install continues and logs why pairing was skipped 10. Finalizes installation (symlinks, cron migration, permission checks) **Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 63dfe6c8..6ae76692 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -804,8 +804,8 @@ TELEGRAM_CHAT_ID= # Chat ID (your user ID or group ID) 3. Open Telegram and start `@ProxmoxAN_bot` 4. Send the Server ID to the bot 5. Verify pairing: - - **TUI installer**: press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification. - - **CLI installer**: opt into the check and retry when prompted. + - **TUI installer**: the Telegram setup screen is shown only when config loads successfully, centralized mode is active, and a Server ID is available. When shown, press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification. + - **CLI installer**: the same eligibility rules apply, then you can opt into the check and retry when prompted. - Normal runs also verify automatically and will skip Telegram if not paired yet. **Setup personal bot**: diff --git a/docs/INSTALL.md b/docs/INSTALL.md index fe02893b..d19a5145 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -233,13 +233,19 @@ Final install steps still run: 6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled) 7. **Cron schedule**: Choose cron time (HH:MM) for the `proxsave` cron entry (TUI mode only) 8. **Post-install check (optional)**: Runs `proxsave --dry-run` and shows actionable warnings like `set BACKUP_*=false to disable`, allowing you to disable unused collectors and reduce WARNING noise -9. **Telegram pairing (optional)**: If Telegram (centralized) is enabled, shows your Server ID and lets you verify pairing with the bot (retry/skip supported) +9. **Telegram pairing (optional)**: If Telegram centralized mode is enabled and the installer can load a valid config plus a Server ID, it shows your Server ID and lets you verify pairing with the bot (retry/skip supported). Otherwise installation continues and logs why pairing was skipped. #### Telegram pairing wizard (TUI) -If you enable Telegram notifications during `--install` (centralized bot), the installer opens an additional **Telegram Setup** screen after the post-install check. +If you enable Telegram notifications during `--install`, the installer opens an additional **Telegram Setup** screen only when all of these are true: +- `TELEGRAM_ENABLED=true` +- `BOT_TELEGRAM_TYPE=centralized` (or left empty, which defaults to centralized) +- `backup.env` loads successfully +- a Server ID can be resolved from `/identity/.server_identity` -It does **not** modify your `backup.env`. It only: +If any of those checks fail, installation continues without this screen and logs the skip reason (for example config load failure, personal mode, or missing server identity). + +When shown, it does **not** modify your `backup.env`. It only: - Computes/loads the **Server ID** and persists it (identity file) - Guides you through pairing with the centralized bot - Lets you verify pairing immediately (retry supported) @@ -250,7 +256,7 @@ It does **not** modify your `backup.env`. It only: - **Status**: live feedback from the pairing check - **Actions**: - `Check`: verify pairing (press again to retry) - - `Continue`: available only after a successful check (centralized mode), or immediately in personal mode / when the Server ID is unavailable + - `Continue`: available only after a successful check - `Skip`: leave without verification (in centralized mode, `ESC` behaves like Skip when not verified) **Where the Server ID is stored:** @@ -262,7 +268,7 @@ It does **not** modify your `backup.env`. It only: - Other errors: temporary server/network issue; retry or skip and pair later **CLI mode:** -- With `--install --cli`, the installer prints the Server ID and asks whether to run the check now (with a retry loop). +- With `--install --cli`, the installer follows the same eligibility rules, then prints the Server ID and asks whether to run the check now (with a retry loop). **Features:** @@ -271,7 +277,7 @@ It does **not** modify your `backup.env`. It only: - Creates all necessary directories with proper permissions (0700) - Immediate AGE key generation if encryption is enabled - Optional post-install audit to disable unused collectors (keeps changes explicit; nothing is disabled silently) -- Optional Telegram pairing wizard (centralized mode) that displays Server ID and verifies the bot registration (retry/skip supported) +- Optional Telegram pairing wizard (centralized mode, valid config, Server ID available) that displays Server ID and verifies the bot registration (retry/skip supported) - Install session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome) After completion, edit `configs/backup.env` manually for advanced options. diff --git a/internal/orchestrator/telegram_setup_bootstrap.go b/internal/orchestrator/telegram_setup_bootstrap.go new file mode 100644 index 00000000..1e5429cf --- /dev/null +++ b/internal/orchestrator/telegram_setup_bootstrap.go @@ -0,0 +1,104 @@ +package orchestrator + +import ( + "os" + "strings" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" +) + +const defaultTelegramServerAPIHost = "https://bot.tis24.it:1443" + +type TelegramSetupEligibility int + +const ( + TelegramSetupEligibilityUnknown TelegramSetupEligibility = iota + TelegramSetupEligibleCentralized + TelegramSetupSkipDisabled + TelegramSetupSkipConfigError + TelegramSetupSkipPersonalMode + TelegramSetupSkipIdentityUnavailable +) + +type TelegramSetupBootstrap struct { + Eligibility TelegramSetupEligibility + + ConfigLoaded bool + ConfigError string + + TelegramEnabled bool + TelegramMode string + ServerAPIHost string + + ServerID string + IdentityFile string + IdentityPersisted bool + IdentityDetectError string +} + +var ( + telegramSetupBootstrapLoadConfig = config.LoadConfig + telegramSetupBootstrapIdentityDetect = identity.Detect + telegramSetupBootstrapStat = os.Stat +) + +func BuildTelegramSetupBootstrap(configPath, baseDir string) (TelegramSetupBootstrap, error) { + state := TelegramSetupBootstrap{} + + cfg, err := telegramSetupBootstrapLoadConfig(configPath) + if err != nil { + state.Eligibility = TelegramSetupSkipConfigError + state.ConfigError = err.Error() + return state, nil + } + + state.ConfigLoaded = true + if cfg != nil { + state.TelegramEnabled = cfg.TelegramEnabled + state.TelegramMode = strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) + state.ServerAPIHost = strings.TrimSpace(cfg.TelegramServerAPIHost) + } + + if !state.TelegramEnabled { + state.Eligibility = TelegramSetupSkipDisabled + return state, nil + } + + if state.TelegramMode == "" { + state.TelegramMode = "centralized" + } + if state.ServerAPIHost == "" { + state.ServerAPIHost = defaultTelegramServerAPIHost + } + + if state.TelegramMode == "personal" { + state.Eligibility = TelegramSetupSkipPersonalMode + return state, nil + } + + info, err := telegramSetupBootstrapIdentityDetect(baseDir, nil) + if err != nil { + state.Eligibility = TelegramSetupSkipIdentityUnavailable + state.IdentityDetectError = err.Error() + return state, nil + } + + if info != nil { + state.ServerID = strings.TrimSpace(info.ServerID) + state.IdentityFile = strings.TrimSpace(info.IdentityFile) + if state.IdentityFile != "" { + if _, statErr := telegramSetupBootstrapStat(state.IdentityFile); statErr == nil { + state.IdentityPersisted = true + } + } + } + + if state.ServerID == "" { + state.Eligibility = TelegramSetupSkipIdentityUnavailable + return state, nil + } + + state.Eligibility = TelegramSetupEligibleCentralized + return state, nil +} diff --git a/internal/orchestrator/telegram_setup_bootstrap_test.go b/internal/orchestrator/telegram_setup_bootstrap_test.go new file mode 100644 index 00000000..ad655665 --- /dev/null +++ b/internal/orchestrator/telegram_setup_bootstrap_test.go @@ -0,0 +1,212 @@ +package orchestrator + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/logging" +) + +func stubTelegramSetupBootstrapDeps(t *testing.T) { + t.Helper() + + origLoadConfig := telegramSetupBootstrapLoadConfig + origIdentityDetect := telegramSetupBootstrapIdentityDetect + origStat := telegramSetupBootstrapStat + + t.Cleanup(func() { + telegramSetupBootstrapLoadConfig = origLoadConfig + telegramSetupBootstrapIdentityDetect = origIdentityDetect + telegramSetupBootstrapStat = origStat + }) +} + +func TestBuildTelegramSetupBootstrap_ConfigLoadFailureSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return nil, errors.New("parse failed") + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run on config failure") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipConfigError { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipConfigError) + } + if state.ConfigError == "" { + t.Fatalf("expected ConfigError to be set") + } + if state.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=false") + } +} + +func TestBuildTelegramSetupBootstrap_DisabledSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{TelegramEnabled: false}, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run when telegram is disabled") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipDisabled { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipDisabled) + } + if state.TelegramEnabled { + t.Fatalf("expected TelegramEnabled=false") + } +} + +func TestBuildTelegramSetupBootstrap_PersonalModeSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: " Personal ", + TelegramServerAPIHost: "", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not run in personal mode") + return nil, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipPersonalMode { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipPersonalMode) + } + if state.TelegramMode != "personal" { + t.Fatalf("TelegramMode=%q, want personal", state.TelegramMode) + } + if state.ServerAPIHost != defaultTelegramServerAPIHost { + t.Fatalf("ServerAPIHost=%q, want %q", state.ServerAPIHost, defaultTelegramServerAPIHost) + } +} + +func TestBuildTelegramSetupBootstrap_IdentityErrorSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return nil, errors.New("detect failed") + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipIdentityUnavailable { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipIdentityUnavailable) + } + if state.IdentityDetectError == "" { + t.Fatalf("expected IdentityDetectError to be set") + } + if state.ServerAPIHost != defaultTelegramServerAPIHost { + t.Fatalf("ServerAPIHost=%q, want %q", state.ServerAPIHost, defaultTelegramServerAPIHost) + } +} + +func TestBuildTelegramSetupBootstrap_EmptyServerIDSkips(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: " ", IdentityFile: " /tmp/id "}, nil + } + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupSkipIdentityUnavailable { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupSkipIdentityUnavailable) + } + if state.ServerID != "" { + t.Fatalf("ServerID=%q, want empty", state.ServerID) + } + if state.IdentityFile != "/tmp/id" { + t.Fatalf("IdentityFile=%q, want /tmp/id", state.IdentityFile) + } +} + +func TestBuildTelegramSetupBootstrap_EligibleCentralized(t *testing.T) { + stubTelegramSetupBootstrapDeps(t) + + identityFile := filepath.Join(t.TempDir(), ".server_identity") + if err := os.WriteFile(identityFile, []byte("id"), 0o600); err != nil { + t.Fatalf("write identity file: %v", err) + } + + telegramSetupBootstrapLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: " ", + TelegramServerAPIHost: " https://api.example.test ", + }, nil + } + telegramSetupBootstrapIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ + ServerID: " 123456789 ", + IdentityFile: " " + identityFile + " ", + }, nil + } + telegramSetupBootstrapStat = os.Stat + + state, err := BuildTelegramSetupBootstrap("/fake/backup.env", t.TempDir()) + if err != nil { + t.Fatalf("BuildTelegramSetupBootstrap error: %v", err) + } + if state.Eligibility != TelegramSetupEligibleCentralized { + t.Fatalf("Eligibility=%v, want %v", state.Eligibility, TelegramSetupEligibleCentralized) + } + if !state.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=true") + } + if state.TelegramMode != "centralized" { + t.Fatalf("TelegramMode=%q, want centralized", state.TelegramMode) + } + if state.ServerAPIHost != "https://api.example.test" { + t.Fatalf("ServerAPIHost=%q, want https://api.example.test", state.ServerAPIHost) + } + if state.ServerID != "123456789" { + t.Fatalf("ServerID=%q, want 123456789", state.ServerID) + } + if state.IdentityFile != identityFile { + t.Fatalf("IdentityFile=%q, want %q", state.IdentityFile, identityFile) + } + if !state.IdentityPersisted { + t.Fatalf("expected IdentityPersisted=true") + } +} diff --git a/internal/tui/wizard/telegram_setup_tui.go b/internal/tui/wizard/telegram_setup_tui.go index 90e20980..ea79eb6c 100644 --- a/internal/tui/wizard/telegram_setup_tui.go +++ b/internal/tui/wizard/telegram_setup_tui.go @@ -3,16 +3,14 @@ package wizard import ( "context" "fmt" - "os" "strings" "sync" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui" ) @@ -21,29 +19,16 @@ var ( return app.SetRoot(root, true).SetFocus(focus).Run() } - telegramSetupLoadConfig = config.LoadConfig - telegramSetupReadFile = os.ReadFile - telegramSetupStat = os.Stat - telegramSetupIdentityDetect = identity.Detect - telegramSetupCheckRegistration = notify.CheckTelegramRegistration - telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { app.QueueUpdateDraw(f) } - telegramSetupGo = func(fn func()) { go fn() } + telegramSetupBuildBootstrap = orchestrator.BuildTelegramSetupBootstrap + telegramSetupCheckRegistration = notify.CheckTelegramRegistration + telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { app.QueueUpdateDraw(f) } + telegramSetupGo = func(fn func()) { go fn() } ) type TelegramSetupResult struct { - Shown bool - - ConfigLoaded bool - ConfigError string + orchestrator.TelegramSetupBootstrap - TelegramEnabled bool - TelegramMode string - ServerAPIHost string - - ServerID string - IdentityFile string - IdentityPersisted bool - IdentityDetectError string + Shown bool CheckAttempts int Verified bool @@ -55,51 +40,18 @@ type TelegramSetupResult struct { } func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig string) (TelegramSetupResult, error) { - result := TelegramSetupResult{Shown: true} - - cfg, cfgErr := telegramSetupLoadConfig(configPath) - if cfgErr != nil { - result.ConfigLoaded = false - result.ConfigError = cfgErr.Error() - // Fall back to raw env parsing so the wizard can still run even when the full - // config parser fails for unrelated keys. - if configBytes, readErr := telegramSetupReadFile(configPath); readErr == nil { - values := parseEnvTemplate(string(configBytes)) - result.TelegramEnabled = readTemplateBool(values, "TELEGRAM_ENABLED") - result.TelegramMode = strings.ToLower(strings.TrimSpace(readTemplateString(values, "BOT_TELEGRAM_TYPE"))) - } - } else { - result.ConfigLoaded = true - result.TelegramEnabled = cfg.TelegramEnabled - result.TelegramMode = strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) - result.ServerAPIHost = strings.TrimSpace(cfg.TelegramServerAPIHost) + state, err := telegramSetupBuildBootstrap(configPath, baseDir) + if err != nil { + return TelegramSetupResult{}, err } - - if !result.TelegramEnabled { + result := TelegramSetupResult{ + TelegramSetupBootstrap: state, + Shown: true, + } + if result.Eligibility != orchestrator.TelegramSetupEligibleCentralized { result.Shown = false return result, nil } - if result.TelegramMode == "" { - result.TelegramMode = "centralized" - } - if result.ServerAPIHost == "" { - // Fallback (keeps behavior aligned with internal/config defaults). - result.ServerAPIHost = "https://bot.tis24.it:1443" - } - - idInfo, idErr := telegramSetupIdentityDetect(baseDir, nil) - if idErr != nil { - result.IdentityDetectError = idErr.Error() - } - if idInfo != nil { - result.ServerID = strings.TrimSpace(idInfo.ServerID) - result.IdentityFile = strings.TrimSpace(idInfo.IdentityFile) - if result.IdentityFile != "" { - if _, err := telegramSetupStat(result.IdentityFile); err == nil { - result.IdentityPersisted = true - } - } - } app := tui.NewApp() pages := tview.NewPages() @@ -173,36 +125,16 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s return s[:max] + "...(truncated)" } - modeLabel := result.TelegramMode - if modeLabel == "" { - modeLabel = "centralized" - } - var b strings.Builder - b.WriteString(fmt.Sprintf("[yellow]Mode:[white] %s\n", modeLabel)) - if !result.ConfigLoaded && result.ConfigError != "" { - b.WriteString(fmt.Sprintf("[red]WARNING:[white] failed to load config: %s\n\n", truncate(result.ConfigError, 200))) - } - if result.TelegramMode == "personal" { - b.WriteString("\nPersonal mode uses your own bot.\n\n") - b.WriteString("This installer does not guide the personal bot setup.\n") - b.WriteString("Edit backup.env and set:\n") - b.WriteString(" - TELEGRAM_BOT_TOKEN\n") - b.WriteString(" - TELEGRAM_CHAT_ID\n\n") - b.WriteString("Then run ProxSave once to validate notifications.\n") - } else { - b.WriteString("\n1) Open Telegram and start [yellow]@ProxmoxAN_bot[white]\n") - b.WriteString("2) Send the [yellow]Server ID[white] below (digits only)\n") - b.WriteString("3) Press [yellow]Check[white] to verify\n\n") - b.WriteString("If the check fails, you can press Check again.\n") - b.WriteString("You can also Skip verification and complete pairing later.\n") - } + b.WriteString("[yellow]Mode:[white] centralized\n") + b.WriteString("\n1) Open Telegram and start [yellow]@ProxmoxAN_bot[white]\n") + b.WriteString("2) Send the [yellow]Server ID[white] below (digits only)\n") + b.WriteString("3) Press [yellow]Check[white] to verify\n\n") + b.WriteString("If the check fails, you can press Check again.\n") + b.WriteString("You can also Skip verification and complete pairing later.\n") instructions.SetText(b.String()) - serverIDLine := "[red]Server ID unavailable.[white]" - if result.ServerID != "" { - serverIDLine = fmt.Sprintf("[yellow]%s[white]", result.ServerID) - } + serverIDLine := fmt.Sprintf("[yellow]%s[white]", result.ServerID) identityLine := "" if result.IdentityFile != "" { persisted := "not persisted" @@ -217,17 +149,7 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s statusView.SetText(text) } - initialStatus := "[yellow]Not checked yet.[white]\n\nPress [yellow]Check[white] after sending the Server ID to the bot." - if result.TelegramMode == "personal" { - initialStatus = "[yellow]No centralized pairing check for personal mode.[white]" - } - if result.ServerID == "" && result.TelegramMode != "personal" { - initialStatus = "[red]Cannot check registration: Server ID missing.[white]" - if result.IdentityDetectError != "" { - initialStatus += "\n\n" + truncate(result.IdentityDetectError, 200) - } - } - setStatus(initialStatus) + setStatus("[yellow]Not checked yet.[white]\n\nPress [yellow]Check[white] after sending the Server ID to the bot.") var mu sync.Mutex checking := false @@ -256,10 +178,6 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s var refreshButtons func() checkHandler := func() { - if result.TelegramMode == "personal" || strings.TrimSpace(result.ServerID) == "" { - return - } - mu.Lock() if checking || closing { mu.Unlock() @@ -318,23 +236,13 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s refreshButtons = func() { form.ClearButtons() - - // Centralized mode pairing only works when the Server ID is available. - if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" { - form.AddButton("Check", checkHandler) - } - - switch { - case result.TelegramMode == "personal": + form.AddButton("Check", checkHandler) + if result.Verified { form.AddButton("Continue", func() { doClose(false) }) - case strings.TrimSpace(result.ServerID) == "": - form.AddButton("Continue", func() { doClose(false) }) - case result.Verified: - form.AddButton("Continue", func() { doClose(false) }) - default: - // Until verification succeeds, require an explicit skip to leave without pairing. - form.AddButton("Skip", func() { doClose(true) }) + return } + // Until verification succeeds, require an explicit skip to leave without pairing. + form.AddButton("Skip", func() { doClose(true) }) } refreshButtons() @@ -372,7 +280,7 @@ func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig s app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { - if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" && !result.Verified { + if !result.Verified { doClose(true) } else { doClose(false) diff --git a/internal/tui/wizard/telegram_setup_tui_test.go b/internal/tui/wizard/telegram_setup_tui_test.go index 19e7a8f4..47ef7c78 100644 --- a/internal/tui/wizard/telegram_setup_tui_test.go +++ b/internal/tui/wizard/telegram_setup_tui_test.go @@ -3,18 +3,14 @@ package wizard import ( "context" "errors" - "os" - "path/filepath" - "strings" "testing" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" - "github.com/tis24dev/proxsave/internal/config" - "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui" ) @@ -22,20 +18,14 @@ func stubTelegramSetupDeps(t *testing.T) { t.Helper() origRunner := telegramSetupWizardRunner - origLoadConfig := telegramSetupLoadConfig - origReadFile := telegramSetupReadFile - origStat := telegramSetupStat - origIdentityDetect := telegramSetupIdentityDetect + origBuildBootstrap := telegramSetupBuildBootstrap origCheckRegistration := telegramSetupCheckRegistration origQueueUpdateDraw := telegramSetupQueueUpdateDraw origGo := telegramSetupGo t.Cleanup(func() { telegramSetupWizardRunner = origRunner - telegramSetupLoadConfig = origLoadConfig - telegramSetupReadFile = origReadFile - telegramSetupStat = origStat - telegramSetupIdentityDetect = origIdentityDetect + telegramSetupBuildBootstrap = origBuildBootstrap telegramSetupCheckRegistration = origCheckRegistration telegramSetupQueueUpdateDraw = origQueueUpdateDraw telegramSetupGo = origGo @@ -45,15 +35,28 @@ func stubTelegramSetupDeps(t *testing.T) { telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } } +func eligibleTelegramSetupBootstrap() orchestrator.TelegramSetupBootstrap { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupEligibleCentralized, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + ServerID: "123456789", + IdentityFile: "/tmp/.server_identity", + IdentityPersisted: false, + } +} + func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{TelegramEnabled: false}, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - t.Fatalf("identity detect should not be called when telegram is disabled") - return nil, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipDisabled, + ConfigLoaded: true, + TelegramEnabled: false, + }, nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { t.Fatalf("runner should not be called when telegram is disabled") @@ -75,17 +78,17 @@ func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) } } -func TestRunTelegramSetupWizard_ConfigLoadAndReadFailSkipsUI(t *testing.T) { +func TestRunTelegramSetupWizard_ConfigErrorSkipsUI(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return nil, errors.New("parse failed") - } - telegramSetupReadFile = func(path string) ([]byte, error) { - return nil, errors.New("read failed") + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipConfigError, + ConfigError: "parse failed", + }, nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - t.Fatalf("runner should not be called when env cannot be read") + t.Fatalf("runner should not be called when config bootstrap failed") return nil } @@ -104,33 +107,20 @@ func TestRunTelegramSetupWizard_ConfigLoadAndReadFailSkipsUI(t *testing.T) { } } -func TestRunTelegramSetupWizard_FallbackPersonalMode_Continue(t *testing.T) { +func TestRunTelegramSetupWizard_PersonalModeSkipsUI(t *testing.T) { stubTelegramSetupDeps(t) - identityFile := filepath.Join(t.TempDir(), ".server_identity") - if err := os.WriteFile(identityFile, []byte("id"), 0o600); err != nil { - t.Fatalf("write identity file: %v", err) - } - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return nil, errors.New(strings.Repeat("x", 250)) - } - telegramSetupReadFile = func(path string) ([]byte, error) { - return []byte("TELEGRAM_ENABLED=true\nBOT_TELEGRAM_TYPE=Personal\n"), nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: " 123 ", IdentityFile: " " + identityFile + " "}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipPersonalMode, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "personal", + ServerAPIHost: "https://bot.tis24.it:1443", + }, nil } - telegramSetupStat = os.Stat telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - if form.GetButtonIndex("Check") != -1 { - t.Fatalf("expected no Check button in personal mode") - } - if form.GetButtonIndex("Continue") == -1 { - t.Fatalf("expected Continue button in personal mode") - } - pressFormButton(t, form, "Continue") + t.Fatalf("runner should not be called in personal mode") return nil } @@ -138,56 +128,57 @@ func TestRunTelegramSetupWizard_FallbackPersonalMode_Continue(t *testing.T) { if err != nil { t.Fatalf("RunTelegramSetupWizard error: %v", err) } - if !result.Shown { - t.Fatalf("expected wizard to be shown") - } - if result.ConfigLoaded { - t.Fatalf("expected ConfigLoaded=false for fallback mode") + if result.Shown { + t.Fatalf("expected wizard to not be shown") } - if result.ConfigError == "" { - t.Fatalf("expected ConfigError to be set") + if result.TelegramMode != "personal" { + t.Fatalf("TelegramMode=%q, want personal", result.TelegramMode) } if !result.TelegramEnabled { t.Fatalf("expected TelegramEnabled=true") } - if result.TelegramMode != "personal" { - t.Fatalf("TelegramMode=%q, want personal", result.TelegramMode) - } - if result.ServerAPIHost != "https://bot.tis24.it:1443" { - t.Fatalf("ServerAPIHost=%q, want default", result.ServerAPIHost) +} + +func TestRunTelegramSetupWizard_IdentityUnavailableSkipsUI(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{ + Eligibility: orchestrator.TelegramSetupSkipIdentityUnavailable, + ConfigLoaded: true, + TelegramEnabled: true, + TelegramMode: "centralized", + ServerAPIHost: "https://api.example.test", + IdentityDetectError: "detect failed", + }, nil } - if result.ServerID != "123" { - t.Fatalf("ServerID=%q, want 123", result.ServerID) + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + t.Fatalf("runner should not be called when server ID is unavailable") + return nil } - if !result.IdentityPersisted { - t.Fatalf("expected IdentityPersisted=true") + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) } - if result.Verified { - t.Fatalf("expected Verified=false") + if result.Shown { + t.Fatalf("expected wizard to not be shown") } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") + if result.IdentityDetectError == "" { + t.Fatalf("expected IdentityDetectError to be set") } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + if result.ServerID != "" { + t.Fatalf("ServerID=%q, want empty", result.ServerID) } } func TestRunTelegramSetupWizard_CentralizedSuccess_RequiresCheckBeforeContinue(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: " ", - TelegramServerAPIHost: " https://api.example.test ", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: " 987654321 ", IdentityFile: " /missing "}, nil - } - telegramSetupStat = func(path string) (os.FileInfo, error) { - return nil, os.ErrNotExist + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "987654321" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { if serverAPIHost != "https://api.example.test" { @@ -259,25 +250,21 @@ func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) stubTelegramSetupDeps(t) var calls int - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "111222333"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "111222333" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { calls++ - if calls == 1 { + switch calls { + case 1: return notify.TelegramRegistrationStatus{Code: 403, Error: errors.New("not registered")} - } - if calls == 2 { + case 2: return notify.TelegramRegistrationStatus{Code: 422, Message: "invalid"} + default: + return notify.TelegramRegistrationStatus{Code: 500, Message: "oops"} } - return notify.TelegramRegistrationStatus{Code: 500, Message: "oops"} } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { form := focus.(*tview.Form) @@ -315,106 +302,11 @@ func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) } } -func TestRunTelegramSetupWizard_CentralizedMissingServerID_ExitsOnEscWithoutSkipping(t *testing.T) { - stubTelegramSetupDeps(t) - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return nil, errors.New("detect failed") - } - telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - if form.GetButtonIndex("Check") != -1 { - t.Fatalf("expected no Check button without Server ID") - } - if form.GetButtonIndex("Skip") != -1 { - t.Fatalf("expected no Skip button without Server ID") - } - if form.GetButtonIndex("Continue") == -1 { - t.Fatalf("expected Continue button without Server ID") - } - - capture := app.GetInputCapture() - if capture == nil { - t.Fatalf("expected input capture to be set") - } - capture(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) - return nil - } - - result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") - if err != nil { - t.Fatalf("RunTelegramSetupWizard error: %v", err) - } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") - } - if result.Verified { - t.Fatalf("expected Verified=false") - } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) - } - if result.ServerID != "" { - t.Fatalf("ServerID=%q, want empty", result.ServerID) - } - if result.IdentityDetectError == "" { - t.Fatalf("expected IdentityDetectError to be set") - } -} - -func TestRunTelegramSetupWizard_CentralizedMissingServerID_CanContinueButton(t *testing.T) { - stubTelegramSetupDeps(t) - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: ""}, nil - } - telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { - form := focus.(*tview.Form) - pressFormButton(t, form, "Continue") - return nil - } - - result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") - if err != nil { - t.Fatalf("RunTelegramSetupWizard error: %v", err) - } - if result.SkippedVerification { - t.Fatalf("expected SkippedVerification=false") - } - if result.Verified { - t.Fatalf("expected Verified=false") - } - if result.CheckAttempts != 0 { - t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) - } -} - func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "123456"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { capture := app.GetInputCapture() @@ -449,15 +341,8 @@ func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) func TestRunTelegramSetupWizard_PropagatesRunnerError(t *testing.T) { stubTelegramSetupDeps(t) - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "123456"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil } telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { return errors.New("runner failed") @@ -480,16 +365,10 @@ func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAft telegramSetupGo = func(fn func()) { pending = fn } telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } - - telegramSetupLoadConfig = func(path string) (*config.Config, error) { - return &config.Config{ - TelegramEnabled: true, - TelegramBotType: "centralized", - TelegramServerAPIHost: "https://api.example.test", - }, nil - } - telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { - return &identity.Info{ServerID: "999888777"}, nil + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.ServerID = "999888777" + return state, nil } telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { checkCalls++ @@ -503,10 +382,10 @@ func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAft t.Fatalf("expected pending check goroutine") } - pressFormButton(t, form, "Check") // should be ignored while checking=true - pressFormButton(t, form, "Skip") // closes the wizard + pressFormButton(t, form, "Check") + pressFormButton(t, form, "Skip") - pending() // simulate late completion after closing + pending() return nil } From 94b46e1f94bfe9ff0a8094c6af98c54364f46400 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 19:12:05 +0100 Subject: [PATCH 06/29] Align decrypt secret prompt semantics across CLI and TUI Restore consistent decrypt prompt behavior between CLI and TUI by treating "0" as an explicit abort in both flows. Update the TUI decrypt secret prompt to advertise the exit semantics clearly, return ErrDecryptAborted on zero input, and keep Cancel as an equivalent exit path. Adjust TUI simulation coverage so the shared decrypt workflow no longer carries a UI-specific semantic drift on secret entry. --- internal/orchestrator/decrypt_tui.go | 283 ----------------- .../decrypt_tui_simulation_test.go | 32 +- internal/orchestrator/decrypt_tui_test.go | 216 ------------- internal/orchestrator/decrypt_workflow_ui.go | 7 + .../orchestrator/decrypt_workflow_ui_test.go | 284 ++++++++++++++++++ internal/orchestrator/tui_simulation_test.go | 15 +- .../orchestrator/workflow_ui_tui_decrypt.go | 75 +---- .../workflow_ui_tui_decrypt_prompts.go | 180 +++++++++++ .../workflow_ui_tui_decrypt_test.go | 102 +++++++ .../orchestrator/workflow_ui_tui_shared.go | 49 +++ 10 files changed, 660 insertions(+), 583 deletions(-) create mode 100644 internal/orchestrator/decrypt_workflow_ui_test.go create mode 100644 internal/orchestrator/workflow_ui_tui_decrypt_prompts.go create mode 100644 internal/orchestrator/workflow_ui_tui_decrypt_test.go create mode 100644 internal/orchestrator/workflow_ui_tui_shared.go diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index 7602d8a2..5da72506 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -4,11 +4,8 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" "strings" - "filippo.io/age" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -16,21 +13,11 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/tui" - "github.com/tis24dev/proxsave/internal/tui/components" ) const ( decryptWizardSubtitle = "Decrypt Backup Workflow" decryptNavText = "[yellow]Navigation:[white] TAB/↑↓ to move | ENTER to select | ESC to exit screens | Mouse clicks enabled" - - pathActionOverwrite = "overwrite" - pathActionNew = "new" - pathActionCancel = "cancel" -) - -var ( - promptOverwriteActionFunc = promptOverwriteAction - promptNewPathInputFunc = promptNewPathInput ) // RunDecryptWorkflowTUI runs the decrypt workflow using a TUI flow. @@ -104,276 +91,6 @@ func filterEncryptedCandidates(candidates []*decryptCandidate) []*decryptCandida return filtered } -func ensureWritablePathTUI(path, description, configPath, buildSig string) (string, error) { - current := filepath.Clean(path) - if description == "" { - description = "file" - } - var failureMessage string - - for { - if _, err := restoreFS.Stat(current); errors.Is(err, os.ErrNotExist) { - return current, nil - } else if err != nil && !errors.Is(err, os.ErrExist) { - return "", fmt.Errorf("stat %s: %w", current, err) - } - - action, err := promptOverwriteActionFunc(current, description, failureMessage, configPath, buildSig) - if err != nil { - return "", err - } - failureMessage = "" - - switch action { - case pathActionOverwrite: - if err := restoreFS.Remove(current); err != nil && !errors.Is(err, os.ErrNotExist) { - failureMessage = fmt.Sprintf("Failed to remove existing %s: %v", description, err) - continue - } - return current, nil - case pathActionNew: - newPath, err := promptNewPathInputFunc(current, configPath, buildSig) - if err != nil { - if errors.Is(err, ErrDecryptAborted) { - return "", ErrDecryptAborted - } - failureMessage = err.Error() - continue - } - current = filepath.Clean(newPath) - default: - return "", ErrDecryptAborted - } - } -} - -func promptOverwriteAction(path, description, failureMessage, configPath, buildSig string) (string, error) { - app := newTUIApp() - var choice string - - message := fmt.Sprintf("The %s [yellow]%s[white] already exists.\nSelect how you want to proceed.", description, path) - if strings.TrimSpace(failureMessage) != "" { - message = fmt.Sprintf("%s\n\n[red]%s[white]", message, failureMessage) - } - message += "\n\n[yellow]Use ←→ or TAB to switch buttons | ENTER to confirm[white]" - - modal := tview.NewModal(). - SetText(message). - AddButtons([]string{"Overwrite", "Use different path", "Cancel"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - switch buttonLabel { - case "Overwrite": - choice = pathActionOverwrite - case "Use different path": - choice = pathActionNew - default: - choice = pathActionCancel - } - app.Stop() - }) - - modal.SetBorder(true). - SetTitle(" Existing file "). - SetTitleAlign(tview.AlignCenter). - SetTitleColor(tui.WarningYellow). - SetBorderColor(tui.WarningYellow). - SetBackgroundColor(tcell.ColorBlack) - - wrapped := buildWizardPage("Destination path", configPath, buildSig, modal) - if err := app.SetRoot(wrapped, true).SetFocus(modal).Run(); err != nil { - return "", err - } - return choice, nil -} - -func promptNewPathInput(defaultPath, configPath, buildSig string) (string, error) { - app := newTUIApp() - var newPath string - var cancelled bool - - form := components.NewForm(app) - label := "New path" - form.AddInputFieldWithValidation(label, defaultPath, 64, func(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("path cannot be empty") - } - return nil - }) - form.SetOnSubmit(func(values map[string]string) error { - newPath = strings.TrimSpace(values[label]) - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - - helper := tview.NewTextView(). - SetText("Provide a writable filesystem path for the decrypted files."). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(helper, 3, 0, false). - AddItem(form.Form, 0, 1, true) - - page := buildWizardPage("Choose destination path", configPath, buildSig, content) - form.SetParentView(page) - - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return "", err - } - if cancelled { - return "", ErrDecryptAborted - } - return filepath.Clean(newPath), nil -} - -func preparePlainBundleTUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, configPath, buildSig string) (*preparedBundle, error) { - return preparePlainBundleCommon(ctx, cand, version, logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error { - return decryptArchiveWithTUIPrompts(ctx, encryptedPath, outputPath, displayName, configPath, buildSig, logger) - }) -} - -func decryptArchiveWithTUIPrompts(ctx context.Context, encryptedPath, outputPath, displayName, configPath, buildSig string, logger *logging.Logger) error { - var promptError string - if ctx == nil { - ctx = context.Background() - } - for { - if err := ctx.Err(); err != nil { - return err - } - identities, err := promptDecryptIdentity(displayName, configPath, buildSig, promptError) - if err != nil { - return err - } - - if err := ctx.Err(); err != nil { - return err - } - if err := decryptWithIdentity(encryptedPath, outputPath, identities...); err != nil { - var noMatch *age.NoIdentityMatchError - if errors.Is(err, age.ErrIncorrectIdentity) || errors.As(err, &noMatch) { - promptError = "Provided key or passphrase does not match this archive." - logger.Warning("Incorrect key or passphrase for %s", filepath.Base(encryptedPath)) - continue - } - return err - } - return nil - } -} - -func promptDecryptIdentity(displayName, configPath, buildSig, errorMessage string) ([]age.Identity, error) { - app := newTUIApp() - var ( - chosenIdentity []age.Identity - cancelled bool - ) - - name := displayName - if strings.TrimSpace(name) == "" { - name = "selected backup" - } - infoMessage := fmt.Sprintf("Provide the AGE secret key or passphrase used for [yellow]%s[white].", name) - if strings.TrimSpace(errorMessage) != "" { - infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, errorMessage) - } - infoText := tview.NewTextView(). - SetText(infoMessage). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - form := components.NewForm(app) - label := "Key or passphrase:" - form.AddPasswordField(label, 64) - form.SetOnSubmit(func(values map[string]string) error { - raw := strings.TrimSpace(values[label]) - if raw == "" { - return fmt.Errorf("key or passphrase cannot be empty") - } - identity, err := parseIdentityInput(raw) - resetString(&raw) - if err != nil { - return fmt.Errorf("invalid key or passphrase: %w", err) - } - chosenIdentity = identity - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - // Buttons: Continue, Cancel - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(infoText, 3, 0, false). - AddItem(form.Form, 0, 1, true) - - page := buildWizardPage("Enter decryption secret", configPath, buildSig, content) - form.SetParentView(page) - - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return nil, err - } - if cancelled { - return nil, ErrDecryptAborted - } - if len(chosenIdentity) == 0 { - return nil, fmt.Errorf("missing identity") - } - return chosenIdentity, nil -} - -func enableFormNavigation(form *components.Form, dropdownOpen *bool) { - if form == nil || form.Form == nil { - return - } - form.Form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event == nil { - return event - } - if dropdownOpen != nil && *dropdownOpen { - return event - } - - formItemIndex, buttonIndex := form.Form.GetFocusedItemIndex() - isOnButton := formItemIndex < 0 && buttonIndex >= 0 - isOnField := formItemIndex >= 0 - - if isOnButton { - switch event.Key() { - case tcell.KeyLeft, tcell.KeyUp: - return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) - case tcell.KeyRight, tcell.KeyDown: - return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) - } - } else if isOnField { - // If focused item is a ListFormItem, let it handle navigation internally - if formItemIndex >= 0 { - if _, ok := form.Form.GetFormItem(formItemIndex).(*components.ListFormItem); ok { - return event - } - } - // For other form fields, convert arrows to tab navigation - switch event.Key() { - case tcell.KeyUp: - return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) - case tcell.KeyDown: - return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) - } - } - return event - }) -} - func buildWizardPage(title, configPath, buildSig string, content tview.Primitive) tview.Primitive { welcomeText := tview.NewTextView(). SetText(fmt.Sprintf("ProxSave - By TIS24DEV\n%s\n", decryptWizardSubtitle)). diff --git a/internal/orchestrator/decrypt_tui_simulation_test.go b/internal/orchestrator/decrypt_tui_simulation_test.go index 9a65f1c8..d574a135 100644 --- a/internal/orchestrator/decrypt_tui_simulation_test.go +++ b/internal/orchestrator/decrypt_tui_simulation_test.go @@ -1,22 +1,23 @@ package orchestrator import ( + "context" "testing" "github.com/gdamore/tcell/v2" ) -func TestPromptDecryptIdentity_CancelReturnsAborted(t *testing.T) { - // Focus starts on the password field; tab to Cancel and submit. +func TestTUIWorkflowUIPromptDecryptSecret_CancelReturnsAborted(t *testing.T) { withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyTab, tcell.KeyEnter}) - _, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDecryptSecret(context.Background(), "backup", "") if err != ErrDecryptAborted { t.Fatalf("err=%v; want %v", err, ErrDecryptAborted) } } -func TestPromptDecryptIdentity_PassphraseReturnsIdentity(t *testing.T) { +func TestTUIWorkflowUIPromptDecryptSecret_PassphraseReturnsSecret(t *testing.T) { passphrase := "test passphrase" var seq []simKey @@ -26,11 +27,26 @@ func TestPromptDecryptIdentity_PassphraseReturnsIdentity(t *testing.T) { seq = append(seq, simKey{Key: tcell.KeyTab}, simKey{Key: tcell.KeyEnter}) withSimAppSequence(t, seq) - ids, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + secret, err := ui.PromptDecryptSecret(context.Background(), "backup", "") if err != nil { - t.Fatalf("promptDecryptIdentity error: %v", err) + t.Fatalf("PromptDecryptSecret error: %v", err) } - if len(ids) == 0 { - t.Fatalf("expected at least one identity") + if secret != passphrase { + t.Fatalf("secret=%q; want %q", secret, passphrase) + } +} + +func TestTUIWorkflowUIPromptDecryptSecret_ZeroInputAborts(t *testing.T) { + withSimAppSequence(t, []simKey{ + {Key: tcell.KeyRune, R: '0'}, + {Key: tcell.KeyTab}, + {Key: tcell.KeyEnter}, + }) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDecryptSecret(context.Background(), "backup", "") + if err != ErrDecryptAborted { + t.Fatalf("err=%v; want %v", err, ErrDecryptAborted) } } diff --git a/internal/orchestrator/decrypt_tui_test.go b/internal/orchestrator/decrypt_tui_test.go index f08f9a04..d94b78ea 100644 --- a/internal/orchestrator/decrypt_tui_test.go +++ b/internal/orchestrator/decrypt_tui_test.go @@ -1,18 +1,12 @@ package orchestrator import ( - "context" - "errors" - "os" - "path/filepath" "testing" "time" "github.com/rivo/tview" "github.com/tis24dev/proxsave/internal/backup" - "github.com/tis24dev/proxsave/internal/logging" - "github.com/tis24dev/proxsave/internal/types" ) func TestNormalizeProxmoxVersion(t *testing.T) { @@ -66,194 +60,6 @@ func TestFilterEncryptedCandidates(t *testing.T) { } } -func TestEnsureWritablePathTUI_ReturnsCleanMissingPath(t *testing.T) { - originalFS := restoreFS - restoreFS = osFS{} - defer func() { restoreFS = originalFS }() - - tmp := t.TempDir() - target := filepath.Join(tmp, "subdir", "file.txt") - dirty := target + string(filepath.Separator) + ".." + string(filepath.Separator) + "file.txt" - - path, err := ensureWritablePathTUI(dirty, "test file", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI returned error: %v", err) - } - if path != target { - t.Fatalf("ensureWritablePathTUI path=%q, want %q", path, target) - } -} - -func TestEnsureWritablePathTUIOverwriteExisting(t *testing.T) { - tmp := t.TempDir() - target := filepath.Join(tmp, "existing.tar") - if err := os.WriteFile(target, []byte("payload"), 0o640); err != nil { - t.Fatalf("write existing file: %v", err) - } - - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - if failure != "" { - t.Fatalf("unexpected failure message: %s", failure) - } - return pathActionOverwrite, nil - }) - defer restore() - - got, err := ensureWritablePathTUI(target, "archive", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI error: %v", err) - } - if got != target { - t.Fatalf("path = %q, want %q", got, target) - } - if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("existing file should be removed, stat err=%v", err) - } -} - -func TestEnsureWritablePathTUINewPath(t *testing.T) { - tmp := t.TempDir() - existing := filepath.Join(tmp, "current.tar") - if err := os.WriteFile(existing, []byte("payload"), 0o640); err != nil { - t.Fatalf("write existing file: %v", err) - } - nextPath := filepath.Join(tmp, "new.tar") - - var promptCalls int - restorePrompt := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - promptCalls++ - if failure != "" { - t.Fatalf("unexpected failure message: %s", failure) - } - return pathActionNew, nil - }) - defer restorePrompt() - - restoreNew := stubPromptNewPath(func(current, configPath, buildSig string) (string, error) { - if filepath.Clean(current) != filepath.Clean(existing) { - t.Fatalf("promptNewPath received %q, want %q", current, existing) - } - return nextPath, nil - }) - defer restoreNew() - - got, err := ensureWritablePathTUI(existing, "bundle", "cfg", "sig") - if err != nil { - t.Fatalf("ensureWritablePathTUI error: %v", err) - } - if got != filepath.Clean(nextPath) { - t.Fatalf("path=%q, want %q", got, nextPath) - } - if promptCalls != 1 { - t.Fatalf("expected 1 prompt call, got %d", promptCalls) - } -} - -func TestEnsureWritablePathTUIAbortOnCancel(t *testing.T) { - path := mustCreateExistingFile(t) - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return pathActionCancel, nil - }) - defer restore() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, ErrDecryptAborted) { - t.Fatalf("expected ErrDecryptAborted, got %v", err) - } -} - -func TestEnsureWritablePathTUIPropagatesPromptErrors(t *testing.T) { - path := mustCreateExistingFile(t) - wantErr := errors.New("boom") - restore := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return "", wantErr - }) - defer restore() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, wantErr) { - t.Fatalf("expected %v, got %v", wantErr, err) - } -} - -func TestEnsureWritablePathTUINewPathAbort(t *testing.T) { - path := mustCreateExistingFile(t) - restorePrompt := stubPromptOverwriteAction(func(path, desc, failure, configPath, buildSig string) (string, error) { - return pathActionNew, nil - }) - defer restorePrompt() - - restoreNew := stubPromptNewPath(func(current, configPath, buildSig string) (string, error) { - return "", ErrDecryptAborted - }) - defer restoreNew() - - if _, err := ensureWritablePathTUI(path, "bundle", "cfg", "sig"); !errors.Is(err, ErrDecryptAborted) { - t.Fatalf("expected ErrDecryptAborted, got %v", err) - } -} - -func TestPreparePlainBundleTUICopiesRawArtifacts(t *testing.T) { - logger := logging.New(types.LogLevelError, false) - tmp := t.TempDir() - rawArchive := filepath.Join(tmp, "backup.tar") - rawMetadata := rawArchive + ".metadata" - rawChecksum := rawArchive + ".sha256" - - if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { - t.Fatalf("write archive: %v", err) - } - if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { - t.Fatalf("write metadata: %v", err) - } - if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { - t.Fatalf("write checksum: %v", err) - } - - cand := &decryptCandidate{ - Manifest: &backup.Manifest{ - ArchivePath: rawArchive, - EncryptionMode: "none", - CreatedAt: time.Now(), - Hostname: "node1", - }, - Source: sourceRaw, - RawArchivePath: rawArchive, - RawMetadataPath: rawMetadata, - RawChecksumPath: rawChecksum, - DisplayBase: "test-backup", - } - - ctx := context.Background() - prepared, err := preparePlainBundleTUI(ctx, cand, "1.0.0", logger, "cfg", "sig") - if err != nil { - t.Fatalf("preparePlainBundleTUI error: %v", err) - } - defer prepared.Cleanup() - - if prepared.ArchivePath == "" { - t.Fatalf("expected archive path to be set") - } - if prepared.Manifest.EncryptionMode != "none" { - t.Fatalf("expected manifest encryption mode none, got %s", prepared.Manifest.EncryptionMode) - } - if prepared.Manifest.ScriptVersion != "1.0.0" { - t.Fatalf("expected script version to propagate, got %s", prepared.Manifest.ScriptVersion) - } - if _, err := os.Stat(prepared.ArchivePath); err != nil { - t.Fatalf("expected staged archive to exist: %v", err) - } - if prepared.Checksum == "" { - t.Fatalf("expected checksum to be computed") - } -} - -func TestPreparePlainBundleTUIRejectsInvalidCandidate(t *testing.T) { - logger := logging.New(types.LogLevelError, false) - ctx := context.Background() - if _, err := preparePlainBundleTUI(ctx, nil, "", logger, "cfg", "sig"); err == nil { - t.Fatalf("expected error for nil candidate") - } -} - func TestBuildWizardPageReturnsFlex(t *testing.T) { content := tview.NewBox() page := buildWizardPage("Title", "/etc/proxsave/backup.env", "sig", content) @@ -264,25 +70,3 @@ func TestBuildWizardPageReturnsFlex(t *testing.T) { t.Fatalf("expected *tview.Flex, got %T", page) } } - -func stubPromptOverwriteAction(fn func(path, description, failureMessage, configPath, buildSig string) (string, error)) func() { - orig := promptOverwriteActionFunc - promptOverwriteActionFunc = fn - return func() { promptOverwriteActionFunc = orig } -} - -func stubPromptNewPath(fn func(current, configPath, buildSig string) (string, error)) func() { - orig := promptNewPathInputFunc - promptNewPathInputFunc = fn - return func() { promptNewPathInputFunc = orig } -} - -func mustCreateExistingFile(t *testing.T) string { - t.Helper() - tmp := t.TempDir() - path := filepath.Join(tmp, "existing.dat") - if err := os.WriteFile(path, []byte("data"), 0o640); err != nil { - t.Fatalf("write %s: %v", path, err) - } - return path -} diff --git a/internal/orchestrator/decrypt_workflow_ui.go b/internal/orchestrator/decrypt_workflow_ui.go index 7de3f697..f03a4136 100644 --- a/internal/orchestrator/decrypt_workflow_ui.go +++ b/internal/orchestrator/decrypt_workflow_ui.go @@ -177,6 +177,13 @@ func decryptArchiveWithSecretPrompt(ctx context.Context, encryptedPath, outputPa func preparePlainBundleWithUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, ui interface { PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) }) (bundle *preparedBundle, err error) { + if cand == nil || cand.Manifest == nil { + return nil, fmt.Errorf("invalid backup candidate") + } + if ui == nil { + return nil, fmt.Errorf("decrypt workflow UI not available") + } + done := logging.DebugStart(logger, "prepare plain bundle (ui)", "source=%v rclone=%v", cand.Source, cand.IsRclone) defer func() { done(err) }() return preparePlainBundleCommon(ctx, cand, version, logger, func(ctx context.Context, encryptedPath, outputPath, displayName string) error { diff --git a/internal/orchestrator/decrypt_workflow_ui_test.go b/internal/orchestrator/decrypt_workflow_ui_test.go new file mode 100644 index 00000000..069b55cb --- /dev/null +++ b/internal/orchestrator/decrypt_workflow_ui_test.go @@ -0,0 +1,284 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +type fakeDecryptWorkflowUI struct { + resolveExistingPathFn func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) +} + +func (f *fakeDecryptWorkflowUI) RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error { + panic("unexpected RunTask call") +} + +func (f *fakeDecryptWorkflowUI) ShowMessage(ctx context.Context, title, message string) error { + panic("unexpected ShowMessage call") +} + +func (f *fakeDecryptWorkflowUI) ShowError(ctx context.Context, title, message string) error { + panic("unexpected ShowError call") +} + +func (f *fakeDecryptWorkflowUI) SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) { + panic("unexpected SelectBackupSource call") +} + +func (f *fakeDecryptWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) { + panic("unexpected SelectBackupCandidate call") +} + +func (f *fakeDecryptWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir string) (string, error) { + panic("unexpected PromptDestinationDir call") +} + +func (f *fakeDecryptWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + if f.resolveExistingPathFn == nil { + panic("unexpected ResolveExistingPath call") + } + return f.resolveExistingPathFn(ctx, path, description, failure) +} + +func (f *fakeDecryptWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { + panic("unexpected PromptDecryptSecret call") +} + +type countingSecretPrompter struct { + calls int +} + +func (c *countingSecretPrompter) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { + c.calls++ + return "unused", nil +} + +func TestEnsureWritablePathWithUI_ReturnsCleanMissingPath(t *testing.T) { + originalFS := restoreFS + restoreFS = osFS{} + defer func() { restoreFS = originalFS }() + + tmp := t.TempDir() + target := filepath.Join(tmp, "subdir", "file.txt") + dirty := target + string(filepath.Separator) + ".." + string(filepath.Separator) + "file.txt" + + got, err := ensureWritablePathWithUI(context.Background(), &fakeDecryptWorkflowUI{}, dirty, "test file") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != target { + t.Fatalf("ensureWritablePathWithUI path=%q, want %q", got, target) + } +} + +func TestEnsureWritablePathWithUI_OverwriteExisting(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "existing.tar") + if err := os.WriteFile(target, []byte("payload"), 0o640); err != nil { + t.Fatalf("write existing file: %v", err) + } + + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + if path != target { + t.Fatalf("path=%q, want %q", path, target) + } + if failure != "" { + t.Fatalf("unexpected failure message: %s", failure) + } + return PathDecisionOverwrite, "", nil + }, + } + + got, err := ensureWritablePathWithUI(context.Background(), ui, target, "archive") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != target { + t.Fatalf("path=%q, want %q", got, target) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("existing file should be removed, stat err=%v", err) + } +} + +func TestEnsureWritablePathWithUI_NewPath(t *testing.T) { + tmp := t.TempDir() + existing := filepath.Join(tmp, "current.tar") + if err := os.WriteFile(existing, []byte("payload"), 0o640); err != nil { + t.Fatalf("write existing file: %v", err) + } + nextPath := filepath.Join(tmp, "next.tar") + + var calls int + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + calls++ + if path != existing { + t.Fatalf("path=%q, want %q", path, existing) + } + return PathDecisionNewPath, nextPath, nil + }, + } + + got, err := ensureWritablePathWithUI(context.Background(), ui, existing, "bundle") + if err != nil { + t.Fatalf("ensureWritablePathWithUI error: %v", err) + } + if got != filepath.Clean(nextPath) { + t.Fatalf("path=%q, want %q", got, filepath.Clean(nextPath)) + } + if calls != 1 { + t.Fatalf("expected 1 ResolveExistingPath call, got %d", calls) + } +} + +func TestEnsureWritablePathWithUI_AbortOnCancelDecision(t *testing.T) { + path := mustCreateExistingFile(t) + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", nil + }, + } + + if _, err := ensureWritablePathWithUI(context.Background(), ui, path, "bundle"); !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("expected ErrDecryptAborted, got %v", err) + } +} + +func TestEnsureWritablePathWithUI_PropagatesPromptErrors(t *testing.T) { + path := mustCreateExistingFile(t) + wantErr := errors.New("boom") + ui := &fakeDecryptWorkflowUI{ + resolveExistingPathFn: func(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", wantErr + }, + } + + if _, err := ensureWritablePathWithUI(context.Background(), ui, path, "bundle"); !errors.Is(err, wantErr) { + t.Fatalf("expected %v, got %v", wantErr, err) + } +} + +func TestPreparePlainBundleWithUICopiesRawArtifacts(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + tmp := t.TempDir() + rawArchive := filepath.Join(tmp, "backup.tar") + rawMetadata := rawArchive + ".metadata" + rawChecksum := rawArchive + ".sha256" + + if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { + t.Fatalf("write archive: %v", err) + } + if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { + t.Fatalf("write metadata: %v", err) + } + if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { + t.Fatalf("write checksum: %v", err) + } + + cand := &decryptCandidate{ + Manifest: &backup.Manifest{ + ArchivePath: rawArchive, + EncryptionMode: "none", + CreatedAt: time.Now(), + Hostname: "node1", + }, + Source: sourceRaw, + RawArchivePath: rawArchive, + RawMetadataPath: rawMetadata, + RawChecksumPath: rawChecksum, + DisplayBase: "test-backup", + } + + ctx := context.Background() + prompter := &countingSecretPrompter{} + prepared, err := preparePlainBundleWithUI(ctx, cand, "1.0.0", logger, prompter) + if err != nil { + t.Fatalf("preparePlainBundleWithUI error: %v", err) + } + defer prepared.Cleanup() + + if prepared.ArchivePath == "" { + t.Fatalf("expected archive path to be set") + } + if prepared.Manifest.EncryptionMode != "none" { + t.Fatalf("expected manifest encryption mode none, got %s", prepared.Manifest.EncryptionMode) + } + if prepared.Manifest.ScriptVersion != "1.0.0" { + t.Fatalf("expected script version to propagate, got %s", prepared.Manifest.ScriptVersion) + } + if _, err := os.Stat(prepared.ArchivePath); err != nil { + t.Fatalf("expected staged archive to exist: %v", err) + } + if prepared.Checksum == "" { + t.Fatalf("expected checksum to be computed") + } + if prompter.calls != 0 { + t.Fatalf("PromptDecryptSecret should not be called for plain backups, got %d calls", prompter.calls) + } +} + +func TestPreparePlainBundleWithUIRejectsInvalidCandidate(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + ctx := context.Background() + prompter := &countingSecretPrompter{} + if _, err := preparePlainBundleWithUI(ctx, nil, "", logger, prompter); err == nil { + t.Fatalf("expected error for nil candidate") + } +} + +func TestPreparePlainBundleWithUIRejectsMissingUI(t *testing.T) { + logger := logging.New(types.LogLevelError, false) + tmp := t.TempDir() + rawArchive := filepath.Join(tmp, "backup.tar") + rawMetadata := rawArchive + ".metadata" + rawChecksum := rawArchive + ".sha256" + + if err := os.WriteFile(rawArchive, []byte("payload-data"), 0o640); err != nil { + t.Fatalf("write archive: %v", err) + } + if err := os.WriteFile(rawMetadata, []byte(`{"manifest":true}`), 0o640); err != nil { + t.Fatalf("write metadata: %v", err) + } + if err := os.WriteFile(rawChecksum, checksumLineForBytes("backup.tar", []byte("payload-data")), 0o640); err != nil { + t.Fatalf("write checksum: %v", err) + } + + cand := &decryptCandidate{ + Manifest: &backup.Manifest{ + ArchivePath: rawArchive, + EncryptionMode: "none", + CreatedAt: time.Now(), + Hostname: "node1", + }, + Source: sourceRaw, + RawArchivePath: rawArchive, + RawMetadataPath: rawMetadata, + RawChecksumPath: rawChecksum, + DisplayBase: "test-backup", + } + + if _, err := preparePlainBundleWithUI(context.Background(), cand, "1.0.0", logger, nil); err == nil { + t.Fatalf("expected error for missing UI") + } +} + +func mustCreateExistingFile(t *testing.T) string { + t.Helper() + + tmp := t.TempDir() + path := filepath.Join(tmp, "existing.dat") + if err := os.WriteFile(path, []byte("data"), 0o640); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index 27dd3d0a..2c705ee4 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -61,12 +61,15 @@ func withSimApp(t *testing.T, keys []tcell.Key) { func TestPromptOverwriteAction_SelectsOverwrite(t *testing.T) { withSimApp(t, []tcell.Key{tcell.KeyEnter}) - got, err := promptOverwriteAction("/tmp/existing", "file", "", "/tmp/config.env", "sig") + decision, newPath, err := promptExistingPathDecisionTUI("/tmp/existing", "file", "", "/tmp/config.env", "sig") if err != nil { - t.Fatalf("promptOverwriteAction error: %v", err) + t.Fatalf("promptExistingPathDecisionTUI error: %v", err) } - if got != pathActionOverwrite { - t.Fatalf("choice=%q; want %q", got, pathActionOverwrite) + if decision != PathDecisionOverwrite { + t.Fatalf("decision=%v; want %v", decision, PathDecisionOverwrite) + } + if newPath != "" { + t.Fatalf("newPath=%q; want empty", newPath) } } @@ -74,9 +77,9 @@ func TestPromptNewPathInput_ContinueReturnsDefault(t *testing.T) { // Move focus to Continue button then submit. withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) - got, err := promptNewPathInput("/tmp/newpath", "/tmp/config.env", "sig") + got, err := promptNewPathInputTUI("/tmp/newpath", "/tmp/config.env", "sig") if err != nil { - t.Fatalf("promptNewPathInput error: %v", err) + t.Fatalf("promptNewPathInputTUI error: %v", err) } if got != "/tmp/newpath" { t.Fatalf("path=%q; want %q", got, "/tmp/newpath") diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go index 88c83b3f..8849a04a 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt.go @@ -375,83 +375,18 @@ func (u *tuiWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir str } func (u *tuiWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) { - action, err := promptOverwriteActionFunc(path, description, failure, u.configPath, u.buildSig) + decision, newPath, err := tuiPromptExistingPathDecision(path, description, failure, u.configPath, u.buildSig) if err != nil { return PathDecisionCancel, "", err } - switch action { - case pathActionOverwrite: - return PathDecisionOverwrite, "", nil - case pathActionNew: - newPath, err := promptNewPathInputFunc(path, u.configPath, u.buildSig) - if err != nil { - return PathDecisionCancel, "", err - } - return PathDecisionNewPath, filepath.Clean(newPath), nil - default: - return PathDecisionCancel, "", ErrDecryptAborted + if decision != PathDecisionNewPath { + return decision, "", nil } + return decision, filepath.Clean(newPath), nil } func (u *tuiWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) { - app := newTUIApp() - var ( - secret string - cancelled bool - ) - - name := strings.TrimSpace(displayName) - if name == "" { - name = "selected backup" - } - - infoMessage := fmt.Sprintf("Provide the AGE secret key or passphrase used for [yellow]%s[white].", name) - if strings.TrimSpace(previousError) != "" { - infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, strings.TrimSpace(previousError)) - } - - infoText := tview.NewTextView(). - SetText(infoMessage). - SetWrap(true). - SetTextColor(tcell.ColorWhite). - SetDynamicColors(true) - - form := components.NewForm(app) - label := "Key or passphrase:" - form.AddPasswordField(label, 64) - form.SetOnSubmit(func(values map[string]string) error { - raw := strings.TrimSpace(values[label]) - if raw == "" { - return fmt.Errorf("key or passphrase cannot be empty") - } - if raw == "0" { - cancelled = true - return nil - } - secret = raw - return nil - }) - form.SetOnCancel(func() { - cancelled = true - }) - form.AddSubmitButton("Continue") - form.AddCancelButton("Cancel") - enableFormNavigation(form, nil) - - content := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(infoText, 0, 2, false). - AddItem(form.Form, 0, 1, true) - - page := u.buildPage("Decrypt key", u.configPath, u.buildSig, content) - form.SetParentView(page) - if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { - return "", err - } - if cancelled { - return "", ErrDecryptAborted - } - return secret, nil + return tuiPromptDecryptSecret(u.configPath, u.buildSig, displayName, previousError) } func backupSummaryForUI(cand *decryptCandidate) string { diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go new file mode 100644 index 00000000..eb78fd2c --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go @@ -0,0 +1,180 @@ +package orchestrator + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/tui" + "github.com/tis24dev/proxsave/internal/tui/components" +) + +var ( + tuiPromptExistingPathDecision = promptExistingPathDecisionTUI + tuiPromptDecryptSecret = promptDecryptSecretTUI +) + +func promptExistingPathDecisionTUI(path, description, failureMessage, configPath, buildSig string) (ExistingPathDecision, string, error) { + app := newTUIApp() + decision := PathDecisionCancel + + message := fmt.Sprintf("The %s [yellow]%s[white] already exists.\nSelect how you want to proceed.", description, path) + if strings.TrimSpace(failureMessage) != "" { + message = fmt.Sprintf("%s\n\n[red]%s[white]", message, failureMessage) + } + message += "\n\n[yellow]Use ←→ or TAB to switch buttons | ENTER to confirm[white]" + + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"Overwrite", "Use different path", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonLabel { + case "Overwrite": + decision = PathDecisionOverwrite + case "Use different path": + decision = PathDecisionNewPath + default: + decision = PathDecisionCancel + } + app.Stop() + }) + + modal.SetBorder(true). + SetTitle(" Existing file "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.WarningYellow). + SetBorderColor(tui.WarningYellow). + SetBackgroundColor(tcell.ColorBlack) + + page := buildWizardPage("Destination path", configPath, buildSig, modal) + if err := app.SetRoot(page, true).SetFocus(modal).Run(); err != nil { + return PathDecisionCancel, "", err + } + if decision != PathDecisionNewPath { + return decision, "", nil + } + + newPath, err := promptNewPathInputTUI(path, configPath, buildSig) + if err != nil { + if err == ErrDecryptAborted { + return PathDecisionCancel, "", nil + } + return PathDecisionCancel, "", err + } + return PathDecisionNewPath, filepath.Clean(newPath), nil +} + +func promptNewPathInputTUI(defaultPath, configPath, buildSig string) (string, error) { + app := newTUIApp() + var newPath string + var cancelled bool + + form := components.NewForm(app) + label := "New path" + form.AddInputFieldWithValidation(label, defaultPath, 64, func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("path cannot be empty") + } + return nil + }) + form.SetOnSubmit(func(values map[string]string) error { + newPath = strings.TrimSpace(values[label]) + return nil + }) + form.SetOnCancel(func() { + cancelled = true + }) + form.AddSubmitButton("Continue") + form.AddCancelButton("Cancel") + enableFormNavigation(form, nil) + + helper := tview.NewTextView(). + SetText("Provide a writable filesystem path for the decrypted files."). + SetWrap(true). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true) + + content := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(helper, 3, 0, false). + AddItem(form.Form, 0, 1, true) + + page := buildWizardPage("Choose destination path", configPath, buildSig, content) + form.SetParentView(page) + + if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { + return "", err + } + if cancelled { + return "", ErrDecryptAborted + } + return filepath.Clean(newPath), nil +} + +func promptDecryptSecretTUI(configPath, buildSig, displayName, previousError string) (string, error) { + app := newTUIApp() + var ( + secret string + cancelled bool + ) + + name := strings.TrimSpace(displayName) + if name == "" { + name = "selected backup" + } + + infoMessage := fmt.Sprintf( + "Provide the AGE secret key or passphrase used for [yellow]%s[white].\n\n"+ + "Enter [yellow]0[white] to exit or use [yellow]Cancel[white].", + name, + ) + if strings.TrimSpace(previousError) != "" { + infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, strings.TrimSpace(previousError)) + } + + infoText := tview.NewTextView(). + SetText(infoMessage). + SetWrap(true). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true) + + form := components.NewForm(app) + label := "Key or passphrase:" + form.AddPasswordField(label, 64) + form.SetOnSubmit(func(values map[string]string) error { + raw := strings.TrimSpace(values[label]) + if raw == "" { + return fmt.Errorf("key or passphrase cannot be empty") + } + if raw == "0" { + cancelled = true + return nil + } + secret = raw + return nil + }) + form.SetOnCancel(func() { + cancelled = true + }) + form.AddSubmitButton("Continue") + form.AddCancelButton("Cancel") + enableFormNavigation(form, nil) + + content := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(infoText, 0, 2, false). + AddItem(form.Form, 0, 1, true) + + page := buildWizardPage("Decrypt key", configPath, buildSig, content) + form.SetParentView(page) + if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil { + return "", err + } + if cancelled { + return "", ErrDecryptAborted + } + return secret, nil +} diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_test.go b/internal/orchestrator/workflow_ui_tui_decrypt_test.go new file mode 100644 index 00000000..8abf48ab --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_decrypt_test.go @@ -0,0 +1,102 @@ +package orchestrator + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/gdamore/tcell/v2" +) + +func stubTUIExistingPathDecisionPrompt(fn func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error)) func() { + orig := tuiPromptExistingPathDecision + tuiPromptExistingPathDecision = fn + return func() { tuiPromptExistingPathDecision = orig } +} + +func TestTUIWorkflowUIResolveExistingPath_Overwrite(t *testing.T) { + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + if path != "/tmp/archive.tar" { + t.Fatalf("path=%q, want /tmp/archive.tar", path) + } + if description != "archive" { + t.Fatalf("description=%q, want archive", description) + } + if configPath != "/tmp/config.env" { + t.Fatalf("configPath=%q, want /tmp/config.env", configPath) + } + if buildSig != "sig" { + t.Fatalf("buildSig=%q, want sig", buildSig) + } + return PathDecisionOverwrite, "", nil + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionOverwrite { + t.Fatalf("decision=%v, want %v", decision, PathDecisionOverwrite) + } + if newPath != "" { + t.Fatalf("newPath=%q, want empty", newPath) + } +} + +func TestTUIWorkflowUIResolveExistingPath_NewPathIsCleaned(t *testing.T) { + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + return PathDecisionNewPath, "/tmp/out/../out/final.tar", nil + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != filepath.Clean("/tmp/out/../out/final.tar") { + t.Fatalf("newPath=%q, want %q", newPath, filepath.Clean("/tmp/out/../out/final.tar")) + } +} + +func TestTUIWorkflowUIResolveExistingPath_PropagatesError(t *testing.T) { + wantErr := errors.New("boom") + restore := stubTUIExistingPathDecisionPrompt(func(path, description, failure, configPath, buildSig string) (ExistingPathDecision, string, error) { + return PathDecisionCancel, "", wantErr + }) + defer restore() + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + if _, _, err := ui.ResolveExistingPath(context.Background(), "/tmp/archive.tar", "archive", ""); !errors.Is(err, wantErr) { + t.Fatalf("expected %v, got %v", wantErr, err) + } +} + +func TestTUIWorkflowUIPromptDestinationDir_ContinueReturnsCleanPath(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + got, err := ui.PromptDestinationDir(context.Background(), "/tmp/out/../out") + if err != nil { + t.Fatalf("PromptDestinationDir error: %v", err) + } + if got != "/tmp/out" { + t.Fatalf("destination=%q, want %q", got, "/tmp/out") + } +} + +func TestTUIWorkflowUIPromptDestinationDir_CancelReturnsAborted(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyTab, tcell.KeyEnter}) + + ui := newTUIWorkflowUI("/tmp/config.env", "sig", nil) + _, err := ui.PromptDestinationDir(context.Background(), "/tmp/out") + if !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("err=%v, want %v", err, ErrDecryptAborted) + } +} diff --git a/internal/orchestrator/workflow_ui_tui_shared.go b/internal/orchestrator/workflow_ui_tui_shared.go new file mode 100644 index 00000000..3488bbbf --- /dev/null +++ b/internal/orchestrator/workflow_ui_tui_shared.go @@ -0,0 +1,49 @@ +package orchestrator + +import ( + "github.com/gdamore/tcell/v2" + + "github.com/tis24dev/proxsave/internal/tui/components" +) + +func enableFormNavigation(form *components.Form, dropdownOpen *bool) { + if form == nil || form.Form == nil { + return + } + form.Form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event == nil { + return event + } + if dropdownOpen != nil && *dropdownOpen { + return event + } + + formItemIndex, buttonIndex := form.Form.GetFocusedItemIndex() + isOnButton := formItemIndex < 0 && buttonIndex >= 0 + isOnField := formItemIndex >= 0 + + if isOnButton { + switch event.Key() { + case tcell.KeyLeft, tcell.KeyUp: + return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case tcell.KeyRight, tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + } + } else if isOnField { + // If focused item is a ListFormItem, let it handle navigation internally. + if formItemIndex >= 0 { + if _, ok := form.Form.GetFormItem(formItemIndex).(*components.ListFormItem); ok { + return event + } + } + // For other form fields, convert arrows to tab navigation. + switch event.Key() { + case tcell.KeyUp: + return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + } + } + return event + }) +} From a10ce5a0e83055bab33d4eba243f76bef0a406d0 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 20:01:29 +0100 Subject: [PATCH 07/29] Add end-to-end coverage for the production decrypt TUI flow Add deterministic end-to-end smoke tests for RunDecryptWorkflowTUI so the real decrypt TUI production path is covered from entrypoint through source selection, candidate selection, secret prompt, destination prompt, and final bundle creation. Introduce test-only helpers for a real AGE-encrypted raw-backup fixture, serialized TUI simulation across multi-screen workflows, bundle-content inspection, and guarded workflow execution. Verify both the success path (including final *.decrypted.bundle.tar contents, metadata, and checksum) and clean abort at the decrypt secret prompt, without changing production behavior. --- .../decrypt_tui_e2e_helpers_test.go | 272 ++++++++++++++++++ internal/orchestrator/decrypt_tui_e2e_test.go | 99 +++++++ 2 files changed, 371 insertions(+) create mode 100644 internal/orchestrator/decrypt_tui_e2e_helpers_test.go create mode 100644 internal/orchestrator/decrypt_tui_e2e_test.go diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go new file mode 100644 index 00000000..d9af2167 --- /dev/null +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -0,0 +1,272 @@ +package orchestrator + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "filippo.io/age" + "github.com/gdamore/tcell/v2" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/tui" + "github.com/tis24dev/proxsave/internal/types" +) + +var decryptTUIE2EMu sync.Mutex + +type timedSimKey struct { + Key tcell.Key + R rune + Mod tcell.ModMask + Wait time.Duration +} + +type decryptTUIFixture struct { + Config *config.Config + ConfigPath string + BackupDir string + BaseDir string + DestinationDir string + ArchivePlaintext []byte + Secret string + EncryptedArchive string + ExpectedBundlePath string + ExpectedArchiveName string + ExpectedChecksum string +} + +func lockDecryptTUIE2E(t *testing.T) { + t.Helper() + + decryptTUIE2EMu.Lock() + t.Cleanup(decryptTUIE2EMu.Unlock) +} + +func withTimedSimAppSequence(t *testing.T, keys []timedSimKey) { + t.Helper() + + orig := newTUIApp + screen := tcell.NewSimulationScreen("UTF-8") + if err := screen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + screen.SetSize(120, 40) + + var once sync.Once + newTUIApp = func() *tui.App { + app := tui.NewApp() + app.SetScreen(screen) + + once.Do(func() { + go func() { + for _, k := range keys { + if k.Wait > 0 { + time.Sleep(k.Wait) + } + mod := k.Mod + if mod == 0 { + mod = tcell.ModNone + } + screen.InjectKey(k.Key, k.R, mod) + } + }() + }) + + return app + } + + t.Cleanup(func() { + newTUIApp = orig + }) +} + +func createDecryptTUIEncryptedFixture(t *testing.T) *decryptTUIFixture { + t.Helper() + + backupDir := t.TempDir() + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "backup.env") + if err := os.WriteFile(configPath, []byte("BACKUP_PATH="+backupDir+"\nBASE_DIR="+baseDir+"\n"), 0o600); err != nil { + t.Fatalf("write config placeholder: %v", err) + } + + passphrase := "Decrypt123!" + recipientStr, err := deriveDeterministicRecipientFromPassphrase(passphrase) + if err != nil { + t.Fatalf("deriveDeterministicRecipientFromPassphrase: %v", err) + } + recipient, err := age.ParseX25519Recipient(recipientStr) + if err != nil { + t.Fatalf("age.ParseX25519Recipient: %v", err) + } + + plaintext := []byte("proxsave decrypt tui e2e plaintext\n") + archivePath := filepath.Join(backupDir, "backup.tar.xz.age") + archiveFile, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create encrypted archive: %v", err) + } + + encWriter, err := age.Encrypt(archiveFile, recipient) + if err != nil { + _ = archiveFile.Close() + t.Fatalf("age.Encrypt: %v", err) + } + if _, err := encWriter.Write(plaintext); err != nil { + _ = encWriter.Close() + _ = archiveFile.Close() + t.Fatalf("write plaintext to age writer: %v", err) + } + if err := encWriter.Close(); err != nil { + _ = archiveFile.Close() + t.Fatalf("close age writer: %v", err) + } + if err := archiveFile.Close(); err != nil { + t.Fatalf("close encrypted archive: %v", err) + } + + encryptedBytes, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read encrypted archive: %v", err) + } + + createdAt := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + manifest := &backup.Manifest{ + ArchivePath: archivePath, + CreatedAt: createdAt, + Hostname: "node1", + EncryptionMode: "age", + ProxmoxType: "pve", + } + manifestData, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(archivePath+".metadata", manifestData, 0o640); err != nil { + t.Fatalf("write manifest sidecar: %v", err) + } + if err := os.WriteFile(archivePath+".sha256", checksumLineForBytes(filepath.Base(archivePath), encryptedBytes), 0o640); err != nil { + t.Fatalf("write checksum sidecar: %v", err) + } + + checksum := sha256.Sum256(plaintext) + expectedArchiveName := "backup.tar.xz" + destinationDir := filepath.Join(baseDir, "decrypt") + + return &decryptTUIFixture{ + Config: &config.Config{ + BackupPath: backupDir, + BaseDir: baseDir, + SecondaryEnabled: false, + CloudEnabled: false, + }, + ConfigPath: configPath, + BackupDir: backupDir, + BaseDir: baseDir, + DestinationDir: destinationDir, + ArchivePlaintext: plaintext, + Secret: passphrase, + EncryptedArchive: archivePath, + ExpectedBundlePath: filepath.Join(destinationDir, expectedArchiveName+".decrypted.bundle.tar"), + ExpectedArchiveName: expectedArchiveName, + ExpectedChecksum: hex.EncodeToString(checksum[:]), + } +} + +func successDecryptTUISequence(secret string) []timedSimKey { + keys := []timedSimKey{ + {Key: tcell.KeyEnter, Wait: 150 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 300 * time.Millisecond}, + } + + for _, r := range secret { + keys = append(keys, timedSimKey{ + Key: tcell.KeyRune, + R: r, + Wait: 20 * time.Millisecond, + }) + } + + keys = append(keys, + timedSimKey{Key: tcell.KeyTab, Wait: 80 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, + timedSimKey{Key: tcell.KeyTab, Wait: 300 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, + ) + + return keys +} + +func abortDecryptTUISequence() []timedSimKey { + return []timedSimKey{ + {Key: tcell.KeyEnter, Wait: 150 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 300 * time.Millisecond}, + {Key: tcell.KeyRune, R: '0', Wait: 300 * time.Millisecond}, + {Key: tcell.KeyTab, Wait: 80 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, + } +} + +func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config.Config, configPath string) error { + t.Helper() + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + + errCh := make(chan error, 1) + go func() { + errCh <- RunDecryptWorkflowTUI(ctx, cfg, logger, "1.0.0", configPath, "test-build") + }() + + select { + case err := <-errCh: + return err + case <-time.After(12 * time.Second): + t.Fatalf("RunDecryptWorkflowTUI did not complete within 12s") + return nil + } +} + +func readTarEntries(t *testing.T, tarPath string) map[string][]byte { + t.Helper() + + file, err := os.Open(tarPath) + if err != nil { + t.Fatalf("open tar %s: %v", tarPath, err) + } + defer file.Close() + + tr := tar.NewReader(file) + entries := make(map[string][]byte) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read tar header from %s: %v", tarPath, err) + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read tar entry %s: %v", hdr.Name, err) + } + entries[hdr.Name] = data + } + return entries +} + +func checksumLineForArchiveHex(filename, checksumHex string) string { + return fmt.Sprintf("%s %s\n", checksumHex, filename) +} diff --git a/internal/orchestrator/decrypt_tui_e2e_test.go b/internal/orchestrator/decrypt_tui_e2e_test.go new file mode 100644 index 00000000..11cee746 --- /dev/null +++ b/internal/orchestrator/decrypt_tui_e2e_test.go @@ -0,0 +1,99 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" +) + +func TestRunDecryptWorkflowTUI_SuccessLocalEncrypted(t *testing.T) { + lockDecryptTUIE2E(t) + + origFS := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = origFS }) + + fixture := createDecryptTUIEncryptedFixture(t) + withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath); err != nil { + t.Fatalf("RunDecryptWorkflowTUI error: %v", err) + } + + if _, err := os.Stat(fixture.ExpectedBundlePath); err != nil { + t.Fatalf("expected decrypted bundle at %s: %v", fixture.ExpectedBundlePath, err) + } + + entries := readTarEntries(t, fixture.ExpectedBundlePath) + + archiveData, ok := entries[fixture.ExpectedArchiveName] + if !ok { + t.Fatalf("bundle missing archive entry %s", fixture.ExpectedArchiveName) + } + if string(archiveData) != string(fixture.ArchivePlaintext) { + t.Fatalf("archive entry content mismatch: got %q want %q", string(archiveData), string(fixture.ArchivePlaintext)) + } + + metadataName := fixture.ExpectedArchiveName + ".metadata" + metadataData, ok := entries[metadataName] + if !ok { + t.Fatalf("bundle missing metadata entry %s", metadataName) + } + + var manifest backup.Manifest + if err := json.Unmarshal(metadataData, &manifest); err != nil { + t.Fatalf("unmarshal metadata entry %s: %v", metadataName, err) + } + if manifest.EncryptionMode != "none" { + t.Fatalf("metadata EncryptionMode=%q; want %q", manifest.EncryptionMode, "none") + } + expectedArchivePath := filepath.Join(fixture.DestinationDir, fixture.ExpectedArchiveName) + if manifest.ArchivePath != expectedArchivePath { + t.Fatalf("metadata ArchivePath=%q; want %q", manifest.ArchivePath, expectedArchivePath) + } + if manifest.SHA256 != fixture.ExpectedChecksum { + t.Fatalf("metadata SHA256=%q; want %q", manifest.SHA256, fixture.ExpectedChecksum) + } + + checksumName := fixture.ExpectedArchiveName + ".sha256" + checksumData, ok := entries[checksumName] + if !ok { + t.Fatalf("bundle missing checksum entry %s", checksumName) + } + expectedChecksumLine := checksumLineForArchiveHex(fixture.ExpectedArchiveName, fixture.ExpectedChecksum) + if string(checksumData) != expectedChecksumLine { + t.Fatalf("checksum entry=%q; want %q", string(checksumData), expectedChecksumLine) + } +} + +func TestRunDecryptWorkflowTUI_AbortAtSecretPrompt(t *testing.T) { + lockDecryptTUIE2E(t) + + origFS := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = origFS }) + + fixture := createDecryptTUIEncryptedFixture(t) + withTimedSimAppSequence(t, abortDecryptTUISequence()) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath) + if !errors.Is(err, ErrDecryptAborted) { + t.Fatalf("RunDecryptWorkflowTUI error=%v; want %v", err, ErrDecryptAborted) + } + + if _, err := os.Stat(fixture.ExpectedBundlePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected no decrypted bundle at %s, stat err=%v", fixture.ExpectedBundlePath, err) + } +} From a7a3df69ec3ab43a005d5d3c7d966c70cf016fff Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 20:17:46 +0100 Subject: [PATCH 08/29] Align secondary disable semantics across CLI and TUI Introduce a shared env-template helper for secondary storage state and use it from both installer flows so disabling secondary storage always writes the same canonical config: SECONDARY_ENABLED=false, SECONDARY_PATH=, and SECONDARY_LOG_PATH=. This removes the previous TUI-only drift where editing an existing backup.env could leave stale secondary paths after the user disabled the feature. Add focused unit coverage for the shared helper plus CLI and TUI regression tests covering disabled state and clearing of pre-existing secondary values, and clarify the installer docs to note that disabling secondary storage clears the saved secondary paths. --- cmd/proxsave/install.go | 8 +-- cmd/proxsave/install_test.go | 32 ++++++++++++ docs/CLI_REFERENCE.md | 2 +- docs/INSTALL.md | 2 +- internal/config/env_mutation.go | 24 +++++++++ internal/config/env_mutation_test.go | 74 ++++++++++++++++++++++++++++ internal/tui/wizard/install.go | 13 +++-- internal/tui/wizard/install_test.go | 34 +++++++++++++ 8 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 internal/config/env_mutation.go create mode 100644 internal/config/env_mutation_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 3feb3327..325cc332 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -637,13 +637,9 @@ func configureSecondaryStorage(ctx context.Context, reader *bufio.Reader, templa } break } - template = setEnvValue(template, "SECONDARY_ENABLED", "true") - template = setEnvValue(template, "SECONDARY_PATH", secondaryPath) - template = setEnvValue(template, "SECONDARY_LOG_PATH", secondaryLog) + template = config.ApplySecondaryStorageSettings(template, true, secondaryPath, secondaryLog) } else { - template = setEnvValue(template, "SECONDARY_ENABLED", "false") - template = setEnvValue(template, "SECONDARY_PATH", "") - template = setEnvValue(template, "SECONDARY_LOG_PATH", "") + template = config.ApplySecondaryStorageSettings(template, false, "", "") } return template, nil } diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index 525b0ad6..827f22f9 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -372,6 +372,38 @@ func TestConfigureSecondaryStorageDisabled(t *testing.T) { if !strings.Contains(result, "SECONDARY_ENABLED=false") { t.Fatalf("expected disabled flag in template: %q", result) } + if !strings.Contains(result, "SECONDARY_PATH=") { + t.Fatalf("expected cleared secondary path in template: %q", result) + } + if !strings.Contains(result, "SECONDARY_LOG_PATH=") { + t.Fatalf("expected cleared secondary log path in template: %q", result) + } +} + +func TestConfigureSecondaryStorageDisabledClearsExistingValues(t *testing.T) { + var result string + var err error + ctx := context.Background() + reader := bufio.NewReader(strings.NewReader("n\n")) + template := "SECONDARY_ENABLED=true\nSECONDARY_PATH=/mnt/old-secondary\nSECONDARY_LOG_PATH=/mnt/old-secondary/logs\n" + captureStdout(t, func() { + result, err = configureSecondaryStorage(ctx, reader, template) + }) + if err != nil { + t.Fatalf("configureSecondaryStorage error: %v", err) + } + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(result, needle) { + t.Fatalf("expected %q in template: %q", needle, result) + } + } + if strings.Contains(result, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared: %q", result) + } } func TestConfigureCloudStorageEnabled(t *testing.T) { diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 83a6be81..4c41b691 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -140,7 +140,7 @@ Some interactive commands support two interface modes: **Wizard workflow**: 1. Generates/updates the configuration file (`configs/backup.env` by default) -2. Optionally configures secondary storage (`SECONDARY_PATH` required if enabled; `SECONDARY_LOG_PATH` optional; invalid secondary paths are re-prompted/rejected) +2. Optionally configures secondary storage (`SECONDARY_PATH` required if enabled; `SECONDARY_LOG_PATH` optional; invalid secondary paths are re-prompted/rejected; disabling secondary storage clears both saved secondary paths) 3. Optionally configures cloud storage (rclone) 4. Optionally enables firewall rules collection (`BACKUP_FIREWALL_RULES=false` by default) 5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d19a5145..172acc58 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -226,7 +226,7 @@ Final install steps still run: **Wizard prompts:** 1. **Configuration file path**: Default `configs/backup.env` (accepts absolute or relative paths within repo) -2. **Secondary storage**: Optional path for backup/log copies +2. **Secondary storage**: Optional path for backup/log copies; disabling it clears both saved secondary paths from `backup.env` 3. **Cloud storage (rclone)**: Optional rclone configuration (supports `CLOUD_REMOTE` as a remote name (recommended) or legacy `remote:path`; `CLOUD_LOG_PATH` supports path-only (recommended) or `otherremote:/path`) 4. **Firewall rules**: Optional firewall rules collection toggle (`BACKUP_FIREWALL_RULES=false` by default; supports iptables/nftables) 5. **Notifications**: Enable Telegram (centralized) and Email notifications (wizard defaults to `EMAIL_DELIVERY_METHOD=relay`; you can switch to `sendmail` or `pmf` later) diff --git a/internal/config/env_mutation.go b/internal/config/env_mutation.go new file mode 100644 index 00000000..5ade6071 --- /dev/null +++ b/internal/config/env_mutation.go @@ -0,0 +1,24 @@ +package config + +import ( + "strings" + + "github.com/tis24dev/proxsave/pkg/utils" +) + +// ApplySecondaryStorageSettings writes the canonical secondary-storage state +// into an env template. Disabled secondary storage always clears both +// SECONDARY_PATH and SECONDARY_LOG_PATH so the saved config matches user intent. +func ApplySecondaryStorageSettings(template string, enabled bool, secondaryPath string, secondaryLogPath string) string { + if enabled { + template = utils.SetEnvValue(template, "SECONDARY_ENABLED", "true") + template = utils.SetEnvValue(template, "SECONDARY_PATH", strings.TrimSpace(secondaryPath)) + template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", strings.TrimSpace(secondaryLogPath)) + return template + } + + template = utils.SetEnvValue(template, "SECONDARY_ENABLED", "false") + template = utils.SetEnvValue(template, "SECONDARY_PATH", "") + template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", "") + return template +} diff --git a/internal/config/env_mutation_test.go b/internal/config/env_mutation_test.go new file mode 100644 index 00000000..6b434b68 --- /dev/null +++ b/internal/config/env_mutation_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "strings" + "testing" +) + +func TestApplySecondaryStorageSettingsEnabled(t *testing.T) { + template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=\n" + + got := ApplySecondaryStorageSettings(template, true, " /mnt/secondary ", " /mnt/secondary/log ") + + for _, needle := range []string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/secondary", + "SECONDARY_LOG_PATH=/mnt/secondary/log", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} + +func TestApplySecondaryStorageSettingsEnabledWithEmptyLogPath(t *testing.T) { + template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=/old/log\n" + + got := ApplySecondaryStorageSettings(template, true, "/mnt/secondary", "") + + for _, needle := range []string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/secondary", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} + +func TestApplySecondaryStorageSettingsDisabledClearsValues(t *testing.T) { + template := "SECONDARY_ENABLED=true\nSECONDARY_PATH=/mnt/old-secondary\nSECONDARY_LOG_PATH=/mnt/old-secondary/logs\n" + + got := ApplySecondaryStorageSettings(template, false, "/ignored", "/ignored/logs") + + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } + if strings.Contains(got, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared:\n%s", got) + } +} + +func TestApplySecondaryStorageSettingsDisabledAppendsCanonicalState(t *testing.T) { + template := "BACKUP_ENABLED=true\n" + + got := ApplySecondaryStorageSettings(template, false, "", "") + + for _, needle := range []string{ + "BACKUP_ENABLED=true", + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q in template:\n%s", needle, got) + } + } +} diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index b81269d9..2098b872 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -498,13 +498,12 @@ func ApplyInstallData(baseTemplate string, data *InstallWizardData) (string, err template = unsetEnvValue(template, "CRON_MINUTE") // Apply secondary storage - if data.EnableSecondaryStorage { - template = setEnvValue(template, "SECONDARY_ENABLED", "true") - template = setEnvValue(template, "SECONDARY_PATH", strings.TrimSpace(data.SecondaryPath)) - template = setEnvValue(template, "SECONDARY_LOG_PATH", strings.TrimSpace(data.SecondaryLogPath)) - } else { - template = setEnvValue(template, "SECONDARY_ENABLED", "false") - } + template = config.ApplySecondaryStorageSettings( + template, + data.EnableSecondaryStorage, + data.SecondaryPath, + data.SecondaryLogPath, + ) // Apply cloud storage if data.EnableCloudStorage { diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index e2a04076..e5d77cdb 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -126,6 +126,40 @@ func TestApplyInstallDataAllowsEmptySecondaryLogPath(t *testing.T) { } } +func TestApplyInstallDataDisabledSecondaryClearsExistingValues(t *testing.T) { + baseTemplate := strings.Join([]string{ + "SECONDARY_ENABLED=true", + "SECONDARY_PATH=/mnt/old-secondary", + "SECONDARY_LOG_PATH=/mnt/old-secondary/logs", + "TELEGRAM_ENABLED=false", + "EMAIL_ENABLED=false", + "ENCRYPT_ARCHIVE=false", + "", + }, "\n") + data := &InstallWizardData{ + BaseDir: "/tmp/base", + EnableSecondaryStorage: false, + } + + result, err := ApplyInstallData(baseTemplate, data) + if err != nil { + t.Fatalf("ApplyInstallData returned error: %v", err) + } + + for _, needle := range []string{ + "SECONDARY_ENABLED=false", + "SECONDARY_PATH=", + "SECONDARY_LOG_PATH=", + } { + if !strings.Contains(result, needle) { + t.Fatalf("expected %q in result:\n%s", needle, result) + } + } + if strings.Contains(result, "/mnt/old-secondary") { + t.Fatalf("expected old secondary values to be cleared:\n%s", result) + } +} + func TestApplyInstallDataRejectsInvalidSecondaryPath(t *testing.T) { data := &InstallWizardData{ BaseDir: "/tmp/base", From dfa28e02ab12cac815b8231c9e97b875a9b6c887 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 20:52:49 +0100 Subject: [PATCH 09/29] Align install cron scheduling across CLI and TUI Introduce shared cron parsing/normalization for install workflows and align CLI with the existing TUI cron capability. Add a neutral internal cron helper package, collect cron time during the CLI install wizard, propagate an explicit CronSchedule through the CLI install result, and make install-time cron finalization honor wizard-selected/default cron values instead of falling back to env overrides after a normal wizard run. Keep skip-config-wizard and upgrade flows on their existing env/default behavior, update the TUI wizard to reuse the same cron validation logic, add regression coverage for shared cron parsing, CLI prompt/result propagation, and install schedule precedence, and update install/CLI docs to reflect cron selection in both modes. --- cmd/proxsave/install.go | 86 +++++++++++++++++----- cmd/proxsave/install_test.go | 100 ++++++++++++++++++++++++++ cmd/proxsave/install_tui.go | 7 +- cmd/proxsave/schedule_helpers.go | 44 ++++-------- cmd/proxsave/schedule_helpers_test.go | 70 ++++++++---------- cmd/proxsave/upgrade.go | 2 +- docs/CLI_REFERENCE.md | 2 +- docs/INSTALL.md | 2 +- internal/cron/cron.go | 51 +++++++++++++ internal/cron/cron_test.go | 61 ++++++++++++++++ internal/tui/wizard/install.go | 25 ++----- 11 files changed, 341 insertions(+), 109 deletions(-) create mode 100644 internal/cron/cron.go create mode 100644 internal/cron/cron_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 325cc332..42a8ccf0 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/tis24dev/proxsave/internal/config" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/tui/wizard" @@ -26,6 +27,12 @@ var ( newInstallRunInstallTUI = runInstallTUI ) +type installConfigResult struct { + EnableEncryption bool + SkipConfigWizard bool + CronSchedule string +} + func runInstall(ctx context.Context, configPath string, bootstrap *logging.BootstrapLogger) (err error) { logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "resolving configuration path") resolvedPath, err := resolveInstallConfigPath(configPath) @@ -78,11 +85,18 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "running config wizard") - enableEncryption, skipConfigWizard, err := runConfigWizardCLI(ctx, reader, configPath, tmpConfigPath, baseDir, bootstrap) + configResult, err := runConfigWizardCLI(ctx, reader, configPath, tmpConfigPath, baseDir, bootstrap) if err != nil { return err } - logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "config wizard done (encryption=%v skip=%v)", enableEncryption, skipConfigWizard) + logging.DebugStepBootstrap( + bootstrap, + "install workflow (cli)", + "config wizard done (encryption=%v skip=%v cron=%s)", + configResult.EnableEncryption, + configResult.SkipConfigWizard, + configResult.CronSchedule, + ) logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "installing support docs") if err := installSupportDocs(baseDir, bootstrap); err != nil { @@ -90,12 +104,12 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "running encryption setup if needed") - if err := runEncryptionSetupIfNeeded(ctx, configPath, enableEncryption, skipConfigWizard, bootstrap); err != nil { + if err := runEncryptionSetupIfNeeded(ctx, configPath, configResult.EnableEncryption, configResult.SkipConfigWizard, bootstrap); err != nil { return err } // Optional post-install audit: run a dry-run and offer to disable unused collectors. - if !skipConfigWizard { + if !configResult.SkipConfigWizard { logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "post-install audit") if err := runPostInstallAuditCLI(ctx, reader, execInfo.ExecPath, configPath, bootstrap); err != nil { return err @@ -110,7 +124,13 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "finalizing symlinks and cron") - runPostInstallSymlinksAndCron(ctx, baseDir, execInfo, bootstrap) + runPostInstallSymlinksAndCron( + ctx, + baseDir, + execInfo, + bootstrap, + buildInstallCronSchedule(configResult.SkipConfigWizard, configResult.CronSchedule), + ) logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "detecting telegram identity") telegramCode = detectTelegramCode(baseDir) @@ -426,53 +446,65 @@ func handleLegacyInstall(ctx context.Context, reader *bufio.Reader, baseDir stri return nil } -func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, tmpConfigPath, baseDir string, bootstrap *logging.BootstrapLogger) (enableEncryption bool, skipConfigWizard bool, err error) { +func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, tmpConfigPath, baseDir string, bootstrap *logging.BootstrapLogger) (result installConfigResult, err error) { done := logging.DebugStartBootstrap(bootstrap, "install config wizard (cli)", "config=%s", configPath) defer func() { done(err) }() logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "preparing base template") template, skipConfigWizard, err := prepareBaseTemplate(ctx, reader, configPath) if err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } if skipConfigWizard { - return false, true, nil + return installConfigResult{SkipConfigWizard: true}, nil } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage") if template, err = configureSecondaryStorage(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring cloud storage") if template, err = configureCloudStorage(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring firewall rules") if template, err = configureFirewallRules(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring notifications") if template, err = configureNotifications(ctx, reader, template); err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring encryption") - enableEncryption, err = configureEncryption(ctx, reader, &template) + result.EnableEncryption, err = configureEncryption(ctx, reader, &template) + if err != nil { + return installConfigResult{}, wrapInstallError(err) + } + + logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring cron time") + cronTime, err := configureCronTime(ctx, reader, cronutil.DefaultTime) if err != nil { - return false, false, wrapInstallError(err) + return installConfigResult{}, wrapInstallError(err) + } + result.CronSchedule = cronutil.TimeToSchedule(cronTime) + result.SkipConfigWizard = false + + if bootstrap != nil { + bootstrap.Info("Cron schedule selected: %s", cronTime) } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "writing configuration") if err := writeConfigFile(configPath, tmpConfigPath, template); err != nil { - return false, false, err + return installConfigResult{}, err } if bootstrap != nil { bootstrap.Info("✓ Configuration saved at %s", configPath) } - return enableEncryption, false, nil + return result, nil } func runEncryptionSetupIfNeeded(ctx context.Context, configPath string, enableEncryption, skipConfigWizard bool, bootstrap *logging.BootstrapLogger) (err error) { @@ -494,7 +526,7 @@ func runEncryptionSetupIfNeeded(ctx context.Context, configPath string, enableEn return nil } -func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo ExecInfo, bootstrap *logging.BootstrapLogger) { +func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo ExecInfo, bootstrap *logging.BootstrapLogger, cronSchedule string) { done := logging.DebugStartBootstrap(bootstrap, "post-install setup", "base=%s", baseDir) defer func() { done(nil) }() // Clean up legacy bash-based symlinks that point to the old installer scripts. @@ -513,7 +545,9 @@ func runPostInstallSymlinksAndCron(ctx context.Context, baseDir string, execInfo // Migrate legacy cron entries pointing to the bash script to the Go binary. // If no cron entry exists at all, create a default one at 02:00 every day. - cronSchedule := resolveCronSchedule(nil) + if strings.TrimSpace(cronSchedule) == "" { + cronSchedule = resolveCronScheduleFromEnv() + } logging.DebugStepBootstrap(bootstrap, "post-install setup", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execInfo.ExecPath, bootstrap, cronSchedule) } @@ -731,6 +765,22 @@ func configureEncryption(ctx context.Context, reader *bufio.Reader, template *st return enableEncryption, nil } +func configureCronTime(ctx context.Context, reader *bufio.Reader, defaultCron string) (string, error) { + fmt.Println("\n--- Schedule ---") + for { + cronTime, err := promptOptional(ctx, reader, fmt.Sprintf("Cron time for daily proxsave job (HH:MM) [%s]: ", defaultCron)) + if err != nil { + return "", err + } + normalized, err := cronutil.NormalizeTime(cronTime, defaultCron) + if err != nil { + fmt.Printf("%v\n", err) + continue + } + return normalized, nil + } +} + func writeConfigFile(configPath, tmpConfigPath, content string) error { dir := filepath.Dir(configPath) if err := os.MkdirAll(dir, 0o700); err != nil { diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index 827f22f9..1531d697 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/logging" ) @@ -529,6 +530,105 @@ func TestConfigureEncryption(t *testing.T) { } } +func TestConfigureCronTime(t *testing.T) { + t.Run("empty input uses default", func(t *testing.T) { + var cronTime string + var err error + reader := bufio.NewReader(strings.NewReader("\n")) + captureStdout(t, func() { + cronTime, err = configureCronTime(context.Background(), reader, cronutil.DefaultTime) + }) + if err != nil { + t.Fatalf("configureCronTime returned error: %v", err) + } + if cronTime != cronutil.DefaultTime { + t.Fatalf("configureCronTime default = %q, want %q", cronTime, cronutil.DefaultTime) + } + }) + + t.Run("invalid input re-prompts until valid", func(t *testing.T) { + var cronTime string + var err error + reader := bufio.NewReader(strings.NewReader("24:00\n3:7\n")) + output := captureStdout(t, func() { + cronTime, err = configureCronTime(context.Background(), reader, cronutil.DefaultTime) + }) + if err != nil { + t.Fatalf("configureCronTime returned error: %v", err) + } + if cronTime != "03:07" { + t.Fatalf("configureCronTime normalized = %q, want %q", cronTime, "03:07") + } + if !strings.Contains(output, "cron hour must be between 00 and 23") { + t.Fatalf("expected validation error in output, got %q", output) + } + }) + + t.Run("aborted input returns sentinel", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reader := bufio.NewReader(strings.NewReader("03:15\n")) + _, err := configureCronTime(ctx, reader, cronutil.DefaultTime) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } + }) +} + +func TestRunConfigWizardCLIReturnsCronSchedule(t *testing.T) { + cfgDir := t.TempDir() + configPath := filepath.Join(cfgDir, "env", "backup.env") + tmpConfigPath := configPath + ".tmp" + reader := bufio.NewReader(strings.NewReader("n\nn\nn\nn\nn\nn\n03:15\n")) + + var result installConfigResult + var err error + captureStdout(t, func() { + result, err = runConfigWizardCLI(context.Background(), reader, configPath, tmpConfigPath, "/opt/proxsave", nil) + }) + if err != nil { + t.Fatalf("runConfigWizardCLI returned error: %v", err) + } + if result.SkipConfigWizard { + t.Fatal("expected SkipConfigWizard=false") + } + if result.EnableEncryption { + t.Fatal("expected EnableEncryption=false") + } + if result.CronSchedule != "15 03 * * *" { + t.Fatalf("CronSchedule = %q, want %q", result.CronSchedule, "15 03 * * *") + } + + content, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatalf("expected config file to be written: %v", readErr) + } + if !strings.Contains(string(content), "ENCRYPT_ARCHIVE=false") { + t.Fatalf("expected config content to be written, got %q", string(content)) + } +} + +func TestRunConfigWizardCLISkipLeavesCronScheduleEmpty(t *testing.T) { + cfgFile := createTempFile(t, "EXISTING=1\n") + tmpConfigPath := cfgFile + ".tmp" + reader := bufio.NewReader(strings.NewReader("3\n")) + + var result installConfigResult + var err error + captureStdout(t, func() { + result, err = runConfigWizardCLI(context.Background(), reader, cfgFile, tmpConfigPath, "/opt/proxsave", nil) + }) + if err != nil { + t.Fatalf("runConfigWizardCLI returned error: %v", err) + } + if !result.SkipConfigWizard { + t.Fatal("expected SkipConfigWizard=true") + } + if result.CronSchedule != "" { + t.Fatalf("expected empty CronSchedule when skipping wizard, got %q", result.CronSchedule) + } +} + func createTempFile(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "config-*.env") diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 696727b4..52195df6 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -7,6 +7,7 @@ import ( "os" "strings" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/tui/wizard" @@ -219,7 +220,11 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo ensureGoSymlink(execInfo.ExecPath, bootstrap) // Migrate legacy cron entries - cronSchedule := resolveCronSchedule(wizardData) + wizardCronSchedule := "" + if wizardData != nil { + wizardCronSchedule = cronutil.TimeToSchedule(wizardData.CronTime) + } + cronSchedule := buildInstallCronSchedule(skipConfigWizard, wizardCronSchedule) logging.DebugStepBootstrap(bootstrap, "install workflow (tui)", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execInfo.ExecPath, bootstrap, cronSchedule) diff --git a/cmd/proxsave/schedule_helpers.go b/cmd/proxsave/schedule_helpers.go index 4b7da8a3..d8d5b279 100644 --- a/cmd/proxsave/schedule_helpers.go +++ b/cmd/proxsave/schedule_helpers.go @@ -3,49 +3,35 @@ package main import ( "fmt" "os" - "strconv" "strings" - "github.com/tis24dev/proxsave/internal/tui/wizard" + cronutil "github.com/tis24dev/proxsave/internal/cron" ) -// resolveCronSchedule returns a cron schedule string (e.g. "0 2 * * *") derived from -// wizard data or environment variables, falling back to 02:00 if unavailable. -func resolveCronSchedule(data *wizard.InstallWizardData) string { - // Try wizard data first - if data != nil { - cron := strings.TrimSpace(data.CronTime) - if cron != "" { - if schedule := cronToSchedule(cron); schedule != "" { - return schedule - } - } - } - - // Environment overrides +// resolveCronScheduleFromEnv returns a cron schedule string derived from the +// legacy environment overrides, falling back to 02:00 if unavailable. +func resolveCronScheduleFromEnv() string { if s := strings.TrimSpace(os.Getenv("CRON_SCHEDULE")); s != "" { return s } + hour := strings.TrimSpace(os.Getenv("CRON_HOUR")) min := strings.TrimSpace(os.Getenv("CRON_MINUTE")) if hour != "" && min != "" { return fmt.Sprintf("%s %s * * *", min, hour) } - // Default: 02:00 - return "0 2 * * *" + return cronutil.TimeToSchedule(cronutil.DefaultTime) } -// cronToSchedule converts HH:MM into "MM HH * * *". -func cronToSchedule(cron string) string { - parts := strings.Split(cron, ":") - if len(parts) != 2 { - return "" - } - hour, errH := strconv.Atoi(parts[0]) - min, errM := strconv.Atoi(parts[1]) - if errH != nil || errM != nil || hour < 0 || hour > 23 || min < 0 || min > 59 { - return "" +// buildInstallCronSchedule keeps wizard-driven installs independent from +// env-based overrides while preserving the existing skip-wizard behavior. +func buildInstallCronSchedule(skipConfigWizard bool, cronSchedule string) string { + if !skipConfigWizard { + if schedule := strings.TrimSpace(cronSchedule); schedule != "" { + return schedule + } + return cronutil.TimeToSchedule(cronutil.DefaultTime) } - return fmt.Sprintf("%02d %02d * * *", min, hour) + return resolveCronScheduleFromEnv() } diff --git a/cmd/proxsave/schedule_helpers_test.go b/cmd/proxsave/schedule_helpers_test.go index 1e906af3..fd268f54 100644 --- a/cmd/proxsave/schedule_helpers_test.go +++ b/cmd/proxsave/schedule_helpers_test.go @@ -3,45 +3,14 @@ package main import ( "testing" - "github.com/tis24dev/proxsave/internal/tui/wizard" + cronutil "github.com/tis24dev/proxsave/internal/cron" ) -func TestCronToSchedule(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"valid with padding", "2:5", "05 02 * * *"}, - {"valid already padded", "02:05", "05 02 * * *"}, - {"invalid format", "0205", ""}, - {"invalid hour", "24:00", ""}, - {"invalid minute", "00:60", ""}, - {"non numeric", "aa:bb", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := cronToSchedule(tt.in); got != tt.want { - t.Fatalf("cronToSchedule(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func TestResolveCronSchedule(t *testing.T) { - t.Run("wizard data takes precedence", func(t *testing.T) { - t.Setenv("CRON_SCHEDULE", "0 4 * * *") - data := &wizard.InstallWizardData{CronTime: "03:15"} - if got := resolveCronSchedule(data); got != "15 03 * * *" { - t.Fatalf("resolveCronSchedule(wizard) = %q, want %q", got, "15 03 * * *") - } - }) - +func TestResolveCronScheduleFromEnv(t *testing.T) { t.Run("env CRON_SCHEDULE overrides", func(t *testing.T) { t.Setenv("CRON_SCHEDULE", "5 1 * * *") - if got := resolveCronSchedule(nil); got != "5 1 * * *" { - t.Fatalf("resolveCronSchedule(env) = %q, want %q", got, "5 1 * * *") + if got := resolveCronScheduleFromEnv(); got != "5 1 * * *" { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, "5 1 * * *") } }) @@ -49,8 +18,8 @@ func TestResolveCronSchedule(t *testing.T) { t.Setenv("CRON_SCHEDULE", "") t.Setenv("CRON_HOUR", "22") t.Setenv("CRON_MINUTE", "10") - if got := resolveCronSchedule(nil); got != "10 22 * * *" { - t.Fatalf("resolveCronSchedule(hour/minute) = %q, want %q", got, "10 22 * * *") + if got := resolveCronScheduleFromEnv(); got != "10 22 * * *" { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, "10 22 * * *") } }) @@ -58,8 +27,31 @@ func TestResolveCronSchedule(t *testing.T) { t.Setenv("CRON_SCHEDULE", "") t.Setenv("CRON_HOUR", "") t.Setenv("CRON_MINUTE", "") - if got := resolveCronSchedule(nil); got != "0 2 * * *" { - t.Fatalf("resolveCronSchedule(default) = %q, want %q", got, "0 2 * * *") + if got := resolveCronScheduleFromEnv(); got != cronutil.TimeToSchedule(cronutil.DefaultTime) { + t.Fatalf("resolveCronScheduleFromEnv() = %q, want %q", got, cronutil.TimeToSchedule(cronutil.DefaultTime)) + } + }) +} + +func TestBuildInstallCronSchedule(t *testing.T) { + t.Run("wizard schedule takes precedence over env", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(false, "15 03 * * *"); got != "15 03 * * *" { + t.Fatalf("buildInstallCronSchedule(false, schedule) = %q, want %q", got, "15 03 * * *") + } + }) + + t.Run("wizard run with empty schedule falls back to default time not env", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(false, ""); got != cronutil.TimeToSchedule(cronutil.DefaultTime) { + t.Fatalf("buildInstallCronSchedule(false, \"\") = %q, want %q", got, cronutil.TimeToSchedule(cronutil.DefaultTime)) + } + }) + + t.Run("skip wizard uses env fallback", func(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + if got := buildInstallCronSchedule(true, "15 03 * * *"); got != "5 1 * * *" { + t.Fatalf("buildInstallCronSchedule(true, schedule) = %q, want %q", got, "5 1 * * *") } }) } diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 374a3195..8b89e366 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -177,7 +177,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra cleanupLegacyBashSymlinks(baseDir, bootstrap) ensureGoSymlink(execPath, bootstrap) - cronSchedule := resolveCronSchedule(nil) + cronSchedule := resolveCronScheduleFromEnv() logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "migrating cron entries") migrateLegacyCronEntries(ctx, baseDir, execPath, bootstrap, cronSchedule) diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 4c41b691..2812a7bb 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -145,7 +145,7 @@ Some interactive commands support two interface modes: 4. Optionally enables firewall rules collection (`BACKUP_FIREWALL_RULES=false` by default) 5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`) 6. Optionally configures encryption (AGE setup) -7. (TUI) Optionally selects a cron time (HH:MM) for the `proxsave` cron entry +7. Optionally selects a cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install flows 8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`) 9. (If Telegram centralized mode is enabled and config + Server ID resolve successfully) Shows Server ID and offers pairing verification (retry/skip supported); otherwise install continues and logs why pairing was skipped 10. Finalizes installation (symlinks, cron migration, permission checks) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 172acc58..e09b6b79 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -231,7 +231,7 @@ Final install steps still run: 4. **Firewall rules**: Optional firewall rules collection toggle (`BACKUP_FIREWALL_RULES=false` by default; supports iptables/nftables) 5. **Notifications**: Enable Telegram (centralized) and Email notifications (wizard defaults to `EMAIL_DELIVERY_METHOD=relay`; you can switch to `sendmail` or `pmf` later) 6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled) -7. **Cron schedule**: Choose cron time (HH:MM) for the `proxsave` cron entry (TUI mode only) +7. **Cron schedule**: Choose cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install modes 8. **Post-install check (optional)**: Runs `proxsave --dry-run` and shows actionable warnings like `set BACKUP_*=false to disable`, allowing you to disable unused collectors and reduce WARNING noise 9. **Telegram pairing (optional)**: If Telegram centralized mode is enabled and the installer can load a valid config plus a Server ID, it shows your Server ID and lets you verify pairing with the bot (retry/skip supported). Otherwise installation continues and logs why pairing was skipped. diff --git a/internal/cron/cron.go b/internal/cron/cron.go new file mode 100644 index 00000000..4c6c5193 --- /dev/null +++ b/internal/cron/cron.go @@ -0,0 +1,51 @@ +package cron + +import ( + "fmt" + "strconv" + "strings" +) + +const DefaultTime = "02:00" + +// NormalizeTime validates a cron time in HH:MM form and returns a normalized, +// zero-padded value. Empty input falls back to defaultValue. +func NormalizeTime(input string, defaultValue string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + value = strings.TrimSpace(defaultValue) + } + hour, minute, err := parseTime(value) + if err != nil { + return "", err + } + return fmt.Sprintf("%02d:%02d", hour, minute), nil +} + +// TimeToSchedule converts HH:MM into "MM HH * * *". Invalid input returns "". +func TimeToSchedule(cronTime string) string { + hour, minute, err := parseTime(strings.TrimSpace(cronTime)) + if err != nil { + return "" + } + return fmt.Sprintf("%02d %02d * * *", minute, hour) +} + +func parseTime(value string) (int, int, error) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("cron time must be in HH:MM format") + } + + hour, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil || hour < 0 || hour > 23 { + return 0, 0, fmt.Errorf("cron hour must be between 00 and 23") + } + + minute, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || minute < 0 || minute > 59 { + return 0, 0, fmt.Errorf("cron minute must be between 00 and 59") + } + + return hour, minute, nil +} diff --git a/internal/cron/cron_test.go b/internal/cron/cron_test.go new file mode 100644 index 00000000..40077505 --- /dev/null +++ b/internal/cron/cron_test.go @@ -0,0 +1,61 @@ +package cron + +import "testing" + +func TestNormalizeTime(t *testing.T) { + tests := []struct { + name string + input string + defaultValue string + want string + wantErr string + }{ + {name: "default fallback", input: "", defaultValue: DefaultTime, want: DefaultTime}, + {name: "normalize short values", input: "3:7", defaultValue: DefaultTime, want: "03:07"}, + {name: "trim whitespace", input: " 03:15 ", defaultValue: DefaultTime, want: "03:15"}, + {name: "invalid format", input: "0315", defaultValue: DefaultTime, wantErr: "cron time must be in HH:MM format"}, + {name: "invalid hour", input: "24:00", defaultValue: DefaultTime, wantErr: "cron hour must be between 00 and 23"}, + {name: "invalid minute", input: "00:60", defaultValue: DefaultTime, wantErr: "cron minute must be between 00 and 59"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeTime(tt.input, tt.defaultValue) + if tt.wantErr != "" { + if err == nil { + t.Fatal("expected error") + } + if err.Error() != tt.wantErr { + t.Fatalf("NormalizeTime(%q) error = %q, want %q", tt.input, err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("NormalizeTime(%q) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("NormalizeTime(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestTimeToSchedule(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "valid", in: "02:05", want: "05 02 * * *"}, + {name: "normalized short", in: "2:5", want: "05 02 * * *"}, + {name: "invalid", in: "bad", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TimeToSchedule(tt.in); got != tt.want { + t.Fatalf("TimeToSchedule(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index 2098b872..58ad1ed8 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -6,13 +6,13 @@ import ( "errors" "fmt" "os" - "strconv" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/tis24dev/proxsave/internal/config" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/tui" "github.com/tis24dev/proxsave/internal/tui/components" "github.com/tis24dev/proxsave/pkg/utils" @@ -71,7 +71,7 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu data := &InstallWizardData{ BaseDir: baseDir, ConfigPath: configPath, - CronTime: "02:00", + CronTime: cronutil.DefaultTime, EnableEncryption: false, // Default to disabled BackupFirewallRules: &defaultFirewallRules, } @@ -365,24 +365,11 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu // Get encryption setting data.EnableEncryption = values["Enable Backup Encryption (AGE)"] == "Yes" - // Cron time validation (HH:MM) - cron := strings.TrimSpace(cronField.GetText()) - if cron == "" { - cron = "02:00" + normalizedCron, err := cronutil.NormalizeTime(cronField.GetText(), cronutil.DefaultTime) + if err != nil { + return err } - parts := strings.Split(cron, ":") - if len(parts) != 2 { - return fmt.Errorf("cron time must be in HH:MM format") - } - hour, err := strconv.Atoi(parts[0]) - if err != nil || hour < 0 || hour > 23 { - return fmt.Errorf("cron hour must be between 00 and 23") - } - minute, err := strconv.Atoi(parts[1]) - if err != nil || minute < 0 || minute > 59 { - return fmt.Errorf("cron minute must be between 00 and 59") - } - data.CronTime = fmt.Sprintf("%02d:%02d", hour, minute) + data.CronTime = normalizedCron return nil }) From 9550745c2a09199b6efad88dab2bdeada6c34f16 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 22:06:31 +0100 Subject: [PATCH 10/29] Add cron install regression coverage for CLI and TUI Close the remaining cron-install test gaps after aligning CLI and TUI scheduling behavior. Add a TUI wizard regression test that proves blank cron input resolves to the installer default (02:00) even when CRON_SCHEDULE is set in the environment, and add a CLI wizard regression test that aborting exactly at the cron prompt propagates the interactive abort and leaves backup.env unwritten. Introduce minimal test seams for the install wizard runner and cron prompt boundary to exercise the real command/wizard paths without changing production semantics. --- cmd/proxsave/install.go | 3 ++- cmd/proxsave/install_test.go | 25 ++++++++++++++++++++++ internal/tui/wizard/install.go | 10 ++++++--- internal/tui/wizard/install_test.go | 33 +++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 42a8ccf0..d6d49a68 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -25,6 +25,7 @@ var ( newInstallConfirmTUI = wizard.ConfirmNewInstall newInstallRunInstall = runInstall newInstallRunInstallTUI = runInstallTUI + configureCronTimeFunc = configureCronTime ) type installConfigResult struct { @@ -484,7 +485,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t } logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring cron time") - cronTime, err := configureCronTime(ctx, reader, cronutil.DefaultTime) + cronTime, err := configureCronTimeFunc(ctx, reader, cronutil.DefaultTime) if err != nil { return installConfigResult{}, wrapInstallError(err) } diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go index 1531d697..4f3f83de 100644 --- a/cmd/proxsave/install_test.go +++ b/cmd/proxsave/install_test.go @@ -629,6 +629,31 @@ func TestRunConfigWizardCLISkipLeavesCronScheduleEmpty(t *testing.T) { } } +func TestRunConfigWizardCLIAbortAtCronPromptDoesNotWriteConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "env", "backup.env") + tmpConfigPath := configPath + ".tmp" + + originalConfigureCronTime := configureCronTimeFunc + t.Cleanup(func() { configureCronTimeFunc = originalConfigureCronTime }) + + configureCronTimeFunc = func(ctx context.Context, reader *bufio.Reader, defaultCron string) (string, error) { + return "", errInteractiveAborted + } + + reader := bufio.NewReader(strings.NewReader("n\nn\nn\nn\nn\nn\n")) + + _, err := runConfigWizardCLI(context.Background(), reader, configPath, tmpConfigPath, "/opt/proxsave", nil) + if !errors.Is(err, errInteractiveAborted) { + t.Fatalf("expected errInteractiveAborted, got %v", err) + } + if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) { + t.Fatalf("expected config file not to exist, got err=%v", statErr) + } + if _, statErr := os.Stat(tmpConfigPath); !os.IsNotExist(statErr) { + t.Fatalf("expected temp config file not to exist, got err=%v", statErr) + } +} + func createTempFile(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "config-*.env") diff --git a/internal/tui/wizard/install.go b/internal/tui/wizard/install.go index 58ad1ed8..a33e9bfc 100644 --- a/internal/tui/wizard/install.go +++ b/internal/tui/wizard/install.go @@ -59,7 +59,10 @@ const ( var ( // ErrInstallCancelled is returned when the user aborts the install wizard. - ErrInstallCancelled = errors.New("installation aborted by user") + ErrInstallCancelled = errors.New("installation aborted by user") + runInstallWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return app.SetRoot(root, true).SetFocus(focus).Run() + } checkExistingConfigRunner = func(app *tui.App, root, focus tview.Primitive) error { return app.SetRoot(root, true).SetFocus(focus).Run() } @@ -451,8 +454,9 @@ func RunInstallWizard(ctx context.Context, configPath string, baseDir string, bu SetBorderColor(tui.ProxmoxOrange). SetBackgroundColor(tcell.ColorBlack) - // Run the app - ignore errors from normal app termination - _ = app.SetRoot(flex, true).SetFocus(form.Form).Run() + if err := runInstallWizardRunner(app, flex, form.Form); err != nil { + return nil, err + } if data == nil { return nil, ErrInstallCancelled diff --git a/internal/tui/wizard/install_test.go b/internal/tui/wizard/install_test.go index e5d77cdb..fc20e372 100644 --- a/internal/tui/wizard/install_test.go +++ b/internal/tui/wizard/install_test.go @@ -7,8 +7,10 @@ import ( "strings" "testing" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + cronutil "github.com/tis24dev/proxsave/internal/cron" "github.com/tis24dev/proxsave/internal/tui" ) @@ -223,6 +225,37 @@ func TestApplyInstallDataCronAndNotifications(t *testing.T) { assertContains("ENCRYPT_ARCHIVE", "false") } +func TestRunInstallWizardBlankCronIgnoresEnvOverride(t *testing.T) { + t.Setenv("CRON_SCHEDULE", "5 1 * * *") + + originalRunner := runInstallWizardRunner + t.Cleanup(func() { runInstallWizardRunner = originalRunner }) + + runInstallWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form, ok := focus.(*tview.Form) + if !ok { + t.Fatalf("focus primitive = %T, want *tview.Form", focus) + } + button := form.GetButton(0) + if button == nil { + t.Fatal("expected install button") + } + button.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), nil) + return nil + } + + data, err := RunInstallWizard(t.Context(), "/tmp/proxsave/backup.env", "/opt/proxsave", "sig", "") + if err != nil { + t.Fatalf("RunInstallWizard returned error: %v", err) + } + if data == nil { + t.Fatal("expected wizard data") + } + if data.CronTime != cronutil.DefaultTime { + t.Fatalf("CronTime = %q, want %q", data.CronTime, cronutil.DefaultTime) + } +} + func TestCheckExistingConfigActions(t *testing.T) { tmp := t.TempDir() configPath := filepath.Join(tmp, "prox.env") From 8dfd4037b5cd0f21039de254cbdad9dfe5f5d7d4 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 22:38:30 +0100 Subject: [PATCH 11/29] test(orchestrator): stabilize decrypt TUI end-to-end tests Reduces flakiness in the decrypt TUI end-to-end tests when run with coverage enabled or under package-level load. - increases simulated input delays - extends end-to-end test timeouts and contexts - avoids false negatives without changing production code --- .../decrypt_tui_e2e_helpers_test.go | 31 ++++++++++--------- internal/orchestrator/decrypt_tui_e2e_test.go | 4 +-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go index d9af2167..df564780 100644 --- a/internal/orchestrator/decrypt_tui_e2e_helpers_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_helpers_test.go @@ -187,23 +187,23 @@ func createDecryptTUIEncryptedFixture(t *testing.T) *decryptTUIFixture { func successDecryptTUISequence(secret string) []timedSimKey { keys := []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 150 * time.Millisecond}, - {Key: tcell.KeyEnter, Wait: 300 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 250 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 500 * time.Millisecond}, } for _, r := range secret { keys = append(keys, timedSimKey{ Key: tcell.KeyRune, R: r, - Wait: 20 * time.Millisecond, + Wait: 35 * time.Millisecond, }) } keys = append(keys, - timedSimKey{Key: tcell.KeyTab, Wait: 80 * time.Millisecond}, - timedSimKey{Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, - timedSimKey{Key: tcell.KeyTab, Wait: 300 * time.Millisecond}, - timedSimKey{Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, + timedSimKey{Key: tcell.KeyTab, Wait: 150 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, + timedSimKey{Key: tcell.KeyTab, Wait: 500 * time.Millisecond}, + timedSimKey{Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, ) return keys @@ -211,11 +211,11 @@ func successDecryptTUISequence(secret string) []timedSimKey { func abortDecryptTUISequence() []timedSimKey { return []timedSimKey{ - {Key: tcell.KeyEnter, Wait: 150 * time.Millisecond}, - {Key: tcell.KeyEnter, Wait: 300 * time.Millisecond}, - {Key: tcell.KeyRune, R: '0', Wait: 300 * time.Millisecond}, - {Key: tcell.KeyTab, Wait: 80 * time.Millisecond}, - {Key: tcell.KeyEnter, Wait: 50 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 250 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 500 * time.Millisecond}, + {Key: tcell.KeyRune, R: '0', Wait: 500 * time.Millisecond}, + {Key: tcell.KeyTab, Wait: 150 * time.Millisecond}, + {Key: tcell.KeyEnter, Wait: 100 * time.Millisecond}, } } @@ -233,8 +233,11 @@ func runDecryptWorkflowTUIForTest(t *testing.T, ctx context.Context, cfg *config select { case err := <-errCh: return err - case <-time.After(12 * time.Second): - t.Fatalf("RunDecryptWorkflowTUI did not complete within 12s") + case <-ctx.Done(): + t.Fatalf("RunDecryptWorkflowTUI context expired: %v", ctx.Err()) + return nil + case <-time.After(20 * time.Second): + t.Fatalf("RunDecryptWorkflowTUI did not complete within 20s") return nil } } diff --git a/internal/orchestrator/decrypt_tui_e2e_test.go b/internal/orchestrator/decrypt_tui_e2e_test.go index 11cee746..6f471eb1 100644 --- a/internal/orchestrator/decrypt_tui_e2e_test.go +++ b/internal/orchestrator/decrypt_tui_e2e_test.go @@ -22,7 +22,7 @@ func TestRunDecryptWorkflowTUI_SuccessLocalEncrypted(t *testing.T) { fixture := createDecryptTUIEncryptedFixture(t) withTimedSimAppSequence(t, successDecryptTUISequence(fixture.Secret)) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() if err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath); err != nil { @@ -85,7 +85,7 @@ func TestRunDecryptWorkflowTUI_AbortAtSecretPrompt(t *testing.T) { fixture := createDecryptTUIEncryptedFixture(t) withTimedSimAppSequence(t, abortDecryptTUISequence()) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) defer cancel() err := runDecryptWorkflowTUIForTest(t, ctx, fixture.Config, fixture.ConfigPath) From 1534acbc834b027d17478b0573c5901041c43a65 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 09:54:55 +0100 Subject: [PATCH 12/29] fix(install): guard optional bootstrap logging in TUI install flow Avoid nil-pointer panics in runInstallTUI when the bootstrap logger is not provided. Guard the AGE encryption success-path Info logs and the configuration-saved Debug log with bootstrap nil checks, preserving existing behavior when bootstrap is available. --- cmd/proxsave/install_tui.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 52195df6..8a177b38 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -122,7 +122,9 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo return err } - bootstrap.Debug("Configuration saved at %s", configPath) + if bootstrap != nil { + bootstrap.Debug("Configuration saved at %s", configPath) + } } // Install support docs @@ -142,13 +144,15 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo return err } - bootstrap.Info("AGE encryption configured successfully") - if setupResult.WroteRecipientFile && setupResult.RecipientPath != "" { - bootstrap.Info("Recipient saved to: %s", setupResult.RecipientPath) - } else if setupResult.ReusedExistingRecipients { - bootstrap.Info("Using existing AGE recipient configuration") + if bootstrap != nil { + bootstrap.Info("AGE encryption configured successfully") + if setupResult.WroteRecipientFile && setupResult.RecipientPath != "" { + bootstrap.Info("Recipient saved to: %s", setupResult.RecipientPath) + } else if setupResult.ReusedExistingRecipients { + bootstrap.Info("Using existing AGE recipient configuration") + } + bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") } - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") } // Optional post-install audit: run a dry-run and offer to disable unused collectors From 0adf76dc2a4b8194f2a71bb0ac56ce4016a97ae3 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 10:11:25 +0100 Subject: [PATCH 13/29] fix(newkey): guard success logging when bootstrap is nil Prevent nil-pointer panics in the newkey flow by routing final success messages through a shared helper. When a bootstrap logger is available, keep using bootstrap.Info; otherwise fall back to stdout so both CLI and TUI paths remain safe and user-visible. Also add targeted tests for bootstrap and nil-bootstrap cases. --- cmd/proxsave/newkey.go | 17 ++++++-- cmd/proxsave/newkey_test.go | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 cmd/proxsave/newkey_test.go diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go index 6a680658..8ae155f1 100644 --- a/cmd/proxsave/newkey.go +++ b/cmd/proxsave/newkey.go @@ -88,8 +88,7 @@ func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *lo return err } - bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") + logNewKeySuccess(recipientPath, bootstrap) return nil } @@ -100,12 +99,22 @@ func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *loggi return err } - bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) - bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") + logNewKeySuccess(recipientPath, bootstrap) return nil } +func logNewKeySuccess(recipientPath string, bootstrap *logging.BootstrapLogger) { + if bootstrap != nil { + bootstrap.Info("✓ New AGE recipient(s) generated and saved to %s", recipientPath) + bootstrap.Info("IMPORTANT: Keep your passphrase/private key offline and secure!") + return + } + + fmt.Printf("✓ New AGE recipient(s) generated and saved to %s\n", recipientPath) + fmt.Println("IMPORTANT: Keep your passphrase/private key offline and secure!") +} + func modeLabel(useCLI bool) string { if useCLI { return "cli" diff --git a/cmd/proxsave/newkey_test.go b/cmd/proxsave/newkey_test.go new file mode 100644 index 00000000..52ddd9aa --- /dev/null +++ b/cmd/proxsave/newkey_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/types" +) + +func captureNewKeyStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + os.Stdout = orig + <-done + return buf.String() +} + +func TestLogNewKeySuccessWithoutBootstrapFallsBackToStdout(t *testing.T) { + recipientPath := filepath.Join("/tmp", "identity", "age", "recipient.txt") + + output := captureNewKeyStdout(t, func() { + logNewKeySuccess(recipientPath, nil) + }) + + if !strings.Contains(output, "✓ New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected recipient success message, got %q", output) + } + if !strings.Contains(output, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected security reminder, got %q", output) + } +} + +func TestLogNewKeySuccessWithBootstrapUsesBootstrapLogger(t *testing.T) { + recipientPath := filepath.Join("/tmp", "identity", "age", "recipient.txt") + bootstrap := logging.NewBootstrapLogger() + bootstrap.SetLevel(types.LogLevelInfo) + + var mirrorBuf bytes.Buffer + mirror := logging.New(types.LogLevelDebug, false) + mirror.SetOutput(&mirrorBuf) + bootstrap.SetMirrorLogger(mirror) + + output := captureNewKeyStdout(t, func() { + logNewKeySuccess(recipientPath, bootstrap) + }) + + if !strings.Contains(output, "✓ New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected bootstrap stdout success message, got %q", output) + } + if !strings.Contains(output, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected bootstrap stdout security reminder, got %q", output) + } + + mirrorOutput := mirrorBuf.String() + if !strings.Contains(mirrorOutput, "New AGE recipient(s) generated and saved to "+recipientPath) { + t.Fatalf("expected mirror logger success message, got %q", mirrorOutput) + } + if !strings.Contains(mirrorOutput, "IMPORTANT: Keep your passphrase/private key offline and secure!") { + t.Fatalf("expected mirror logger security reminder, got %q", mirrorOutput) + } +} From 42297e4d32e71cc9ca958d49c4d84401d29e9a3a Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 10:35:49 +0100 Subject: [PATCH 14/29] fix(decrypt): reject unchanged destination paths in CLI and TUI prompts Prevent decrypt path conflict prompts from accepting the same destination path again. Add shared validation that rejects empty or normalized-equivalent paths to the existing target, apply it in both TUI and CLI flows, and update tests to cover valid edits plus normalized-path rejection and retry behavior. --- internal/orchestrator/tui_simulation_test.go | 16 ++-- internal/orchestrator/workflow_ui_cli.go | 5 +- internal/orchestrator/workflow_ui_cli_test.go | 83 +++++++++++++++++++ .../workflow_ui_tui_decrypt_prompts.go | 26 ++++-- .../workflow_ui_tui_decrypt_test.go | 20 +++++ 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 internal/orchestrator/workflow_ui_cli_test.go diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go index 2c705ee4..f40d5949 100644 --- a/internal/orchestrator/tui_simulation_test.go +++ b/internal/orchestrator/tui_simulation_test.go @@ -73,16 +73,22 @@ func TestPromptOverwriteAction_SelectsOverwrite(t *testing.T) { } } -func TestPromptNewPathInput_ContinueReturnsDefault(t *testing.T) { - // Move focus to Continue button then submit. - withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) +func TestPromptNewPathInput_ContinueReturnsEditedPath(t *testing.T) { + withSimAppSequence(t, []simKey{ + {Key: tcell.KeyRune, R: '/'}, + {Key: tcell.KeyRune, R: 'a'}, + {Key: tcell.KeyRune, R: 'l'}, + {Key: tcell.KeyRune, R: 't'}, + {Key: tcell.KeyTab}, + {Key: tcell.KeyEnter}, + }) got, err := promptNewPathInputTUI("/tmp/newpath", "/tmp/config.env", "sig") if err != nil { t.Fatalf("promptNewPathInputTUI error: %v", err) } - if got != "/tmp/newpath" { - t.Fatalf("path=%q; want %q", got, "/tmp/newpath") + if got != "/tmp/newpath/alt" { + t.Fatalf("path=%q; want %q", got, "/tmp/newpath/alt") } } diff --git a/internal/orchestrator/workflow_ui_cli.go b/internal/orchestrator/workflow_ui_cli.go index 1d303c76..ec734558 100644 --- a/internal/orchestrator/workflow_ui_cli.go +++ b/internal/orchestrator/workflow_ui_cli.go @@ -133,8 +133,9 @@ func (u *cliWorkflowUI) ResolveExistingPath(ctx context.Context, path, descripti if err != nil { return PathDecisionCancel, "", err } - trimmed := strings.TrimSpace(newPath) - if trimmed == "" { + trimmed, err := validateDistinctNewPathInput(newPath, current) + if err != nil { + fmt.Println(err.Error()) continue } return PathDecisionNewPath, filepath.Clean(trimmed), nil diff --git a/internal/orchestrator/workflow_ui_cli_test.go b/internal/orchestrator/workflow_ui_cli_test.go new file mode 100644 index 00000000..4cf17983 --- /dev/null +++ b/internal/orchestrator/workflow_ui_cli_test.go @@ -0,0 +1,83 @@ +package orchestrator + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "strings" + "testing" +) + +func captureCLIStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + <-done + _ = r.Close() + + os.Stdout = oldStdout + return buf.String() +} + +func TestCLIWorkflowUIResolveExistingPath_RejectsEquivalentNormalizedPath(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("2\n/tmp/out/\n2\n /tmp/out/../alt \n")) + ui := newCLIWorkflowUI(reader, nil) + + var ( + decision ExistingPathDecision + newPath string + err error + ) + output := captureCLIStdout(t, func() { + decision, newPath, err = ui.ResolveExistingPath(context.Background(), "/tmp/out", "archive", "") + }) + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != "/tmp/alt" { + t.Fatalf("newPath=%q, want %q", newPath, "/tmp/alt") + } + if !strings.Contains(output, "path must be different from existing path") { + t.Fatalf("expected validation message in output, got %q", output) + } +} + +func TestCLIWorkflowUIResolveExistingPath_EmptyPathRetriesUntilValid(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("2\n \n2\n/tmp/next\n")) + ui := newCLIWorkflowUI(reader, nil) + + decision, newPath, err := ui.ResolveExistingPath(context.Background(), "/tmp/out", "archive", "") + if err != nil { + t.Fatalf("ResolveExistingPath error: %v", err) + } + if decision != PathDecisionNewPath { + t.Fatalf("decision=%v, want %v", decision, PathDecisionNewPath) + } + if newPath != "/tmp/next" { + t.Fatalf("newPath=%q, want %q", newPath, "/tmp/next") + } +} diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go index eb78fd2c..55bc35c5 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt_prompts.go @@ -75,13 +75,15 @@ func promptNewPathInputTUI(defaultPath, configPath, buildSig string) (string, er form := components.NewForm(app) label := "New path" form.AddInputFieldWithValidation(label, defaultPath, 64, func(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("path cannot be empty") - } - return nil + _, err := validateDistinctNewPathInput(value, defaultPath) + return err }) form.SetOnSubmit(func(values map[string]string) error { - newPath = strings.TrimSpace(values[label]) + trimmed, err := validateDistinctNewPathInput(values[label], defaultPath) + if err != nil { + return err + } + newPath = trimmed return nil }) form.SetOnCancel(func() { @@ -114,6 +116,20 @@ func promptNewPathInputTUI(defaultPath, configPath, buildSig string) (string, er return filepath.Clean(newPath), nil } +func validateDistinctNewPathInput(value, defaultPath string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("path cannot be empty") + } + + trimmedDefault := strings.TrimSpace(defaultPath) + if trimmedDefault != "" && filepath.Clean(trimmed) == filepath.Clean(trimmedDefault) { + return "", fmt.Errorf("path must be different from existing path") + } + + return trimmed, nil +} + func promptDecryptSecretTUI(configPath, buildSig, displayName, previousError string) (string, error) { app := newTUIApp() var ( diff --git a/internal/orchestrator/workflow_ui_tui_decrypt_test.go b/internal/orchestrator/workflow_ui_tui_decrypt_test.go index 8abf48ab..28ddc7f8 100644 --- a/internal/orchestrator/workflow_ui_tui_decrypt_test.go +++ b/internal/orchestrator/workflow_ui_tui_decrypt_test.go @@ -100,3 +100,23 @@ func TestTUIWorkflowUIPromptDestinationDir_CancelReturnsAborted(t *testing.T) { t.Fatalf("err=%v, want %v", err, ErrDecryptAborted) } } + +func TestValidateDistinctNewPathInputRejectsEquivalentNormalizedPath(t *testing.T) { + _, err := validateDistinctNewPathInput("/tmp/out/", "/tmp/out") + if err == nil { + t.Fatalf("expected validation error") + } + if err.Error() != "path must be different from existing path" { + t.Fatalf("err=%q, want %q", err.Error(), "path must be different from existing path") + } +} + +func TestValidateDistinctNewPathInputAcceptsDifferentPath(t *testing.T) { + got, err := validateDistinctNewPathInput(" /tmp/out/alt ", "/tmp/out") + if err != nil { + t.Fatalf("validateDistinctNewPathInput error: %v", err) + } + if got != "/tmp/out/alt" { + t.Fatalf("path=%q, want %q", got, "/tmp/out/alt") + } +} From 2f7c89a605a83c03669e7de8a868ff1fab83f451 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 10:43:46 +0100 Subject: [PATCH 15/29] refactor: simplify ticker wait in rollback countdown Replace the single-case select in printNetworkRollbackCountdown with a direct receive from ticker.C. The loop behavior remains unchanged: the countdown still updates on each tick and keeps the explicit continue for clarity. --- cmd/proxsave/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 0c758f59..26dfa4ff 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1566,10 +1566,8 @@ func printNetworkRollbackCountdown(abortInfo *orchestrator.RestoreAbortInfo) { } fmt.Printf("\r Remaining: %ds ", int(remaining.Seconds())) - select { - case <-ticker.C: - continue - } + <-ticker.C + continue } fmt.Printf("%s===========================================%s\n", color, colorReset) From 704623627ea6fae54020bbd34cf40895ff2212eb Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 11:00:41 +0100 Subject: [PATCH 16/29] fix: respect configured recipient file in --newkey Load the existing config in --newkey so AGE_RECIPIENT_FILE is preserved when configured, instead of always forcing the default recipient path. Also update the success message to report the effective recipient file, add tests for custom/default paths and invalid configs, and align the CLI docs. --- cmd/proxsave/encryption_setup_test.go | 54 ++++++++++++++++++++++++- cmd/proxsave/newkey.go | 51 +++++++++++++++++------ cmd/proxsave/newkey_test.go | 58 +++++++++++++++++++++++++++ docs/CLI_REFERENCE.md | 2 +- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/cmd/proxsave/encryption_setup_test.go b/cmd/proxsave/encryption_setup_test.go index df348d02..78dfacbf 100644 --- a/cmd/proxsave/encryption_setup_test.go +++ b/cmd/proxsave/encryption_setup_test.go @@ -183,11 +183,15 @@ func TestRunNewKeySetupKeepsDefaultRecipientPathContract(t *testing.T) { addMore: []bool{false}, } - if err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui); err != nil { + recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui) + if err != nil { t.Fatalf("runNewKeySetup error: %v", err) } target := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if recipientPath != target { + t.Fatalf("recipientPath=%q; want %q", recipientPath, target) + } content, err := os.ReadFile(target) if err != nil { t.Fatalf("ReadFile(%s): %v", target, err) @@ -196,3 +200,51 @@ func TestRunNewKeySetupKeepsDefaultRecipientPathContract(t *testing.T) { t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") } } + +func TestRunNewKeySetupUsesConfiguredRecipientFile(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + customPath := filepath.Join(baseDir, "custom", "recipient.txt") + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT_FILE=" + customPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + ui := &testAgeSetupUI{ + overwrite: true, + drafts: []*orchestrator.AgeRecipientDraft{ + {Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + + recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui) + if err != nil { + t.Fatalf("runNewKeySetup error: %v", err) + } + if recipientPath != customPath { + t.Fatalf("recipientPath=%q; want %q", recipientPath, customPath) + } + + customContent, err := os.ReadFile(customPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", customPath, err) + } + if got := string(customContent); got != id.Recipient().String()+"\n" { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n") + } + + defaultPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") + if _, err := os.Stat(defaultPath); !os.IsNotExist(err) { + t.Fatalf("default path %s should not be written, stat err=%v", defaultPath, err) + } +} diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go index 8ae155f1..5adbf853 100644 --- a/cmd/proxsave/newkey.go +++ b/cmd/proxsave/newkey.go @@ -75,16 +75,16 @@ func runNewKey(ctx context.Context, configPath string, logLevel types.LogLevel, } func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *logging.BootstrapLogger) (err error) { - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") sig := buildSignature() if strings.TrimSpace(sig) == "" { sig = "n/a" } - done := logging.DebugStartBootstrap(bootstrap, "newkey workflow (tui)", "recipient=%s", recipientPath) + done := logging.DebugStartBootstrap(bootstrap, "newkey workflow (tui)", "config=%s", configPath) defer func() { done(err) }() logging.DebugStepBootstrap(bootstrap, "newkey workflow (tui)", "running AGE setup via orchestrator") - if err := runNewKeySetup(ctx, configPath, baseDir, logging.GetDefaultLogger(), wizard.NewAgeSetupUI(configPath, sig)); err != nil { + recipientPath, err := runNewKeySetup(ctx, configPath, baseDir, logging.GetDefaultLogger(), wizard.NewAgeSetupUI(configPath, sig)) + if err != nil { return err } @@ -94,8 +94,8 @@ func runNewKeyTUI(ctx context.Context, configPath, baseDir string, bootstrap *lo } func runNewKeyCLI(ctx context.Context, configPath, baseDir string, logger *logging.Logger, bootstrap *logging.BootstrapLogger) error { - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") - if err := runNewKeySetup(ctx, configPath, baseDir, logger, nil); err != nil { + recipientPath, err := runNewKeySetup(ctx, configPath, baseDir, logger, nil) + if err != nil { return err } @@ -122,14 +122,42 @@ func modeLabel(useCLI bool) string { return "tui" } -func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *logging.Logger, ui orchestrator.AgeSetupUI) error { - recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") +func loadNewKeyConfig(configPath, baseDir string) (*config.Config, string, error) { + defaultRecipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt") cfg := &config.Config{ BaseDir: baseDir, ConfigPath: configPath, EncryptArchive: true, - AgeRecipientFile: recipientPath, + AgeRecipientFile: defaultRecipientPath, + } + + if _, err := os.Stat(configPath); err == nil { + loaded, err := config.LoadConfig(configPath) + if err != nil { + return nil, "", fmt.Errorf("load configuration for newkey: %w", err) + } + cfg = loaded + cfg.BaseDir = baseDir + cfg.ConfigPath = configPath + cfg.EncryptArchive = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, "", fmt.Errorf("inspect configuration for newkey: %w", err) + } + + recipientPath := strings.TrimSpace(cfg.AgeRecipientFile) + if recipientPath == "" { + recipientPath = defaultRecipientPath + } + cfg.AgeRecipientFile = recipientPath + + return cfg, recipientPath, nil +} + +func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *logging.Logger, ui orchestrator.AgeSetupUI) (string, error) { + cfg, recipientPath, err := loadNewKeyConfig(configPath, baseDir) + if err != nil { + return "", err } if logger == nil { @@ -141,7 +169,6 @@ func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *log orch.SetConfig(cfg) orch.SetForceNewAgeRecipient(true) - var err error if ui != nil { err = orch.EnsureAgeRecipientsReadyWithUI(ctx, ui) } else { @@ -149,10 +176,10 @@ func runNewKeySetup(ctx context.Context, configPath, baseDir string, logger *log } if err != nil { if errors.Is(err, orchestrator.ErrAgeRecipientSetupAborted) { - return wrapInstallError(errInteractiveAborted) + return "", wrapInstallError(errInteractiveAborted) } - return fmt.Errorf("AGE setup failed: %w", err) + return "", fmt.Errorf("AGE setup failed: %w", err) } - return nil + return recipientPath, nil } diff --git a/cmd/proxsave/newkey_test.go b/cmd/proxsave/newkey_test.go index 52ddd9aa..c68624f0 100644 --- a/cmd/proxsave/newkey_test.go +++ b/cmd/proxsave/newkey_test.go @@ -80,3 +80,61 @@ func TestLogNewKeySuccessWithBootstrapUsesBootstrapLogger(t *testing.T) { t.Fatalf("expected mirror logger security reminder, got %q", mirrorOutput) } } + +func TestLoadNewKeyConfigUsesConfiguredRecipientFile(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + customPath := filepath.Join(baseDir, "custom", "recipient.txt") + content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=false\nAGE_RECIPIENT_FILE=" + customPath + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + cfg, recipientPath, err := loadNewKeyConfig(configPath, baseDir) + if err != nil { + t.Fatalf("loadNewKeyConfig error: %v", err) + } + if recipientPath != customPath { + t.Fatalf("recipientPath=%q; want %q", recipientPath, customPath) + } + if cfg == nil { + t.Fatalf("expected config") + } + if cfg.BaseDir != baseDir { + t.Fatalf("BaseDir=%q; want %q", cfg.BaseDir, baseDir) + } + if cfg.ConfigPath != configPath { + t.Fatalf("ConfigPath=%q; want %q", cfg.ConfigPath, configPath) + } + if cfg.AgeRecipientFile != customPath { + t.Fatalf("AgeRecipientFile=%q; want %q", cfg.AgeRecipientFile, customPath) + } + if !cfg.EncryptArchive { + t.Fatalf("EncryptArchive=false; want true") + } +} + +func TestLoadNewKeyConfigFailsForInvalidExistingConfig(t *testing.T) { + baseDir := t.TempDir() + configPath := filepath.Join(baseDir, "env", "backup.env") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err) + } + + content := "BASE_DIR=" + baseDir + "\nCUSTOM_BACKUP_PATHS=\"\nunterminated\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s): %v", configPath, err) + } + + _, _, err := loadNewKeyConfig(configPath, baseDir) + if err == nil { + t.Fatalf("expected loadNewKeyConfig to fail for invalid config") + } + if !strings.Contains(err.Error(), "load configuration for newkey") { + t.Fatalf("expected wrapped configuration load error, got %v", err) + } +} diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 2812a7bb..5ed74a5b 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -357,7 +357,7 @@ Next step: ./build/proxsave --dry-run **Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed. **`--newkey` workflow**: -1. Uses the default recipient file: `${BASE_DIR}/identity/age/recipient.txt` (same as `AGE_RECIPIENT_FILE` in the template) +1. Uses the configured `AGE_RECIPIENT_FILE` when present; otherwise falls back to `${BASE_DIR}/identity/age/recipient.txt` 2. Prompts for one of: - **Existing public recipient**: paste an `age1...` recipient - **Passphrase-derived**: enter a passphrase (proxsave derives the recipient; the passphrase is **not stored**) From 0fb0f6d3cf8d395bcd1bf71a4125f5717bdfd166 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 12:48:14 +0100 Subject: [PATCH 17/29] test(tui): complete coverage for new install and telegram setup Add targeted tests for the TUI wizards to cover the remaining uncovered branches. - cover nil and whitespace-only preserved entries in new install tests - cover Telegram setup bootstrap error propagation - verify persisted identity rendering in the Server ID panel - verify truncation of long registration failure messages - verify ESC behavior after successful verification - exercise default async wrappers used by the Telegram setup wizard LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- internal/tui/wizard/new_install_test.go | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/tui/wizard/new_install_test.go b/internal/tui/wizard/new_install_test.go index a7267fcc..43b1a3bf 100644 --- a/internal/tui/wizard/new_install_test.go +++ b/internal/tui/wizard/new_install_test.go @@ -10,6 +10,38 @@ import ( "github.com/tis24dev/proxsave/internal/tui" ) +func TestFormatPreservedEntries(t *testing.T) { + tests := []struct { + name string + entries []string + want string + }{ + { + name: "formats trimmed entries", + entries: []string{" build ", "env", " identity"}, + want: "build/ env/ identity/", + }, + { + name: "returns none for nil input", + entries: nil, + want: "(none)", + }, + { + name: "returns none for blank entries", + entries: []string{"", " ", "\t"}, + want: "(none)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatPreservedEntries(tt.entries); got != tt.want { + t.Fatalf("formatPreservedEntries(%v) = %q, want %q", tt.entries, got, tt.want) + } + }) + } +} + func TestConfirmNewInstallContinue(t *testing.T) { originalRunner := confirmNewInstallRunner defer func() { confirmNewInstallRunner = originalRunner }() From bf2b606ae943edbade946177c2fffaa0262961aa Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 12:50:47 +0100 Subject: [PATCH 18/29] test(tui): add missing wizard coverage Add missing unit tests for new install and Telegram setup flows, covering edge cases, UI state rendering, error propagation, ESC handling, and async helpers. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../tui/wizard/telegram_setup_tui_test.go | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/internal/tui/wizard/telegram_setup_tui_test.go b/internal/tui/wizard/telegram_setup_tui_test.go index 47ef7c78..0955cff6 100644 --- a/internal/tui/wizard/telegram_setup_tui_test.go +++ b/internal/tui/wizard/telegram_setup_tui_test.go @@ -3,7 +3,9 @@ package wizard import ( "context" "errors" + "strings" "testing" + "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -48,6 +50,46 @@ func eligibleTelegramSetupBootstrap() orchestrator.TelegramSetupBootstrap { } } +func extractTelegramSetupViews(t *testing.T, root tview.Primitive) (*tview.TextView, *tview.TextView, *tview.Form) { + t.Helper() + + layout, ok := root.(*tview.Flex) + if !ok { + t.Fatalf("expected root *tview.Flex, got %T", root) + } + if layout.GetItemCount() < 4 { + t.Fatalf("unexpected layout item count: %d", layout.GetItemCount()) + } + + pages, ok := layout.GetItem(3).(*tview.Pages) + if !ok { + t.Fatalf("expected pages at layout index 3, got %T", layout.GetItem(3)) + } + _, bodyPrimitive := pages.GetFrontPage() + body, ok := bodyPrimitive.(*tview.Flex) + if !ok { + t.Fatalf("expected body *tview.Flex, got %T", bodyPrimitive) + } + if body.GetItemCount() < 4 { + t.Fatalf("unexpected body item count: %d", body.GetItemCount()) + } + + serverIDView, ok := body.GetItem(1).(*tview.TextView) + if !ok { + t.Fatalf("expected server ID view at body index 1, got %T", body.GetItem(1)) + } + statusView, ok := body.GetItem(2).(*tview.TextView) + if !ok { + t.Fatalf("expected status view at body index 2, got %T", body.GetItem(2)) + } + form, ok := body.GetItem(3).(*tview.Form) + if !ok { + t.Fatalf("expected form at body index 3, got %T", body.GetItem(3)) + } + + return serverIDView, statusView, form +} + func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) { stubTelegramSetupDeps(t) @@ -172,6 +214,27 @@ func TestRunTelegramSetupWizard_IdentityUnavailableSkipsUI(t *testing.T) { } } +func TestRunTelegramSetupWizard_PropagatesBootstrapError(t *testing.T) { + stubTelegramSetupDeps(t) + + expectedErr := errors.New("bootstrap failed") + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return orchestrator.TelegramSetupBootstrap{}, expectedErr + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + t.Fatalf("runner should not be called when bootstrap returns an error") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if !errors.Is(err, expectedErr) { + t.Fatalf("expected error %v, got %v", expectedErr, err) + } + if result != (TelegramSetupResult{}) { + t.Fatalf("expected empty result on bootstrap error, got %#v", result) + } +} + func TestRunTelegramSetupWizard_CentralizedSuccess_RequiresCheckBeforeContinue(t *testing.T) { stubTelegramSetupDeps(t) @@ -246,6 +309,40 @@ func TestRunTelegramSetupWizard_CentralizedSuccess_RequiresCheckBeforeContinue(t } } +func TestRunTelegramSetupWizard_ShowsPersistedIdentityState(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + state := eligibleTelegramSetupBootstrap() + state.IdentityPersisted = true + return state, nil + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + serverIDView, _, form := extractTelegramSetupViews(t, root) + text := serverIDView.GetText(true) + if !strings.Contains(text, "persisted") { + t.Fatalf("expected persisted identity state, got %q", text) + } + if strings.Contains(text, "not persisted") { + t.Fatalf("did not expect non-persisted label, got %q", text) + } + + pressFormButton(t, form, "Skip") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.IdentityPersisted { + t.Fatalf("expected IdentityPersisted=true") + } + if !result.SkippedVerification { + t.Fatalf("expected SkippedVerification=true") + } +} + func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) { stubTelegramSetupDeps(t) @@ -302,6 +399,57 @@ func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) } } +func TestRunTelegramSetupWizard_TruncatesLongFailureMessage(t *testing.T) { + stubTelegramSetupDeps(t) + + longMessage := strings.Repeat("x", 320) + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + return notify.TelegramRegistrationStatus{ + Code: 500, + Message: " " + longMessage + " ", + } + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + _, statusView, form := extractTelegramSetupViews(t, root) + + pressFormButton(t, form, "Check") + + text := statusView.GetText(true) + if !strings.Contains(text, "...(truncated)") { + t.Fatalf("expected truncated status, got %q", text) + } + if !strings.Contains(text, "Skip verification and complete pairing later.") { + t.Fatalf("expected retry/skip hint, got %q", text) + } + if strings.Contains(text, longMessage) { + t.Fatalf("expected long message to be truncated, got %q", text) + } + + pressFormButton(t, form, "Skip") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if result.CheckAttempts != 1 { + t.Fatalf("CheckAttempts=%d, want 1", result.CheckAttempts) + } + if result.LastStatusCode != 500 { + t.Fatalf("LastStatusCode=%d, want 500", result.LastStatusCode) + } + if result.LastStatusMessage != " "+longMessage+" " { + t.Fatalf("LastStatusMessage=%q, want original message", result.LastStatusMessage) + } +} + func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) { stubTelegramSetupDeps(t) @@ -338,6 +486,48 @@ func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) } } +func TestRunTelegramSetupWizard_CentralizedEscAfterVerificationDoesNotSkip(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupBuildBootstrap = func(configPath, baseDir string) (orchestrator.TelegramSetupBootstrap, error) { + return eligibleTelegramSetupBootstrap(), nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + return notify.TelegramRegistrationStatus{Code: 200, Message: "ok"} + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + _, _, form := extractTelegramSetupViews(t, root) + + pressFormButton(t, form, "Check") + if form.GetButtonIndex("Continue") == -1 { + t.Fatalf("expected Continue button after verification") + } + + capture := app.GetInputCapture() + if capture == nil { + t.Fatalf("expected input capture to be set") + } + if got := capture(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)); got != nil { + t.Fatalf("expected ESC to be consumed, got %#v", got) + } + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.Verified { + t.Fatalf("expected Verified=true") + } + if result.SkippedVerification { + t.Fatalf("expected SkippedVerification=false after ESC on verified flow") + } + if result.CheckAttempts != 1 { + t.Fatalf("CheckAttempts=%d, want 1", result.CheckAttempts) + } +} + func TestRunTelegramSetupWizard_PropagatesRunnerError(t *testing.T) { stubTelegramSetupDeps(t) @@ -406,3 +596,43 @@ func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAft t.Fatalf("checkCalls=%d, want 1", checkCalls) } } + +func TestTelegramSetupDefaultWrappers(t *testing.T) { + done := make(chan struct{}) + telegramSetupGo(func() { + close(done) + }) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for telegramSetupGo") + } + + app := tui.NewApp() + app.SetScreen(tcell.NewSimulationScreen("UTF-8")) + root := tview.NewBox() + + updateDone := make(chan struct{}) + go func() { + time.Sleep(20 * time.Millisecond) + telegramSetupQueueUpdateDraw(app, func() { + close(updateDone) + app.Stop() + }) + }() + + go func() { + time.Sleep(250 * time.Millisecond) + app.Stop() + }() + + if err := telegramSetupWizardRunner(app, root, root); err != nil { + t.Fatalf("telegramSetupWizardRunner error: %v", err) + } + + select { + case <-updateDone: + case <-time.After(time.Second): + t.Fatal("timed out waiting for telegramSetupQueueUpdateDraw") + } +} From 1327eabf0b19daa1a06cfc43418cae4d81799b4a Mon Sep 17 00:00:00 2001 From: tis24dev Date: Sat, 14 Mar 2026 14:27:18 +0100 Subject: [PATCH 19/29] test(tui): harden Telegram setup TUI tests Refactor telegram_setup_tui_test to remove hardcoded layout index assumptions when extracting Telegram setup views. The helper now finds the Pages container and the relevant widgets by type and title instead of relying on UI item ordering. The default wrapper test also replaces sleep-based update timing with direct QueueUpdateDraw synchronization, keeping only a watchdog timeout to avoid hangs. LiveReview Pre-Commit Check: skipped (iter:3, coverage:0%) --- .../tui/wizard/telegram_setup_tui_test.go | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/internal/tui/wizard/telegram_setup_tui_test.go b/internal/tui/wizard/telegram_setup_tui_test.go index 0955cff6..d4b0e41a 100644 --- a/internal/tui/wizard/telegram_setup_tui_test.go +++ b/internal/tui/wizard/telegram_setup_tui_test.go @@ -57,34 +57,54 @@ func extractTelegramSetupViews(t *testing.T, root tview.Primitive) (*tview.TextV if !ok { t.Fatalf("expected root *tview.Flex, got %T", root) } - if layout.GetItemCount() < 4 { - t.Fatalf("unexpected layout item count: %d", layout.GetItemCount()) - } - pages, ok := layout.GetItem(3).(*tview.Pages) - if !ok { - t.Fatalf("expected pages at layout index 3, got %T", layout.GetItem(3)) + var pages *tview.Pages + for i := 0; i < layout.GetItemCount(); i++ { + candidate, ok := layout.GetItem(i).(*tview.Pages) + if !ok { + continue + } + if pages != nil { + t.Fatal("expected a single pages container in telegram setup layout") + } + pages = candidate + } + if pages == nil { + t.Fatal("expected pages container in telegram setup layout") } + _, bodyPrimitive := pages.GetFrontPage() body, ok := bodyPrimitive.(*tview.Flex) if !ok { t.Fatalf("expected body *tview.Flex, got %T", bodyPrimitive) } - if body.GetItemCount() < 4 { - t.Fatalf("unexpected body item count: %d", body.GetItemCount()) + + var serverIDView, statusView *tview.TextView + var form *tview.Form + for i := 0; i < body.GetItemCount(); i++ { + switch item := body.GetItem(i).(type) { + case *tview.TextView: + switch strings.TrimSpace(item.GetTitle()) { + case "Server ID": + serverIDView = item + case "Status": + statusView = item + } + case *tview.Form: + if strings.TrimSpace(item.GetTitle()) == "Actions" { + form = item + } + } } - serverIDView, ok := body.GetItem(1).(*tview.TextView) - if !ok { - t.Fatalf("expected server ID view at body index 1, got %T", body.GetItem(1)) + if serverIDView == nil { + t.Fatal("expected Server ID view in telegram setup body") } - statusView, ok := body.GetItem(2).(*tview.TextView) - if !ok { - t.Fatalf("expected status view at body index 2, got %T", body.GetItem(2)) + if statusView == nil { + t.Fatal("expected Status view in telegram setup body") } - form, ok := body.GetItem(3).(*tview.Form) - if !ok { - t.Fatalf("expected form at body index 3, got %T", body.GetItem(3)) + if form == nil { + t.Fatal("expected Actions form in telegram setup body") } return serverIDView, statusView, form @@ -612,17 +632,19 @@ func TestTelegramSetupDefaultWrappers(t *testing.T) { app.SetScreen(tcell.NewSimulationScreen("UTF-8")) root := tview.NewBox() + updateQueued := make(chan struct{}) updateDone := make(chan struct{}) go func() { - time.Sleep(20 * time.Millisecond) + close(updateQueued) telegramSetupQueueUpdateDraw(app, func() { close(updateDone) app.Stop() }) }() + <-updateQueued go func() { - time.Sleep(250 * time.Millisecond) + time.Sleep(500 * time.Millisecond) app.Stop() }() From 66d2623d1f8cdc7546d8d04b5c9fa2bc23cf863c Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 17 Mar 2026 20:54:10 +0100 Subject: [PATCH 20/29] fix(storage): correct UsedSpace and stop deriving it from Bavail Fix the used space calculation in the local and secondary storage backends by using Blocks-Bfree instead of Total-Bavail, so reserved blocks are no longer reported as "used". Centralize statfs space conversion in a shared helper and apply the same semantics to the PVE collector. Also propagate UsedSpace through the orchestrator and notifications, removing the recomputation from free/total so displayed usage values and percentages match actual filesystem usage. Update and strengthen tests for Bavail != Bfree cases and for end-to-end propagation of the corrected values. LiveReview Pre-Commit Check: skipped (iter:2, coverage:73%) --- internal/backup/collector_pve.go | 4 +- .../backup/collector_pve_additional_test.go | 38 +++++++++ .../orchestrator/additional_helpers_test.go | 30 ++++++- internal/orchestrator/helpers_test.go | 38 ++------- internal/orchestrator/notification_adapter.go | 30 ++++--- .../orchestrator/notification_adapter_test.go | 60 +++++++++++--- internal/orchestrator/orchestrator.go | 2 + internal/orchestrator/storage_adapter.go | 2 + internal/orchestrator/storage_adapter_test.go | 8 ++ internal/safefs/safefs.go | 33 ++++++++ internal/safefs/safefs_test.go | 82 +++++++++++++++++++ internal/storage/local.go | 14 +--- internal/storage/secondary.go | 14 +--- internal/storage/storage_test.go | 10 ++- 14 files changed, 276 insertions(+), 89 deletions(-) diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go index 8fe16eba..fa15ef27 100644 --- a/internal/backup/collector_pve.go +++ b/internal/backup/collector_pve.go @@ -1997,9 +1997,7 @@ func (c *Collector) describeDiskUsage(ctx context.Context, path string, ioTimeou if err != nil { return "", err } - total := int64(stat.Blocks) * int64(stat.Bsize) - available := int64(stat.Bavail) * int64(stat.Bsize) - used := total - available + total, available, used := safefs.SpaceUsageFromStatfs(stat) if total <= 0 { return "", fmt.Errorf("invalid filesystem statistics for %s", path) } diff --git a/internal/backup/collector_pve_additional_test.go b/internal/backup/collector_pve_additional_test.go index 5b5ef981..8c4e915c 100644 --- a/internal/backup/collector_pve_additional_test.go +++ b/internal/backup/collector_pve_additional_test.go @@ -2,13 +2,17 @@ package backup import ( "context" + "fmt" "io" "os" "path/filepath" "strings" + "syscall" "testing" + "time" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safefs" "github.com/tis24dev/proxsave/internal/types" ) @@ -88,6 +92,40 @@ func TestPatternWriterWrite_WritesRelativePathLine(t *testing.T) { } } +func TestDescribeDiskUsageMatchesStatfsSemantics(t *testing.T) { + collector := newTestCollector(t) + tempDir := t.TempDir() + + var stat syscall.Statfs_t + if err := syscall.Statfs(tempDir, &stat); err != nil { + t.Fatalf("Statfs: %v", err) + } + total, available, used := safefs.SpaceUsageFromStatfs(stat) + + got, err := collector.describeDiskUsage(context.Background(), tempDir, time.Second) + if err != nil { + t.Fatalf("describeDiskUsage error: %v", err) + } + + want := fmt.Sprintf("Used: %s / Total: %s (Free: %s)", + FormatBytes(used), + FormatBytes(total), + FormatBytes(available), + ) + if got != want { + t.Fatalf("describeDiskUsage = %q; want %q", got, want) + } +} + +func TestDescribeDiskUsageReturnsStatfsError(t *testing.T) { + collector := newTestCollector(t) + missingPath := filepath.Join(t.TempDir(), "missing") + + if _, err := collector.describeDiskUsage(context.Background(), missingPath, time.Second); err == nil { + t.Fatalf("describeDiskUsage() error = nil; want statfs error") + } +} + func TestCollectorCopyBackupSample_CopiesFile(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(io.Discard) diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index 8b20d737..c1b6caf6 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -175,7 +175,7 @@ func (s *stubStorage) VerifyUpload(ctx context.Context, localFile, remoteFile st return true, nil } func (s *stubStorage) GetStats(ctx context.Context) (*storage.StorageStats, error) { - return &storage.StorageStats{TotalBackups: len(s.list), AvailableSpace: 1024, TotalSpace: 2048}, nil + return &storage.StorageStats{TotalBackups: len(s.list), AvailableSpace: 1024, UsedSpace: 768, TotalSpace: 2048}, nil } func TestApplyStorageStatsSimplePrimary(t *testing.T) { @@ -184,12 +184,12 @@ func TestApplyStorageStatsSimplePrimary(t *testing.T) { logger: logging.New(types.LogLevelError, false), } stats := &BackupStats{} - storageStats := &storage.StorageStats{TotalBackups: 3, AvailableSpace: 100, TotalSpace: 200} + storageStats := &storage.StorageStats{TotalBackups: 3, AvailableSpace: 100, UsedSpace: 80, TotalSpace: 200} retentionCfg := storage.RetentionConfig{Policy: "simple", MaxBackups: 5} adapter.applyStorageStats(storageStats, retentionCfg, stats) - if stats.LocalBackups != 3 || stats.LocalFreeSpace != 100 || stats.LocalTotalSpace != 200 { + if stats.LocalBackups != 3 || stats.LocalFreeSpace != 100 || stats.LocalUsedSpace != 80 || stats.LocalTotalSpace != 200 { t.Fatalf("local stats not set correctly: %+v", stats) } if stats.LocalRetentionPolicy != "simple" { @@ -210,7 +210,7 @@ func TestApplyStorageStatsGFSPrimary(t *testing.T) { logger: logging.New(types.LogLevelError, false), } stats := &BackupStats{} - storageStats := &storage.StorageStats{TotalBackups: len(backups), AvailableSpace: 500, TotalSpace: 1000} + storageStats := &storage.StorageStats{TotalBackups: len(backups), AvailableSpace: 500, UsedSpace: 400, TotalSpace: 1000} retentionCfg := storage.RetentionConfig{Policy: "gfs", Daily: 1, Weekly: 1, Monthly: 1, Yearly: 1} adapter.applyStorageStats(storageStats, retentionCfg, stats) @@ -226,6 +226,28 @@ func TestApplyStorageStatsGFSPrimary(t *testing.T) { } } +func TestApplyStorageStatsSimpleSecondaryUsesUsedSpace(t *testing.T) { + adapter := &StorageAdapter{ + backend: &stubStorage{loc: storage.LocationSecondary}, + logger: logging.New(types.LogLevelError, false), + } + stats := &BackupStats{} + storageStats := &storage.StorageStats{TotalBackups: 4, AvailableSpace: 300, UsedSpace: 700, TotalSpace: 1000} + retentionCfg := storage.RetentionConfig{Policy: "simple", MaxBackups: 7} + + adapter.applyStorageStats(storageStats, retentionCfg, stats) + + if !stats.SecondaryEnabled { + t.Fatalf("SecondaryEnabled = false, want true") + } + if stats.SecondaryBackups != 4 || stats.SecondaryFreeSpace != 300 || stats.SecondaryUsedSpace != 700 || stats.SecondaryTotalSpace != 1000 { + t.Fatalf("secondary stats not set correctly: %+v", stats) + } + if stats.SecondaryRetentionPolicy != "simple" { + t.Fatalf("SecondaryRetentionPolicy = %q, want simple", stats.SecondaryRetentionPolicy) + } +} + func TestSetAndFinalizeStorageStatus(t *testing.T) { stats := &BackupStats{} adapter := &StorageAdapter{ diff --git a/internal/orchestrator/helpers_test.go b/internal/orchestrator/helpers_test.go index a01050c9..ea01bff9 100644 --- a/internal/orchestrator/helpers_test.go +++ b/internal/orchestrator/helpers_test.go @@ -794,48 +794,24 @@ func TestFormatBytesHR(t *testing.T) { func TestCalculateUsagePercent(t *testing.T) { tests := []struct { name string - freeBytes uint64 + usedBytes uint64 total uint64 want float64 }{ {"zero total", 0, 0, 0.0}, {"50% used", 500, 1000, 50.0}, - {"100% full", 0, 1000, 100.0}, - {"empty disk", 1000, 1000, 0.0}, - {"25% used", 750, 1000, 25.0}, + {"100% full", 1000, 1000, 100.0}, + {"empty disk", 0, 1000, 0.0}, + {"25% used", 250, 1000, 25.0}, + {"used exceeds total", 1500, 1000, 100.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := calculateUsagePercent(tt.freeBytes, tt.total) + got := calculateUsagePercent(tt.usedBytes, tt.total) if got != tt.want { t.Errorf("calculateUsagePercent(%d, %d) = %f; want %f", - tt.freeBytes, tt.total, got, tt.want) - } - }) - } -} - -func TestCalculateUsedBytes(t *testing.T) { - tests := []struct { - name string - freeBytes uint64 - total uint64 - want uint64 - }{ - {"zero total", 0, 0, 0}, - {"normal usage", 300, 1000, 700}, - {"full disk", 0, 1000, 1000}, - {"empty disk", 1000, 1000, 0}, - {"free > total (invalid)", 1500, 1000, 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := calculateUsedBytes(tt.freeBytes, tt.total) - if got != tt.want { - t.Errorf("calculateUsedBytes(%d, %d) = %d; want %d", - tt.freeBytes, tt.total, got, tt.want) + tt.usedBytes, tt.total, got, tt.want) } }) } diff --git a/internal/orchestrator/notification_adapter.go b/internal/orchestrator/notification_adapter.go index 2b4605ae..60038fe6 100644 --- a/internal/orchestrator/notification_adapter.go +++ b/internal/orchestrator/notification_adapter.go @@ -160,17 +160,19 @@ func (n *NotificationAdapter) convertBackupStatsToNotificationData(stats *Backup compressionRatio = (1.0 - float64(stats.CompressedSize)/float64(stats.UncompressedSize)) * 100.0 } + n.warnOnInconsistentUsageStats("local", stats.LocalUsedSpace, stats.LocalTotalSpace) localFree := formatBytesHR(stats.LocalFreeSpace) - localUsed := formatBytesHR(calculateUsedBytes(stats.LocalFreeSpace, stats.LocalTotalSpace)) - localPercent := formatPercentString(calculateUsagePercent(stats.LocalFreeSpace, stats.LocalTotalSpace)) + localUsed := formatBytesHR(stats.LocalUsedSpace) + localPercent := formatPercentString(calculateUsagePercent(stats.LocalUsedSpace, stats.LocalTotalSpace)) secondaryFree := "" secondaryUsed := "" secondaryPercent := "" if stats.SecondaryEnabled { + n.warnOnInconsistentUsageStats("secondary", stats.SecondaryUsedSpace, stats.SecondaryTotalSpace) secondaryFree = formatBytesHR(stats.SecondaryFreeSpace) - secondaryUsed = formatBytesHR(calculateUsedBytes(stats.SecondaryFreeSpace, stats.SecondaryTotalSpace)) - secondaryPercent = formatPercentString(calculateUsagePercent(stats.SecondaryFreeSpace, stats.SecondaryTotalSpace)) + secondaryUsed = formatBytesHR(stats.SecondaryUsedSpace) + secondaryPercent = formatPercentString(calculateUsagePercent(stats.SecondaryUsedSpace, stats.SecondaryTotalSpace)) } // Parse log file for categories - use ParseLogCounts as primary source @@ -224,7 +226,7 @@ func (n *NotificationAdapter) convertBackupStatsToNotificationData(stats *Backup LocalUsed: localUsed, LocalPercent: localPercent, LocalSpaceBytes: stats.LocalFreeSpace, - LocalUsagePercent: calculateUsagePercent(stats.LocalFreeSpace, stats.LocalTotalSpace), + LocalUsagePercent: calculateUsagePercent(stats.LocalUsedSpace, stats.LocalTotalSpace), // Local retention info LocalRetentionPolicy: stats.LocalRetentionPolicy, @@ -247,7 +249,7 @@ func (n *NotificationAdapter) convertBackupStatsToNotificationData(stats *Backup SecondaryUsed: secondaryUsed, SecondaryPercent: secondaryPercent, SecondarySpaceBytes: stats.SecondaryFreeSpace, - SecondaryUsagePercent: calculateUsagePercent(stats.SecondaryFreeSpace, stats.SecondaryTotalSpace), + SecondaryUsagePercent: calculateUsagePercent(stats.SecondaryUsedSpace, stats.SecondaryTotalSpace), // Secondary retention info SecondaryRetentionPolicy: stats.SecondaryRetentionPolicy, @@ -318,20 +320,22 @@ func formatBytesHR(bytes uint64) string { return fmt.Sprintf("%.2f %s", val, units[exp]) } -// calculateUsagePercent calculates the usage percentage -func calculateUsagePercent(freeBytes, totalBytes uint64) float64 { +// calculateUsagePercent calculates the usage percentage from used and total bytes. +func calculateUsagePercent(usedBytes, totalBytes uint64) float64 { if totalBytes == 0 { return 0.0 } - usedBytes := totalBytes - freeBytes + if usedBytes >= totalBytes { + return 100.0 + } return (float64(usedBytes) / float64(totalBytes)) * 100.0 } -func calculateUsedBytes(freeBytes, totalBytes uint64) uint64 { - if totalBytes == 0 || totalBytes <= freeBytes { - return 0 +func (n *NotificationAdapter) warnOnInconsistentUsageStats(location string, usedBytes, totalBytes uint64) { + if n == nil || n.logger == nil || totalBytes == 0 || usedBytes <= totalBytes { + return } - return totalBytes - freeBytes + n.logger.Warning("%s storage usage stats inconsistent: used=%d total=%d; clamping percentage to 100%% for display", location, usedBytes, totalBytes) } func formatPercentString(percent float64) string { diff --git a/internal/orchestrator/notification_adapter_test.go b/internal/orchestrator/notification_adapter_test.go index a4c84c37..aacda675 100644 --- a/internal/orchestrator/notification_adapter_test.go +++ b/internal/orchestrator/notification_adapter_test.go @@ -210,12 +210,14 @@ func TestConvertBackupStatsToNotificationData(t *testing.T) { CompressedSize: 4000, UncompressedSize: 8000, LocalBackups: 2, - LocalFreeSpace: 1024, - LocalTotalSpace: 2048, + LocalFreeSpace: 400, + LocalUsedSpace: 500, + LocalTotalSpace: 1000, SecondaryEnabled: true, SecondaryBackups: 1, - SecondaryFreeSpace: 2048, - SecondaryTotalSpace: 4096, + SecondaryFreeSpace: 1000, + SecondaryUsedSpace: 2500, + SecondaryTotalSpace: 4000, CloudEnabled: true, CloudBackups: 3, MaxLocalBackups: 10, @@ -267,6 +269,12 @@ func TestConvertBackupStatsToNotificationData(t *testing.T) { if data.EmailStatus != "disabled" || data.TelegramStatus != "N/A" { t.Fatalf("Email/Telegram status unexpected: %q / %q", data.EmailStatus, data.TelegramStatus) } + if data.LocalUsed != "500 B" || data.LocalPercent != "50.0%" { + t.Fatalf("local usage should use provided used-space stats, got used=%q percent=%q", data.LocalUsed, data.LocalPercent) + } + if data.SecondaryUsed != "2.44 KB" || data.SecondaryPercent != "62.5%" { + t.Fatalf("secondary usage should use provided used-space stats, got used=%q percent=%q", data.SecondaryUsed, data.SecondaryPercent) + } } func TestFormatHelpers(t *testing.T) { @@ -276,11 +284,11 @@ func TestFormatHelpers(t *testing.T) { if got := formatBytesHR(1024); got != "1.00 KB" { t.Fatalf("formatBytesHR(1024) = %q; want 1.00 KB", got) } - if got := calculateUsagePercent(25, 100); got != 75 { - t.Fatalf("calculateUsagePercent = %f; want 75", got) + if got := calculateUsagePercent(25, 100); got != 25 { + t.Fatalf("calculateUsagePercent = %f; want 25", got) } - if got := calculateUsedBytes(25, 100); got != 75 { - t.Fatalf("calculateUsedBytes = %d; want 75", got) + if got := calculateUsagePercent(125, 100); got != 100 { + t.Fatalf("calculateUsagePercent should clamp at 100, got %f", got) } if got := formatPercentString(12.345); got != "12.3%" { t.Fatalf("formatPercentString = %q; want 12.3%%", got) @@ -357,6 +365,32 @@ func TestConvertBackupStatsUsesLogCountsAndCompressionFallback(t *testing.T) { } } +func TestConvertBackupStatsToNotificationDataWarnsOnInconsistentUsageStats(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + var buf bytes.Buffer + logger.SetOutput(&buf) + + adapter := NewNotificationAdapter(&stubNotifier{name: "Email", enabled: true}, logger) + stats := sampleBackupStats() + stats.LocalUsedSpace = 1500 + stats.LocalTotalSpace = 1000 + stats.SecondaryUsedSpace = 4500 + stats.SecondaryTotalSpace = 4000 + + data := adapter.convertBackupStatsToNotificationData(stats) + + if data.LocalUsagePercent != 100 || data.SecondaryUsagePercent != 100 { + t.Fatalf("usage percent should still clamp for display, got local=%f secondary=%f", data.LocalUsagePercent, data.SecondaryUsagePercent) + } + logOutput := buf.String() + if !strings.Contains(logOutput, "local storage usage stats inconsistent") { + t.Fatalf("expected local inconsistency warning, got %q", logOutput) + } + if !strings.Contains(logOutput, "secondary storage usage stats inconsistent") { + t.Fatalf("expected secondary inconsistency warning, got %q", logOutput) + } +} + func sampleBackupStats() *BackupStats { return &BackupStats{ ExitCode: 0, @@ -366,12 +400,14 @@ func sampleBackupStats() *BackupStats { ArchivePath: "/var/tmp/backup.tar", CompressedSize: 12345, LocalBackups: 1, - LocalFreeSpace: 1024, - LocalTotalSpace: 2048, + LocalFreeSpace: 400, + LocalUsedSpace: 500, + LocalTotalSpace: 1000, SecondaryEnabled: true, SecondaryBackups: 1, - SecondaryFreeSpace: 2048, - SecondaryTotalSpace: 4096, + SecondaryFreeSpace: 1000, + SecondaryUsedSpace: 2500, + SecondaryTotalSpace: 4000, CloudEnabled: true, CloudBackups: 1, Timestamp: time.Now(), diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 271e88ca..d645f70e 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -106,9 +106,11 @@ type BackupStats struct { SecondaryEnabled bool LocalBackups int LocalFreeSpace uint64 + LocalUsedSpace uint64 LocalTotalSpace uint64 SecondaryBackups int SecondaryFreeSpace uint64 + SecondaryUsedSpace uint64 SecondaryTotalSpace uint64 CloudEnabled bool CloudBackups int diff --git a/internal/orchestrator/storage_adapter.go b/internal/orchestrator/storage_adapter.go index 6e473bf1..344d4ac7 100644 --- a/internal/orchestrator/storage_adapter.go +++ b/internal/orchestrator/storage_adapter.go @@ -252,6 +252,7 @@ func (s *StorageAdapter) applyStorageStats(storageStats *storage.StorageStats, r case storage.LocationPrimary: stats.LocalBackups = storageStats.TotalBackups stats.LocalFreeSpace = clampInt64ToUint64(storageStats.AvailableSpace) + stats.LocalUsedSpace = clampInt64ToUint64(storageStats.UsedSpace) stats.LocalTotalSpace = clampInt64ToUint64(storageStats.TotalSpace) // Populate retention info stats.LocalRetentionPolicy = retentionConfig.Policy @@ -273,6 +274,7 @@ func (s *StorageAdapter) applyStorageStats(storageStats *storage.StorageStats, r } stats.SecondaryBackups = storageStats.TotalBackups stats.SecondaryFreeSpace = clampInt64ToUint64(storageStats.AvailableSpace) + stats.SecondaryUsedSpace = clampInt64ToUint64(storageStats.UsedSpace) stats.SecondaryTotalSpace = clampInt64ToUint64(storageStats.TotalSpace) // Populate retention info stats.SecondaryRetentionPolicy = retentionConfig.Policy diff --git a/internal/orchestrator/storage_adapter_test.go b/internal/orchestrator/storage_adapter_test.go index 9f6a6deb..0b3360bb 100644 --- a/internal/orchestrator/storage_adapter_test.go +++ b/internal/orchestrator/storage_adapter_test.go @@ -275,6 +275,7 @@ func TestStorageAdapterSync_NonCriticalStoreErrorFinalizesErrorAndContinues(t *t return &storage.StorageStats{ TotalBackups: 3, AvailableSpace: 10, + UsedSpace: 7, TotalSpace: 20, }, nil }, @@ -298,6 +299,9 @@ func TestStorageAdapterSync_NonCriticalStoreErrorFinalizesErrorAndContinues(t *t if stats.SecondaryBackups != 3 { t.Fatalf("SecondaryBackups = %d; want 3", stats.SecondaryBackups) } + if stats.SecondaryUsedSpace != 7 { + t.Fatalf("SecondaryUsedSpace = %d; want 7", stats.SecondaryUsedSpace) + } if stats.SecondaryRetentionPolicy != "simple" { t.Fatalf("SecondaryRetentionPolicy = %q; want simple", stats.SecondaryRetentionPolicy) } @@ -325,6 +329,7 @@ func TestStorageAdapterSync_NonCriticalRetentionErrorFinalizesWarning(t *testing return &storage.StorageStats{ TotalBackups: 1, AvailableSpace: 5, + UsedSpace: 4, TotalSpace: 10, }, nil }, @@ -340,6 +345,9 @@ func TestStorageAdapterSync_NonCriticalRetentionErrorFinalizesWarning(t *testing if got := stats.LocalStatus; got != "warning" { t.Fatalf("LocalStatus = %q; want warning", got) } + if stats.LocalUsedSpace != 4 { + t.Fatalf("LocalUsedSpace = %d; want 4", stats.LocalUsedSpace) + } if stats.LocalRetentionPolicy != "simple" { t.Fatalf("LocalRetentionPolicy = %q; want simple", stats.LocalRetentionPolicy) } diff --git a/internal/safefs/safefs.go b/internal/safefs/safefs.go index 1ca2bed5..61c8c806 100644 --- a/internal/safefs/safefs.go +++ b/internal/safefs/safefs.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "math" "os" "syscall" "time" @@ -172,3 +173,35 @@ func Statfs(ctx context.Context, path string, timeout time.Duration) (syscall.St return stat, err }) } + +// SpaceUsageFromStatfs converts statfs counters into total, user-available, and +// actually-used byte counts. "Available" tracks Bavail (space a non-root user can +// allocate), while "used" tracks Blocks-Bfree (space already consumed). +func SpaceUsageFromStatfs(stat syscall.Statfs_t) (totalBytes, availableBytes, usedBytes int64) { + totalBytes = statfsBlocksToBytes(stat.Blocks, stat.Bsize) + availableBytes = statfsBlocksToBytes(stat.Bavail, stat.Bsize) + + if stat.Blocks > stat.Bfree { + usedBytes = statfsBlocksToBytes(stat.Blocks-stat.Bfree, stat.Bsize) + } + if availableBytes > totalBytes { + availableBytes = totalBytes + } + if usedBytes > totalBytes { + usedBytes = totalBytes + } + + return totalBytes, availableBytes, usedBytes +} + +func statfsBlocksToBytes(blocks uint64, blockSize int64) int64 { + if blocks == 0 || blockSize <= 0 { + return 0 + } + + size := uint64(blockSize) + if blocks > uint64(math.MaxInt64)/size { + return math.MaxInt64 + } + return int64(blocks * size) +} diff --git a/internal/safefs/safefs_test.go b/internal/safefs/safefs_test.go index 27d87b82..97c0224b 100644 --- a/internal/safefs/safefs_test.go +++ b/internal/safefs/safefs_test.go @@ -3,6 +3,7 @@ package safefs import ( "context" "errors" + "math" "os" "sync/atomic" "syscall" @@ -153,6 +154,87 @@ func TestStatfs_ReturnsTimeoutError(t *testing.T) { } } +func TestSpaceUsageFromStatfsUsesBfreeForUsedSpace(t *testing.T) { + stat := syscall.Statfs_t{ + Blocks: 100, + Bfree: 20, + Bavail: 15, + Bsize: 4096, + } + + total, available, used := SpaceUsageFromStatfs(stat) + + if total != 100*4096 { + t.Fatalf("total = %d; want %d", total, 100*4096) + } + if available != 15*4096 { + t.Fatalf("available = %d; want %d", available, 15*4096) + } + if used != 80*4096 { + t.Fatalf("used = %d; want %d", used, 80*4096) + } + if used == total-available { + t.Fatalf("used should not be derived from Bavail when reserved blocks exist") + } +} + +func TestSpaceUsageFromStatfsClampsInconsistentCounters(t *testing.T) { + stat := syscall.Statfs_t{ + Blocks: 100, + Bfree: 150, + Bavail: 125, + Bsize: 1024, + } + + total, available, used := SpaceUsageFromStatfs(stat) + + if total != 100*1024 { + t.Fatalf("total = %d; want %d", total, 100*1024) + } + if available != total { + t.Fatalf("available = %d; want clamp to total %d", available, total) + } + if used != 0 { + t.Fatalf("used = %d; want 0", used) + } +} + +func TestSpaceUsageFromStatfsClampsNegativeByteCounts(t *testing.T) { + stat := syscall.Statfs_t{ + Blocks: 10, + Bfree: 2, + Bavail: 1, + Bsize: -4096, + } + + total, available, used := SpaceUsageFromStatfs(stat) + + if total != 0 || available != 0 || used != 0 { + t.Fatalf("negative byte counts should clamp to zero, got total=%d available=%d used=%d", total, available, used) + } +} + +func TestSpaceUsageFromStatfsSaturatesOverflowingProducts(t *testing.T) { + stat := syscall.Statfs_t{ + Blocks: 1<<63 - 1, + Bfree: 0, + Bavail: 1<<63 - 1, + Bsize: 4096, + } + + total, available, used := SpaceUsageFromStatfs(stat) + + if total != math.MaxInt64 { + t.Fatalf("total = %d; want %d", total, math.MaxInt64) + } + if available != math.MaxInt64 { + t.Fatalf("available = %d; want %d", available, math.MaxInt64) + } + if used != math.MaxInt64 { + t.Fatalf("used = %d; want %d", used, math.MaxInt64) + } +} + func TestStat_PropagatesContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/internal/storage/local.go b/internal/storage/local.go index c7664462..f6bbc421 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -16,6 +16,7 @@ import ( "github.com/tis24dev/proxsave/internal/backup" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safefs" "github.com/tis24dev/proxsave/internal/types" ) @@ -661,20 +662,9 @@ func (l *LocalStorage) GetStats(ctx context.Context) (stats *StorageStats, err e // Get available/total space using statfs var stat syscall.Statfs_t if err := syscall.Statfs(l.basePath, &stat); err == nil { - available := int64(stat.Bavail) * int64(stat.Bsize) - total := int64(stat.Blocks) * int64(stat.Bsize) - if available < 0 { - available = 0 - } - if total < 0 { - total = 0 - } + total, available, used := safefs.SpaceUsageFromStatfs(stat) stats.AvailableSpace = available stats.TotalSpace = total - used := total - available - if used < 0 { - used = 0 - } stats.UsedSpace = used } diff --git a/internal/storage/secondary.go b/internal/storage/secondary.go index ca5a669a..eca5cd86 100644 --- a/internal/storage/secondary.go +++ b/internal/storage/secondary.go @@ -13,6 +13,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safefs" "github.com/tis24dev/proxsave/internal/types" "github.com/tis24dev/proxsave/pkg/utils" ) @@ -727,20 +728,9 @@ func (s *SecondaryStorage) GetStats(ctx context.Context) (stats *StorageStats, e // Get available/total space using statfs var stat syscall.Statfs_t if err := syscall.Statfs(s.basePath, &stat); err == nil { - available := int64(stat.Bavail) * int64(stat.Bsize) - total := int64(stat.Blocks) * int64(stat.Bsize) - if available < 0 { - available = 0 - } - if total < 0 { - total = 0 - } + total, available, used := safefs.SpaceUsageFromStatfs(stat) stats.AvailableSpace = available stats.TotalSpace = total - used := total - available - if used < 0 { - used = 0 - } stats.UsedSpace = used } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 439e82e2..398aa7f0 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -18,6 +18,7 @@ import ( "github.com/tis24dev/proxsave/internal/backup" "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/safefs" "github.com/tis24dev/proxsave/internal/types" ) @@ -1526,8 +1527,13 @@ func TestSecondaryStorageGetStatsIncludesFilesystemInfo(t *testing.T) { if stats.TotalSpace == 0 || stats.AvailableSpace == 0 { t.Fatalf("expected filesystem stats to be populated (TotalSpace=%d, AvailableSpace=%d)", stats.TotalSpace, stats.AvailableSpace) } - if stats.UsedSpace != stats.TotalSpace-stats.AvailableSpace { - t.Fatalf("UsedSpace mismatch: got %d want %d", stats.UsedSpace, stats.TotalSpace-stats.AvailableSpace) + stat, err := safefs.Statfs(context.Background(), backupDir, 0) + if err != nil { + t.Fatalf("Statfs() error = %v", err) + } + _, _, wantUsed := safefs.SpaceUsageFromStatfs(stat) + if stats.UsedSpace != wantUsed { + t.Fatalf("UsedSpace mismatch: got %d want %d", stats.UsedSpace, wantUsed) } } From c9c78c373e606bff6af63aa598dda4dcdea07168 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 17 Mar 2026 21:40:33 +0100 Subject: [PATCH 21/29] fix(orchestrator): avoid duplicate file close in bundle and restore paths Prevent duplicate Close() calls in createBundle() and extractRegularFile() while preserving explicit close error handling. The patch replaces bare deferred closes with guarded defers so resources are only closed if they were not already closed explicitly, and it keeps the explicit Close() calls where finalization order and returned errors still matter. Test coverage was also improved to verify that output files are correctly closed in both success and error paths for bundle creation and regular file extraction. LiveReview Pre-Commit Check: skipped (iter:2, coverage:100%) --- internal/orchestrator/bundle_test.go | 71 +++++++++++++++++++- internal/orchestrator/orchestrator.go | 16 ++++- internal/orchestrator/restore.go | 8 ++- internal/orchestrator/restore_errors_test.go | 38 ++++++++++- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/internal/orchestrator/bundle_test.go b/internal/orchestrator/bundle_test.go index 280060e3..2530e197 100644 --- a/internal/orchestrator/bundle_test.go +++ b/internal/orchestrator/bundle_test.go @@ -3,6 +3,7 @@ package orchestrator import ( "archive/tar" "context" + "errors" "io" "os" "path/filepath" @@ -11,6 +12,27 @@ import ( "github.com/tis24dev/proxsave/internal/logging" ) +type trackingBundleFS struct { + FS + createdFile *os.File + openErr map[string]error +} + +func (f *trackingBundleFS) Create(name string) (*os.File, error) { + file, err := f.FS.Create(name) + if err == nil { + f.createdFile = file + } + return file, err +} + +func (f *trackingBundleFS) Open(path string) (*os.File, error) { + if err, ok := f.openErr[filepath.Clean(path)]; ok { + return nil, err + } + return f.FS.Open(path) +} + func TestCreateBundle_CreatesValidTarArchive(t *testing.T) { logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) tempDir := t.TempDir() @@ -30,9 +52,10 @@ func TestCreateBundle_CreatesValidTarArchive(t *testing.T) { } } + bundleFS := &trackingBundleFS{FS: osFS{}} o := &Orchestrator{ logger: logger, - fs: osFS{}, + fs: bundleFS, } bundlePath, err := o.createBundle(context.Background(), archive) @@ -44,6 +67,12 @@ func TestCreateBundle_CreatesValidTarArchive(t *testing.T) { if bundlePath != expectedPath { t.Fatalf("bundle path = %s, want %s", bundlePath, expectedPath) } + if bundleFS.createdFile == nil { + t.Fatalf("expected tracked bundle file") + } + if err := bundleFS.createdFile.Close(); !errors.Is(err, os.ErrClosed) { + t.Fatalf("bundle file close after createBundle = %v, want ErrClosed", err) + } // Verify bundle file exists bundleInfo, err := os.Stat(bundlePath) @@ -121,6 +150,46 @@ func TestCreateBundle_CreatesValidTarArchive(t *testing.T) { } } +func TestCreateBundle_ClosesBundleFileOnInputOpenError(t *testing.T) { + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + tempDir := t.TempDir() + archive := filepath.Join(tempDir, "backup.tar") + + testData := map[string]string{ + "": "archive-content", + ".sha256": "checksum1", + ".metadata": "metadata-json", + } + for suffix, content := range testData { + if err := os.WriteFile(archive+suffix, []byte(content), 0o640); err != nil { + t.Fatalf("write %s: %v", suffix, err) + } + } + + forcedErr := errors.New("forced open failure") + bundleFS := &trackingBundleFS{ + FS: osFS{}, + openErr: map[string]error{ + filepath.Clean(archive + ".sha256"): forcedErr, + }, + } + o := &Orchestrator{ + logger: logger, + fs: bundleFS, + } + + _, err := o.createBundle(context.Background(), archive) + if !errors.Is(err, forcedErr) { + t.Fatalf("createBundle error = %v, want wrapped %v", err, forcedErr) + } + if bundleFS.createdFile == nil { + t.Fatalf("expected tracked bundle file") + } + if err := bundleFS.createdFile.Close(); !errors.Is(err, os.ErrClosed) { + t.Fatalf("bundle file close after createBundle error = %v, want ErrClosed", err) + } +} + func TestRemoveAssociatedFiles_RemovesAll(t *testing.T) { logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) tempDir := t.TempDir() diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index d645f70e..a4c2823e 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -1126,10 +1126,18 @@ func (o *Orchestrator) createBundle(ctx context.Context, archivePath string) (bu if err != nil { return "", fmt.Errorf("failed to create bundle file: %w", err) } - defer outFile.Close() + defer func() { + if outFile != nil { + _ = outFile.Close() + } + }() tw := tar.NewWriter(outFile) - defer tw.Close() + defer func() { + if tw != nil { + _ = tw.Close() + } + }() // Add each associated file to the tar archive for _, filename := range associated { @@ -1171,12 +1179,16 @@ func (o *Orchestrator) createBundle(ctx context.Context, archivePath string) (bu // Close tar writer to flush if err := tw.Close(); err != nil { + tw = nil return "", fmt.Errorf("failed to finalize tar archive: %w", err) } + tw = nil if err := outFile.Close(); err != nil { + outFile = nil return "", fmt.Errorf("failed to close bundle file: %w", err) } + outFile = nil // Verify bundle was created if _, err := fs.Stat(bundlePath); err != nil { diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index a3903602..73b00e63 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -1607,7 +1607,11 @@ func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header if err != nil { return fmt.Errorf("create file: %w", err) } - defer outFile.Close() + defer func() { + if outFile != nil { + _ = outFile.Close() + } + }() // Copy content if _, err := io.Copy(outFile, tarReader); err != nil { @@ -1616,8 +1620,10 @@ func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header // Close before setting attributes if err := outFile.Close(); err != nil { + outFile = nil return fmt.Errorf("close file: %w", err) } + outFile = nil // Set ownership if err := os.Chown(target, header.Uid, header.Gid); err != nil { diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 0faf0241..96dcd0e5 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -785,6 +785,19 @@ func (a *alwaysFailCommandRunner) RunStream(ctx context.Context, name string, st return nil, a.err } +type trackingOpenFileFS struct { + FS + lastOpened *os.File +} + +func (f *trackingOpenFileFS) OpenFile(path string, flag int, perm os.FileMode) (*os.File, error) { + file, err := f.FS.OpenFile(path, flag, perm) + if err == nil { + f.lastOpened = file + } + return file, err +} + // -------------------------------------------------------------------------- // ErrorInjectingFS - FS wrapper that can inject errors // -------------------------------------------------------------------------- @@ -916,7 +929,8 @@ func TestExtractRegularFile_CopyFails(t *testing.T) { origFS := restoreFS t.Cleanup(func() { restoreFS = origFS }) - restoreFS = osFS{} + trackingFS := &trackingOpenFileFS{FS: osFS{}} + restoreFS = trackingFS dir := t.TempDir() target := filepath.Join(dir, "testfile.txt") @@ -942,6 +956,12 @@ func TestExtractRegularFile_CopyFails(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "write file content") { t.Fatalf("expected io.Copy error, got: %v", err) } + if trackingFS.lastOpened == nil { + t.Fatalf("expected tracked output file") + } + if closeErr := trackingFS.lastOpened.Close(); !errors.Is(closeErr, os.ErrClosed) { + t.Fatalf("output file close after copy failure = %v, want ErrClosed", closeErr) + } } // -------------------------------------------------------------------------- @@ -1609,7 +1629,8 @@ func TestExtractDirectory_SuccessWithTimestamps(t *testing.T) { func TestExtractRegularFile_Success(t *testing.T) { origFS := restoreFS t.Cleanup(func() { restoreFS = origFS }) - restoreFS = osFS{} + trackingFS := &trackingOpenFileFS{FS: osFS{}} + restoreFS = trackingFS dir := t.TempDir() target := filepath.Join(dir, "file.txt") @@ -1651,6 +1672,19 @@ func TestExtractRegularFile_Success(t *testing.T) { if string(data) != "hello world" { t.Fatalf("expected 'hello world', got: %q", string(data)) } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), 0o644) + } + if trackingFS.lastOpened == nil { + t.Fatalf("expected tracked output file") + } + if closeErr := trackingFS.lastOpened.Close(); !errors.Is(closeErr, os.ErrClosed) { + t.Fatalf("output file close after success = %v, want ErrClosed", closeErr) + } } // -------------------------------------------------------------------------- From 994c11e1c32eddb0dc7e367d5500034dff7b0e8c Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 17 Mar 2026 22:09:34 +0100 Subject: [PATCH 22/29] fix(orchestrator): make AGE recipient overwrite flow safe and atomic Prevent data loss when replacing the AGE recipient file by changing the backup logic to copy the existing file into a .bak-* backup instead of renaming or deleting the active file. The new recipient file is now written atomically through a temporary file and rename, and the workflow creates the backup only at commit time, after the new recipients have been collected. Backup failures are now propagated instead of being logged and ignored, so aborts and write errors leave the current recipient file intact. Regression tests were added to cover abort, backup failure, write failure, and successful overwrite paths. LiveReview Pre-Commit Check: skipped (iter:3, coverage:79%) --- internal/orchestrator/age_setup_workflow.go | 49 ++++- .../orchestrator/age_setup_workflow_test.go | 169 ++++++++++++++++++ internal/orchestrator/encryption.go | 164 +++++++++++++++-- .../orchestrator/encryption_exported_test.go | 15 +- internal/orchestrator/encryption_more_test.go | 19 +- internal/orchestrator/encryption_test.go | 15 +- 6 files changed, 395 insertions(+), 36 deletions(-) diff --git a/internal/orchestrator/age_setup_workflow.go b/internal/orchestrator/age_setup_workflow.go index e4a344e0..e4e68333 100644 --- a/internal/orchestrator/age_setup_workflow.go +++ b/internal/orchestrator/age_setup_workflow.go @@ -93,6 +93,7 @@ func (o *Orchestrator) prepareAgeRecipientsWithUI(ctx context.Context, ui AgeSet func (o *Orchestrator) runAgeSetupWorkflow(ctx context.Context, candidatePath string, ui AgeSetupUI) ([]string, *AgeRecipientSetupResult, error) { targetPath := strings.TrimSpace(candidatePath) + fs := o.filesystem() if targetPath == "" { targetPath = o.defaultAgeRecipientFile() } @@ -102,22 +103,33 @@ func (o *Orchestrator) runAgeSetupWorkflow(ctx context.Context, candidatePath st if o.logger != nil { o.logger.Info("Encryption setup: no AGE recipients found, starting interactive wizard") + o.logger.Debug("Encryption setup: target recipient file resolved to %s (force new recipient=%t)", targetPath, o.forceNewAgeRecipient) } + confirmedOverwriteExisting := false if o.forceNewAgeRecipient { - if _, err := os.Stat(targetPath); err == nil { + if _, err := fs.Stat(targetPath); err == nil { + confirmedOverwriteExisting = true + if o.logger != nil { + o.logger.Debug("Encryption setup: existing AGE recipient file found at %s; requesting overwrite confirmation", targetPath) + } confirm, err := ui.ConfirmOverwriteExistingRecipient(ctx, targetPath) if err != nil { return nil, nil, mapAgeSetupAbort(err) } if !confirm { + if o.logger != nil { + o.logger.Info("Encryption setup: overwrite declined for %s; leaving existing AGE recipient file unchanged", targetPath) + } return nil, nil, ErrAgeRecipientSetupAborted } - if err := backupExistingRecipientFile(targetPath); err != nil && o.logger != nil { - o.logger.Warning("NOTE: %v", err) + if o.logger != nil { + o.logger.Debug("Encryption setup: overwrite confirmed for %s; backup will be created before replacing the file", targetPath) } } else if !errors.Is(err, os.ErrNotExist) { return nil, nil, fmt.Errorf("failed to inspect existing AGE recipients at %s: %w", targetPath, err) + } else if o.logger != nil { + o.logger.Debug("Encryption setup: no existing AGE recipient file found at %s; a new file will be created", targetPath) } } @@ -153,13 +165,40 @@ func (o *Orchestrator) runAgeSetupWorkflow(ctx context.Context, candidatePath st if len(recipients) == 0 { return nil, nil, fmt.Errorf("no recipients provided") } + if o.logger != nil { + o.logger.Debug("Encryption setup: collected %d unique AGE recipient(s) for %s", len(recipients), targetPath) + } + + backupPath := "" + if confirmedOverwriteExisting { + if o.logger != nil { + o.logger.Debug("Encryption setup: creating backup of existing AGE recipient file at %s before overwrite", targetPath) + } + var err error + backupPath, err = backupExistingRecipientFileWithDeps(fs, o.clock, targetPath) + if err != nil { + if o.logger != nil { + o.logger.Warning("Encryption setup: failed to back up existing AGE recipients at %s: %v", targetPath, err) + } + return nil, nil, fmt.Errorf("backup existing AGE recipients at %s: %w", targetPath, err) + } + if o.logger != nil { + o.logger.Info("Encryption setup: existing AGE recipients backed up to %s", backupPath) + } + } - if err := writeRecipientFile(targetPath, recipients); err != nil { + if o.logger != nil { + o.logger.Debug("Encryption setup: writing %d AGE recipient(s) to %s (overwrite existing=%t)", len(recipients), targetPath, confirmedOverwriteExisting) + } + if err := writeRecipientFileWithDeps(fs, o.clock, targetPath, recipients); err != nil { return nil, nil, err } if o.logger != nil { - o.logger.Info("Saved AGE recipient to %s", targetPath) + o.logger.Info("Saved %d AGE recipient(s) to %s", len(recipients), targetPath) + if backupPath != "" { + o.logger.Debug("Encryption setup: previous AGE recipient file for %s was preserved at %s", targetPath, backupPath) + } o.logger.Info("Reminder: keep the AGE private key offline; the server stores only recipients.") } return recipients, &AgeRecipientSetupResult{ diff --git a/internal/orchestrator/age_setup_workflow_test.go b/internal/orchestrator/age_setup_workflow_test.go index 33a6730d..562281e2 100644 --- a/internal/orchestrator/age_setup_workflow_test.go +++ b/internal/orchestrator/age_setup_workflow_test.go @@ -5,7 +5,9 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" + "time" "filippo.io/age" @@ -47,6 +49,15 @@ func (m *mockAgeSetupUI) ConfirmAddAnotherRecipient(ctx context.Context, current return next, nil } +type renameFailFS struct { + *FakeFS + err error +} + +func (f *renameFailFS) Rename(oldpath, newpath string) error { + return f.err +} + func TestEnsureAgeRecipientsReadyWithUI_ReusesConfiguredRecipientsWithoutPrompting(t *testing.T) { id, err := age.GenerateX25519Identity() if err != nil { @@ -133,3 +144,161 @@ func TestEnsureAgeRecipientsReadyWithUI_ForceNewRecipientDeclineReturnsAbort(t * t.Fatalf("recipient file should remain in place, stat err=%v", statErr) } } + +func TestEnsureAgeRecipientsReadyWithUI_ForceNewRecipientSuccessfulOverwriteCreatesBackupOnCommit(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + tmp := t.TempDir() + target := filepath.Join(tmp, "identity", "age", "recipient.txt") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ui := &mockAgeSetupUI{ + overwrite: true, + drafts: []*AgeRecipientDraft{ + {Kind: AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + cfg := &config.Config{ + EncryptArchive: true, + BaseDir: tmp, + AgeRecipientFile: target, + } + fakeTime := &FakeTime{Current: time.Date(2026, 3, 17, 10, 11, 12, 0, time.UTC)} + orch := newEncryptionTestOrchestrator(cfg) + orch.SetForceNewAgeRecipient(true) + orch.clock = fakeTime + + if err := orch.EnsureAgeRecipientsReadyWithUI(context.Background(), ui); err != nil { + t.Fatalf("EnsureAgeRecipientsReadyWithUI error: %v", err) + } + + backupPath := target + ".bak-" + fakeTime.Current.Format("20060102-150405") + backup, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", backupPath, err) + } + if got := strings.TrimSpace(string(backup)); got != "old" { + t.Fatalf("backup content=%q; want %q", got, "old") + } + + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile(%s): %v", target, err) + } + if got := strings.TrimSpace(string(content)); got != id.Recipient().String() { + t.Fatalf("content=%q; want %q", got, id.Recipient().String()) + } + if ui.overwriteCalls != 1 { + t.Fatalf("overwriteCalls=%d; want 1", ui.overwriteCalls) + } +} + +func TestRunAgeSetupWorkflow_ForceNewRecipientBackupFailurePreservesOriginal(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + fs := NewFakeFS() + t.Cleanup(func() { _ = os.RemoveAll(fs.Root) }) + fakeTime := &FakeTime{Current: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC)} + target := "/identity/age/recipient.txt" + if err := fs.AddFile(target, []byte("old\n")); err != nil { + t.Fatalf("AddFile: %v", err) + } + backupPath := target + ".bak-" + fakeTime.Current.Format("20060102-150405") + fs.OpenFileErr[filepath.Clean(backupPath)] = errors.New("disk full") + + ui := &mockAgeSetupUI{ + overwrite: true, + drafts: []*AgeRecipientDraft{ + {Kind: AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + orch := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: true, AgeRecipientFile: target}) + orch.SetForceNewAgeRecipient(true) + orch.fs = fs + orch.clock = fakeTime + + _, _, err = orch.runAgeSetupWorkflow(context.Background(), target, ui) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "backup existing AGE recipients at "+target) { + t.Fatalf("err=%v; want backup failure context", err) + } + + content, readErr := fs.ReadFile(target) + if readErr != nil { + t.Fatalf("ReadFile(%s): %v", target, readErr) + } + if got := strings.TrimSpace(string(content)); got != "old" { + t.Fatalf("original content=%q; want %q", got, "old") + } + if _, statErr := fs.Stat(backupPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("backup stat err=%v; want not exist", statErr) + } +} + +func TestRunAgeSetupWorkflow_ForceNewRecipientWriteFailurePreservesOriginalAndBackup(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + baseFS := NewFakeFS() + t.Cleanup(func() { _ = os.RemoveAll(baseFS.Root) }) + fs := &renameFailFS{FakeFS: baseFS, err: errors.New("rename failed")} + fakeTime := &FakeTime{Current: time.Date(2026, 3, 17, 12, 30, 0, 0, time.UTC)} + target := "/identity/age/recipient.txt" + if err := fs.AddFile(target, []byte("old\n")); err != nil { + t.Fatalf("AddFile: %v", err) + } + + ui := &mockAgeSetupUI{ + overwrite: true, + drafts: []*AgeRecipientDraft{ + {Kind: AgeRecipientInputExisting, PublicKey: id.Recipient().String()}, + }, + addMore: []bool{false}, + } + orch := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: true, AgeRecipientFile: target}) + orch.SetForceNewAgeRecipient(true) + orch.fs = fs + orch.clock = fakeTime + + _, _, err = orch.runAgeSetupWorkflow(context.Background(), target, ui) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "write recipient file") { + t.Fatalf("err=%v; want write recipient file failure", err) + } + + backupPath := target + ".bak-" + fakeTime.Current.Format("20060102-150405") + backup, readErr := fs.ReadFile(backupPath) + if readErr != nil { + t.Fatalf("ReadFile(%s): %v", backupPath, readErr) + } + if got := strings.TrimSpace(string(backup)); got != "old" { + t.Fatalf("backup content=%q; want %q", got, "old") + } + + content, readErr := fs.ReadFile(target) + if readErr != nil { + t.Fatalf("ReadFile(%s): %v", target, readErr) + } + if got := strings.TrimSpace(string(content)); got != "old" { + t.Fatalf("original content=%q; want %q", got, "old") + } +} diff --git a/internal/orchestrator/encryption.go b/internal/orchestrator/encryption.go index 4d156769..aaf00dce 100644 --- a/internal/orchestrator/encryption.go +++ b/internal/orchestrator/encryption.go @@ -6,9 +6,11 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "strings" + "syscall" "time" "unicode" @@ -318,19 +320,23 @@ func readRecipientFile(path string) ([]string, error) { } func writeRecipientFile(path string, recipients []string) error { + return writeRecipientFileWithDeps(osFS{}, realTimeProvider{}, path, recipients) +} + +func writeRecipientFileWithDeps(fs FS, tp TimeProvider, path string, recipients []string) error { + if fs == nil { + fs = osFS{} + } if len(recipients) == 0 { return fmt.Errorf("no recipients to write") } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + if err := fs.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create recipient directory: %w", err) } content := strings.Join(recipients, "\n") + "\n" - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + if err := writeFileAtomicWithDeps(fs, tp, path, []byte(content), 0o600); err != nil { return fmt.Errorf("write recipient file: %w", err) } - if err := os.Chmod(path, 0o600); err != nil { - return fmt.Errorf("chmod recipient file: %w", err) - } return nil } @@ -354,26 +360,152 @@ func mapInputAbortToAgeAbort(err error) error { } func backupExistingRecipientFile(path string) error { + _, err := backupExistingRecipientFileWithDeps(osFS{}, realTimeProvider{}, path) + return err +} + +func backupExistingRecipientFileWithDeps(fs FS, tp TimeProvider, path string) (string, error) { + if fs == nil { + fs = osFS{} + } if path == "" { - return nil + return "", nil } - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return nil + info, err := fs.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil } + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("recipient path is a directory: %s", path) + } + perm := info.Mode().Perm() + if perm == 0 { + perm = 0o600 + } + backupPath := fmt.Sprintf("%s.bak-%s", path, recipientTime(tp).Format("20060102-150405")) + if err := copyRecipientFileWithDeps(fs, path, backupPath, perm); err != nil { + return "", fmt.Errorf("backup recipient file: %w", err) + } + return backupPath, nil +} + +func writeFileAtomicWithDeps(fs FS, tp TimeProvider, path string, data []byte, perm os.FileMode) error { + if fs == nil { + fs = osFS{} + } + perm &= 0o7777 + if perm == 0 { + perm = 0o600 + } + + tmpPath := fmt.Sprintf("%s.proxsave.tmp.%d", path, recipientTime(tp).UnixNano()) + tmpFile, err := fs.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL|os.O_TRUNC, perm) + if err != nil { return err } - backupPath := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405")) - if err := os.Rename(path, backupPath); err != nil { - if removeErr := os.Remove(path); removeErr != nil { - return fmt.Errorf("failed to backup recipient file: %w (also failed to remove: %v)", err, removeErr) + + writeErr := func() error { + if len(data) != 0 { + if _, err := tmpFile.Write(data); err != nil { + return err + } + } + if err := tmpFile.Chmod(perm); err != nil { + return err } - return fmt.Errorf("renamed recipient file failed, removed original: %w", err) + return tmpFile.Sync() + }() + + closeErr := tmpFile.Close() + if writeErr != nil { + _ = fs.Remove(tmpPath) + return writeErr } - return nil + if closeErr != nil { + _ = fs.Remove(tmpPath) + return closeErr + } + + if err := fs.Rename(tmpPath, path); err != nil { + _ = fs.Remove(tmpPath) + return err + } + + return syncDirectoryWithDeps(fs, filepath.Dir(path)) +} + +func copyRecipientFileWithDeps(fs FS, src, dest string, perm os.FileMode) error { + if fs == nil { + fs = osFS{} + } + + in, err := fs.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := fs.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_EXCL, perm) + if err != nil { + return err + } + + copyErr := func() error { + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Chmod(perm); err != nil { + return err + } + return out.Sync() + }() + + closeErr := out.Close() + if copyErr != nil { + _ = fs.Remove(dest) + return copyErr + } + if closeErr != nil { + _ = fs.Remove(dest) + return closeErr + } + + return syncDirectoryWithDeps(fs, filepath.Dir(dest)) +} + +func syncDirectoryWithDeps(fs FS, dir string) error { + if fs == nil { + fs = osFS{} + } + + df, err := fs.Open(dir) + if err != nil { + return fmt.Errorf("open dir %s: %w", dir, err) + } + + syncErr := df.Sync() + closeErr := df.Close() + if syncErr != nil { + if errors.Is(syncErr, syscall.EINVAL) || errors.Is(syncErr, syscall.ENOTSUP) { + return closeErr + } + return fmt.Errorf("sync dir %s: %w", dir, syncErr) + } + return closeErr +} + +func recipientTime(tp TimeProvider) time.Time { + if tp != nil { + return tp.Now() + } + return time.Now() } -// BackupAgeRecipientFile backs up an existing AGE recipient file (if present). +// BackupAgeRecipientFile backs up an existing AGE recipient file (if present) +// without removing the active file. func BackupAgeRecipientFile(path string) error { return backupExistingRecipientFile(path) } diff --git a/internal/orchestrator/encryption_exported_test.go b/internal/orchestrator/encryption_exported_test.go index 096b8f45..980634d9 100644 --- a/internal/orchestrator/encryption_exported_test.go +++ b/internal/orchestrator/encryption_exported_test.go @@ -118,8 +118,19 @@ func TestBackupAgeRecipientFileExported(t *testing.T) { if err != nil || len(matches) != 1 { t.Fatalf("expected backup file, got %v err=%v", matches, err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("original path should have been moved, stat err=%v", err) + original, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + if got := string(original); got != "old" { + t.Fatalf("original content=%q; want %q", got, "old") + } + backup, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatalf("ReadFile(%s): %v", matches[0], err) + } + if got := string(backup); got != "old" { + t.Fatalf("backup content=%q; want %q", got, "old") } } diff --git a/internal/orchestrator/encryption_more_test.go b/internal/orchestrator/encryption_more_test.go index 415c0360..e1dcfa0b 100644 --- a/internal/orchestrator/encryption_more_test.go +++ b/internal/orchestrator/encryption_more_test.go @@ -123,7 +123,7 @@ func TestPrepareAgeRecipients_InteractiveWizardSetsRecipientFile(t *testing.T) { } } -func TestRunAgeSetupWizard_ForceNewRecipientBacksUpExistingFile(t *testing.T) { +func TestRunAgeSetupWizard_ForceNewRecipientAbortKeepsExistingFile(t *testing.T) { tmp := t.TempDir() target := filepath.Join(tmp, "identity", "age", "recipient.txt") if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { @@ -175,21 +175,18 @@ func TestRunAgeSetupWizard_ForceNewRecipientBacksUpExistingFile(t *testing.T) { } matches, err := filepath.Glob(target + ".bak-*") - if err != nil || len(matches) != 1 { - t.Fatalf("expected backup file, got %v err=%v", matches, err) + if err != nil { + t.Fatalf("Glob(%s): %v", target+".bak-*", err) } - - // Ensure original was moved away. - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected original to be moved, stat err=%v", err) + if len(matches) != 0 { + t.Fatalf("expected no backup file on abort, got %v", matches) } - // Ensure the old recipient didn't get replaced during abort. - data, err := os.ReadFile(matches[0]) + data, err := os.ReadFile(target) if err != nil { - t.Fatalf("ReadFile backup: %v", err) + t.Fatalf("ReadFile(%s): %v", target, err) } if strings.TrimSpace(string(data)) != "old" { - t.Fatalf("backup content=%q want=%q", strings.TrimSpace(string(data)), "old") + t.Fatalf("original content=%q want=%q", strings.TrimSpace(string(data)), "old") } } diff --git a/internal/orchestrator/encryption_test.go b/internal/orchestrator/encryption_test.go index 2e5a8144..e46a75b3 100644 --- a/internal/orchestrator/encryption_test.go +++ b/internal/orchestrator/encryption_test.go @@ -173,7 +173,18 @@ func TestBackupExistingRecipientFileCreatesBackup(t *testing.T) { if err != nil || len(matches) != 1 { t.Fatalf("expected backup file, got %v err=%v", matches, err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("original path should have been moved, stat err=%v", err) + original, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + if got := string(original); got != "old" { + t.Fatalf("original content=%q; want %q", got, "old") + } + backup, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatalf("ReadFile(%s): %v", matches[0], err) + } + if got := string(backup); got != "old" { + t.Fatalf("backup content=%q; want %q", got, "old") } } From 0fedcbe302331719f1e9c7c77c3568849b56ccd1 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 11:24:15 +0100 Subject: [PATCH 23/29] fix(restore): extract regular files atomically extractRegularFile now writes regular-file restores to a sibling temp file and renames it into place only after content copy and close succeed. This prevents truncated archive entries from clobbering an existing target or leaving partial files behind on restore failure. Also improves temp-file handling by reducing name collision risk with an atomic sequence, surfacing deferred close errors, and logging unexpected temp cleanup failures. Tests were added/updated to verify success cleanup, failed-copy cleanup, and preservation of a pre-existing target on copy errors.` LiveReview Pre-Commit Check: skipped (iter:2, coverage:48%) --- internal/orchestrator/restore.go | 44 +++++++++--- internal/orchestrator/restore_errors_test.go | 74 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index 73b00e63..69e043df 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -34,6 +34,7 @@ var ( servicePollInterval = 500 * time.Millisecond serviceRetryDelay = 500 * time.Millisecond restoreLogSequence uint64 + restoreTempSequence uint64 restoreGlob = filepath.Glob ) @@ -1598,18 +1599,35 @@ func extractDirectory(target string, header *tar.Header, logger *logging.Logger) } // extractRegularFile extracts a regular file with content and timestamps -func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) error { - // Remove existing file if it exists - _ = restoreFS.Remove(target) +func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header, logger *logging.Logger) (retErr error) { + tmpSeq := atomic.AddUint64(&restoreTempSequence, 1) + tmpPath := fmt.Sprintf("%s.proxsave.tmp.%d.%d", target, nowRestore().UnixNano(), tmpSeq) + appendDeferredErr := func(prefix string, err error) { + if err == nil { + return + } + wrapped := fmt.Errorf("%s: %w", prefix, err) + if retErr == nil { + retErr = wrapped + return + } + retErr = errors.Join(retErr, wrapped) + } - // Create the file - outFile, err := restoreFS.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + // Write to a sibling temp file first so a truncated archive entry cannot clobber + // an existing target before the content is fully copied and closed. + outFile, err := restoreFS.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { return fmt.Errorf("create file: %w", err) } defer func() { if outFile != nil { - _ = outFile.Close() + appendDeferredErr("close file", outFile.Close()) + } + if tmpPath != "" { + if err := restoreFS.Remove(tmpPath); err != nil && !errors.Is(err, os.ErrNotExist) && logger != nil { + logger.Debug("Failed to remove temp file %s: %v", tmpPath, err) + } } }() @@ -1618,23 +1636,29 @@ func extractRegularFile(tarReader *tar.Reader, target string, header *tar.Header return fmt.Errorf("write file content: %w", err) } - // Close before setting attributes + // Close before setting attributes and renaming into place. if err := outFile.Close(); err != nil { outFile = nil return fmt.Errorf("close file: %w", err) } outFile = nil - // Set ownership - if err := os.Chown(target, header.Uid, header.Gid); err != nil { + // Set ownership on the temp file before replacing the target so failures do not + // leave the final path in a partially restored state. + if err := os.Chown(tmpPath, header.Uid, header.Gid); err != nil { logger.Debug("Failed to chown file %s: %v", target, err) } // Set permissions explicitly - if err := os.Chmod(target, os.FileMode(header.Mode)); err != nil { + if err := os.Chmod(tmpPath, os.FileMode(header.Mode)); err != nil { return fmt.Errorf("chmod file: %w", err) } + if err := restoreFS.Rename(tmpPath, target); err != nil { + return fmt.Errorf("replace file: %w", err) + } + tmpPath = "" + // Set timestamps (mtime, atime, ctime via syscall) if err := setTimestamps(target, header); err != nil { logger.Debug("Failed to set timestamps on file %s: %v", target, err) diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go index 96dcd0e5..a1da3a26 100644 --- a/internal/orchestrator/restore_errors_test.go +++ b/internal/orchestrator/restore_errors_test.go @@ -962,6 +962,73 @@ func TestExtractRegularFile_CopyFails(t *testing.T) { if closeErr := trackingFS.lastOpened.Close(); !errors.Is(closeErr, os.ErrClosed) { t.Fatalf("output file close after copy failure = %v, want ErrClosed", closeErr) } + tempMatches, err := filepath.Glob(target + ".proxsave.tmp.*") + if err != nil { + t.Fatalf("glob temp files: %v", err) + } + if len(tempMatches) != 0 { + t.Fatalf("temporary files should be removed after copy failure, found %v", tempMatches) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("target %s should not exist after copy failure, stat err=%v", target, err) + } +} + +func TestExtractRegularFile_CopyFailsPreservesExistingTarget(t *testing.T) { + origFS := restoreFS + t.Cleanup(func() { restoreFS = origFS }) + restoreFS = osFS{} + + dir := t.TempDir() + target := filepath.Join(dir, "testfile.txt") + if err := os.WriteFile(target, []byte("keep me"), 0o600); err != nil { + t.Fatalf("seed target: %v", err) + } + + header := &tar.Header{ + Name: "testfile.txt", + Mode: 0o644, + Size: 100, + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + _ = tw.WriteHeader(header) + _, _ = tw.Write([]byte("short")) + tw.Close() + + tr := tar.NewReader(&buf) + _, _ = tr.Next() + + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + err := extractRegularFile(tr, target, header, logger) + if err == nil || !strings.Contains(err.Error(), "write file content") { + t.Fatalf("expected io.Copy error, got: %v", err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read preserved target: %v", err) + } + if string(data) != "keep me" { + t.Fatalf("target content = %q, want preserved original", string(data)) + } + + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat preserved target: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("preserved target mode = %o, want %o", info.Mode().Perm(), 0o600) + } + + tempMatches, err := filepath.Glob(target + ".proxsave.tmp.*") + if err != nil { + t.Fatalf("glob temp files: %v", err) + } + if len(tempMatches) != 0 { + t.Fatalf("temporary files should be removed after copy failure, found %v", tempMatches) + } } // -------------------------------------------------------------------------- @@ -1685,6 +1752,13 @@ func TestExtractRegularFile_Success(t *testing.T) { if closeErr := trackingFS.lastOpened.Close(); !errors.Is(closeErr, os.ErrClosed) { t.Fatalf("output file close after success = %v, want ErrClosed", closeErr) } + tempMatches, err := filepath.Glob(target + ".proxsave.tmp.*") + if err != nil { + t.Fatalf("glob temp files: %v", err) + } + if len(tempMatches) != 0 { + t.Fatalf("temporary files should be removed after success, found %v", tempMatches) + } } // -------------------------------------------------------------------------- From 0e779d3cfa69da02adaf1cecfa565aa42c5a1740 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 11:45:43 +0100 Subject: [PATCH 24/29] fix(config): align notification defaults and legacy env compatibility Align the notification configuration with the shipped template and documentation by making EMAIL_ENABLED default to false when unset. Restore compatibility with legacy *_ENABLE notification flags during runtime loading and legacy env migration, add the missing Gotify environment overrides, and update tests and docs to reflect the corrected behavior. LiveReview Pre-Commit Check: skipped (iter:2, coverage:67%) --- docs/BACKUP_ENV_MAPPING.md | 4 ++ docs/CONFIGURATION.md | 2 + docs/MIGRATION_GUIDE.md | 6 +- internal/config/config.go | 16 ++--- internal/config/config_test.go | 101 +++++++++++++++++++++++++++++- internal/config/migration.go | 4 ++ internal/config/migration_test.go | 51 +++++++++++++++ 7 files changed, 173 insertions(+), 11 deletions(-) diff --git a/docs/BACKUP_ENV_MAPPING.md b/docs/BACKUP_ENV_MAPPING.md index e10ec583..001b21d6 100644 --- a/docs/BACKUP_ENV_MAPPING.md +++ b/docs/BACKUP_ENV_MAPPING.md @@ -116,11 +116,15 @@ ENABLE_SECONDARY_BACKUP = RENAMED(SECONDARY_ENABLED) ✅ FULL_SECURITY_CHECK = RENAMED(SECURITY_CHECK_ENABLED) ✅ LOCAL_BACKUP_PATH = RENAMED(BACKUP_PATH) ✅ LOCAL_LOG_PATH = RENAMED(LOG_PATH) ✅ +EMAIL_ENABLE = RENAMED(EMAIL_ENABLED) ✅ +GOTIFY_ENABLE = RENAMED(GOTIFY_ENABLED) ✅ PROMETHEUS_ENABLED = RENAMED(METRICS_ENABLED) ✅ PROMETHEUS_TEXTFILE_DIR = RENAMED(METRICS_PATH) ✅ PXAR_INCLUDE_PATTERN = RENAMED(PXAR_FILE_INCLUDE_PATTERN) ✅ RCLONE_REMOTE = RENAMED(CLOUD_REMOTE) ✅ SECONDARY_BACKUP_PATH = RENAMED(SECONDARY_PATH) ✅ +TELEGRAM_ENABLE = RENAMED(TELEGRAM_ENABLED) ✅ +WEBHOOK_ENABLE = RENAMED(WEBHOOK_ENABLED) ✅ ### Semantic changes ⚠️ *Require manual value conversion* diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6ae76692..cba8da5a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -835,6 +835,8 @@ EMAIL_RECIPIENT= # e.g., "admin@example.com" EMAIL_FROM=no-reply@proxmox.tis24.it ``` +If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EMAIL_ENABLE` is still accepted during migration and runtime loading. + **Delivery methods**: - **relay**: Uses cloud relay (outbound HTTPS) - **sendmail**: Uses `/usr/sbin/sendmail` (requires a working local MTA, e.g. postfix) diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 9854a545..0c58a3da 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -242,7 +242,7 @@ These old Bash variable names **still work** in Go (automatic fallback): | `LOCAL_BACKUP_PATH` | `BACKUP_PATH` | ✅ Auto-fallback | | `ENABLE_CLOUD_BACKUP` | `CLOUD_ENABLED` | ✅ Auto-fallback | | `PROMETHEUS_ENABLED` | `METRICS_ENABLED` | ✅ Auto-fallback | -| `PROMETHEUS_PATH` | `METRICS_PATH` | ✅ Auto-fallback | +| `PROMETHEUS_TEXTFILE_DIR` | `METRICS_PATH` | ✅ Auto-fallback | | `TELEGRAM_ENABLE` | `TELEGRAM_ENABLED` | ✅ Auto-fallback | | `EMAIL_ENABLE` | `EMAIL_ENABLED` | ✅ Auto-fallback | | `GOTIFY_ENABLE` | `GOTIFY_ENABLED` | ✅ Auto-fallback | @@ -251,6 +251,8 @@ These old Bash variable names **still work** in Go (automatic fallback): **What this means**: You can keep using old variable names, and Go will automatically read them. However, **it's recommended to update to new names** for clarity and future compatibility. +For email notifications, if `EMAIL_ENABLED` is omitted entirely, the runtime default is `false`, matching the template. + ### Variables Requiring Conversion #### 1. Storage Thresholds (SEMANTIC CHANGE) @@ -445,7 +447,7 @@ TELEGRAM_ENABLE=true TELEGRAM_ENABLED=true ``` -**Note**: Automatic fallback should handle this, but explicitly updating is cleaner. +**Note**: Automatic fallback handles the legacy `_ENABLE` aliases, but explicitly updating is cleaner. For email, leaving `EMAIL_ENABLED` unset now keeps notifications disabled by default. --- diff --git a/internal/config/config.go b/internal/config/config.go index c2918df7..fa8211f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -307,10 +307,12 @@ func (c *Config) loadEnvOverrides() { "MAX_LOCAL_BACKUPS", "MAX_SECONDARY_BACKUPS", "MAX_CLOUD_BACKUPS", "RETENTION_DAILY", "RETENTION_WEEKLY", "RETENTION_MONTHLY", "RETENTION_YEARLY", "BUNDLE_ASSOCIATED_FILES", "ENCRYPT_ARCHIVE", "AGE_RECIPIENT", "AGE_RECIPIENT_FILE", - "TELEGRAM_ENABLED", "BOT_TELEGRAM_TYPE", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", - "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_SENDMAIL", + "TELEGRAM_ENABLE", "TELEGRAM_ENABLED", "BOT_TELEGRAM_TYPE", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", + "EMAIL_ENABLE", "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_SENDMAIL", "EMAIL_RECIPIENT", "EMAIL_FROM", - "WEBHOOK_ENABLED", "WEBHOOK_ENDPOINTS", "WEBHOOK_FORMAT", "WEBHOOK_TIMEOUT", + "GOTIFY_ENABLE", "GOTIFY_ENABLED", "GOTIFY_SERVER_URL", "GOTIFY_TOKEN", + "GOTIFY_PRIORITY_SUCCESS", "GOTIFY_PRIORITY_WARNING", "GOTIFY_PRIORITY_FAILURE", + "WEBHOOK_ENABLE", "WEBHOOK_ENABLED", "WEBHOOK_ENDPOINTS", "WEBHOOK_FORMAT", "WEBHOOK_TIMEOUT", "WEBHOOK_MAX_RETRIES", "WEBHOOK_RETRY_DELAY", "METRICS_ENABLED", "METRICS_PATH", "SECURITY_CHECK_ENABLED", "AUTO_UPDATE_HASHES", "AUTO_FIX_PERMISSIONS", @@ -586,20 +588,20 @@ func (c *Config) parseRetentionSettings() { } func (c *Config) parseNotificationSettings() { - c.TelegramEnabled = c.getBool("TELEGRAM_ENABLED", false) + c.TelegramEnabled = c.getBoolWithFallback([]string{"TELEGRAM_ENABLED", "TELEGRAM_ENABLE"}, false) c.TelegramBotType = c.getString("BOT_TELEGRAM_TYPE", "centralized") c.TelegramBotToken = c.getString("TELEGRAM_BOT_TOKEN", "") c.TelegramChatID = c.getString("TELEGRAM_CHAT_ID", "") c.TelegramServerAPIHost = "https://bot.tis24.it:1443" c.ServerID = "" - c.EmailEnabled = c.getBool("EMAIL_ENABLED", true) + c.EmailEnabled = c.getBoolWithFallback([]string{"EMAIL_ENABLED", "EMAIL_ENABLE"}, false) c.EmailDeliveryMethod = c.getString("EMAIL_DELIVERY_METHOD", "relay") c.EmailFallbackSendmail = c.getBool("EMAIL_FALLBACK_SENDMAIL", true) c.EmailRecipient = c.getString("EMAIL_RECIPIENT", "") c.EmailFrom = c.getString("EMAIL_FROM", "no-reply@proxmox.tis24.it") - c.GotifyEnabled = c.getBool("GOTIFY_ENABLED", false) + c.GotifyEnabled = c.getBoolWithFallback([]string{"GOTIFY_ENABLED", "GOTIFY_ENABLE"}, false) c.GotifyServerURL = strings.TrimSpace(c.getString("GOTIFY_SERVER_URL", "")) c.GotifyToken = strings.TrimSpace(c.getString("GOTIFY_TOKEN", "")) c.GotifyPrioritySuccess = c.ensurePositiveInt("GOTIFY_PRIORITY_SUCCESS", 2) @@ -613,7 +615,7 @@ func (c *Config) parseNotificationSettings() { c.WorkerMaxRetries = 2 c.WorkerRetryDelay = 2 - c.WebhookEnabled = c.getBool("WEBHOOK_ENABLED", false) + c.WebhookEnabled = c.getBoolWithFallback([]string{"WEBHOOK_ENABLED", "WEBHOOK_ENABLE"}, false) c.WebhookDefaultFormat = c.getString("WEBHOOK_FORMAT", "generic") c.WebhookTimeout = c.getInt("WEBHOOK_TIMEOUT", 30) c.WebhookMaxRetries = c.getInt("WEBHOOK_MAX_RETRIES", 3) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b3611eef..4a2f42a8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -296,7 +296,7 @@ LOG_PATH=/test/log SECONDARY_ENABLED=false SECONDARY_PATH=remote:path ` - if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { t.Fatalf("Failed to create config file: %v", err) } @@ -317,7 +317,7 @@ LOG_PATH=/test/log SECONDARY_ENABLED=false SECONDARY_LOG_PATH=remote:/logs ` - if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { t.Fatalf("Failed to create config file: %v", err) } @@ -469,11 +469,50 @@ func TestConfigDefaults(t *testing.T) { t.Errorf("Default LocalRetentionDays = %d; want 7", cfg.LocalRetentionDays) } + if cfg.EmailEnabled { + t.Error("Expected default EmailEnabled to be false") + } + if cfg.BaseDir != "/defaults/base" { t.Errorf("Default BaseDir = %q; want %q", cfg.BaseDir, "/defaults/base") } } +func TestLoadConfigNotificationLegacyEnableAliases(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "legacy_notifications.env") + + content := `TELEGRAM_ENABLE=true +EMAIL_ENABLE=true +GOTIFY_ENABLE=true +WEBHOOK_ENABLE=true +` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("Failed to create test config: %v", err) + } + + cleanup := setBaseDirEnv(t, "/legacy/notifications/base") + defer cleanup() + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if !cfg.TelegramEnabled { + t.Error("Expected TelegramEnabled to be true via TELEGRAM_ENABLE") + } + if !cfg.EmailEnabled { + t.Error("Expected EmailEnabled to be true via EMAIL_ENABLE") + } + if !cfg.GotifyEnabled { + t.Error("Expected GotifyEnabled to be true via GOTIFY_ENABLE") + } + if !cfg.WebhookEnabled { + t.Error("Expected WebhookEnabled to be true via WEBHOOK_ENABLE") + } +} + func TestLoadConfigBaseDirFromConfig(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "base_dir.env") @@ -734,6 +773,64 @@ BACKUP_PATH=/fromfile } } +func TestLoadEnvOverridesOverridesNotificationFields(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "notification_env_override.env") + content := `GOTIFY_SERVER_URL=https://from-file.example +GOTIFY_TOKEN=file-token +GOTIFY_PRIORITY_SUCCESS=2 +WEBHOOK_ENDPOINTS=file_hook +WEBHOOK_FORMAT=generic +WEBHOOK_TIMEOUT=30 +` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + overrides := map[string]string{ + "GOTIFY_SERVER_URL": "https://from-env.example", + "GOTIFY_TOKEN": "env-token", + "GOTIFY_PRIORITY_SUCCESS": "9", + "WEBHOOK_ENDPOINTS": "env_hook", + "WEBHOOK_FORMAT": "slack", + "WEBHOOK_TIMEOUT": "45", + } + for key, value := range overrides { + if err := os.Setenv(key, value); err != nil { + t.Fatalf("failed to set env %s: %v", key, err) + } + } + defer func() { + for key := range overrides { + _ = os.Unsetenv(key) + } + }() + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.GotifyServerURL != "https://from-env.example" { + t.Fatalf("GotifyServerURL = %q; want https://from-env.example", cfg.GotifyServerURL) + } + if cfg.GotifyToken != "env-token" { + t.Fatalf("GotifyToken = %q; want env-token", cfg.GotifyToken) + } + if cfg.GotifyPrioritySuccess != 9 { + t.Fatalf("GotifyPrioritySuccess = %d; want 9", cfg.GotifyPrioritySuccess) + } + if len(cfg.WebhookEndpointNames) != 1 || cfg.WebhookEndpointNames[0] != "env_hook" { + t.Fatalf("WebhookEndpointNames = %#v; want [env_hook]", cfg.WebhookEndpointNames) + } + if cfg.WebhookDefaultFormat != "slack" { + t.Fatalf("WebhookDefaultFormat = %q; want slack", cfg.WebhookDefaultFormat) + } + if cfg.WebhookTimeout != 45 { + t.Fatalf("WebhookTimeout = %d; want 45", cfg.WebhookTimeout) + } +} + func TestConfigFallbackHelpers(t *testing.T) { cfg := &Config{ raw: map[string]string{ diff --git a/internal/config/migration.go b/internal/config/migration.go index d48b9b7f..cf5c2f5f 100644 --- a/internal/config/migration.go +++ b/internal/config/migration.go @@ -53,6 +53,10 @@ var migrationRules = map[string]migrationRule{ "BACKUP_NETWORK_CONFIGS": {LegacyKeys: []string{"BACKUP_NETWORK_CONFIG"}}, "BACKUP_REMOTE_CONFIGS": {LegacyKeys: []string{"BACKUP_REMOTE_CFG"}}, "BACKUP_CRON_JOBS": {LegacyKeys: []string{"BACKUP_CRONTABS"}}, + "TELEGRAM_ENABLED": {LegacyKeys: []string{"TELEGRAM_ENABLE"}}, + "EMAIL_ENABLED": {LegacyKeys: []string{"EMAIL_ENABLE"}}, + "GOTIFY_ENABLED": {LegacyKeys: []string{"GOTIFY_ENABLE"}}, + "WEBHOOK_ENABLED": {LegacyKeys: []string{"WEBHOOK_ENABLE"}}, "METRICS_ENABLED": {LegacyKeys: []string{"PROMETHEUS_ENABLED"}}, "METRICS_PATH": {LegacyKeys: []string{"PROMETHEUS_TEXTFILE_DIR"}}, "PXAR_FILE_INCLUDE_PATTERN": {LegacyKeys: []string{"PXAR_INCLUDE_PATTERN"}}, diff --git a/internal/config/migration_test.go b/internal/config/migration_test.go index eb300652..5457aaa4 100644 --- a/internal/config/migration_test.go +++ b/internal/config/migration_test.go @@ -426,6 +426,57 @@ NEW_FLAG=true }) } +func TestPlanLegacyEnvMigrationMapsLegacyNotificationEnableAliases(t *testing.T) { + template := `TELEGRAM_ENABLED=false +EMAIL_ENABLED=false +GOTIFY_ENABLED=false +WEBHOOK_ENABLED=false +` + withTemplate(t, template, func() { + tmpDir := t.TempDir() + legacyPath := filepath.Join(tmpDir, "legacy.env") + outputPath := filepath.Join(tmpDir, "backup.env") + + legacyContent := strings.Join([]string{ + "TELEGRAM_ENABLE=true", + "EMAIL_ENABLE=true", + "GOTIFY_ENABLE=true", + "WEBHOOK_ENABLE=true", + "", + }, "\n") + if err := os.WriteFile(legacyPath, []byte(legacyContent), 0o600); err != nil { + t.Fatalf("failed to write legacy env: %v", err) + } + + _, merged, err := PlanLegacyEnvMigration(legacyPath, outputPath) + if err != nil { + t.Fatalf("PlanLegacyEnvMigration returned error: %v", err) + } + + for _, want := range []string{ + "TELEGRAM_ENABLED=true", + "EMAIL_ENABLED=true", + "GOTIFY_ENABLED=true", + "WEBHOOK_ENABLED=true", + } { + if !strings.Contains(merged, want) { + t.Fatalf("expected migrated config to contain %q:\n%s", want, merged) + } + } + + for _, legacyKey := range []string{ + "TELEGRAM_ENABLE=", + "EMAIL_ENABLE=", + "GOTIFY_ENABLE=", + "WEBHOOK_ENABLE=", + } { + if strings.Contains(merged, legacyKey) { + t.Fatalf("expected migrated config to replace legacy key %q:\n%s", legacyKey, merged) + } + } + }) +} + func TestInvertBoolAndBoolToString(t *testing.T) { tests := []struct { in string From 10fa090590cd8c2dea6a8a819379fd04f9cce293 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 12:08:20 +0100 Subject: [PATCH 25/29] storage: bound cloud retry backoff and remove overflow-prone shifts Replace the shift-based exponential backoff used by cloud remote checks and upload retries with a bounded retry schedule. This keeps the current retry timings for normal cases (2s, 4s, 8s, 16s) while capping larger attempts at 30s and removing the overflow-prone `1<= len(cloudRetryBackoffSchedule) { + return cloudRetryBackoffSchedule[len(cloudRetryBackoffSchedule)-1] + } + return cloudRetryBackoffSchedule[index] +} + // NewCloudStorage creates a new cloud storage instance func NewCloudStorage(cfg *config.Config, logger *logging.Logger) (*CloudStorage, error) { // Normalize CloudRemote and CloudRemotePath into: @@ -324,8 +346,8 @@ func (c *CloudStorage) checkRemoteAccessible(ctx context.Context) error { } if attempt < maxAttempts { - // Exponential backoff: 2s, 4s, 8s, ... - waitTime := time.Duration(1< Date: Wed, 18 Mar 2026 12:19:15 +0100 Subject: [PATCH 26/29] Fix binary integrity validation flow Use os.Lstat in verifyBinaryIntegrity to validate the executable path before opening it, so symlinked executables are rejected correctly. Open the file only when computing the checksum, removing the unused rewind path and making the integrity check flow clearer. Update the related tests to cover the symlink case and the stat failure path. LiveReview Pre-Commit Check: skipped (iter:2, coverage:6%) --- internal/security/security.go | 19 +++++++------------ internal/security/security_test.go | 14 ++++++++------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/internal/security/security.go b/internal/security/security.go index 9edd76ad..f1d39fb1 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -330,14 +330,7 @@ func (c *Checker) verifyBinaryIntegrity() { return } - f, err := os.Open(c.execPath) - if err != nil { - c.addError("Cannot open executable %s: %v", c.execPath, err) - return - } - defer f.Close() - - info, err := f.Stat() + info, err := os.Lstat(c.execPath) if err != nil { c.addError("Cannot stat executable %s: %v", c.execPath, err) return @@ -351,14 +344,16 @@ func (c *Checker) verifyBinaryIntegrity() { c.ensureOwnershipAndPerm(c.execPath, info, 0o700, fmt.Sprintf("Executable %s", c.execPath)) hashFile := c.execPath + ".md5" - currentHash, err := checksumReader(f) + f, err := os.Open(c.execPath) if err != nil { - c.addWarning("Unable to calculate hash for %s: %v", c.execPath, err) + c.addError("Cannot open executable %s: %v", c.execPath, err) return } + defer f.Close() - if _, err := f.Seek(0, io.SeekStart); err != nil { - c.addWarning("Unable to rewind file for %s: %v", c.execPath, err) + currentHash, err := checksumReader(f) + if err != nil { + c.addWarning("Unable to calculate hash for %s: %v", c.execPath, err) return } diff --git a/internal/security/security_test.go b/internal/security/security_test.go index 09998d0e..d818dfe5 100644 --- a/internal/security/security_test.go +++ b/internal/security/security_test.go @@ -1531,8 +1531,6 @@ func TestVerifyBinaryIntegritySymlinkError(t *testing.T) { t.Fatal(err) } - // Note: The current implementation checks Mode()&os.ModeSymlink after os.Open - // which doesn't detect symlinks properly. This test documents the behavior. checker := &Checker{ logger: newSecurityTestLogger(), cfg: &config.Config{AutoUpdateHashes: true}, @@ -1542,8 +1540,12 @@ func TestVerifyBinaryIntegritySymlinkError(t *testing.T) { checker.verifyBinaryIntegrity() - // The function opens the file and then stats - symlink is followed by Open - // This is expected behavior given the current implementation + if !containsIssue(checker.result, "is a symlink") { + t.Fatalf("expected symlink error, issues=%+v", checker.result.Issues) + } + if _, err := os.Stat(symlinkFile + ".md5"); err == nil { + t.Fatal("hash file should not be created for symlink executable") + } } func TestVerifyBinaryIntegrityOpenError(t *testing.T) { @@ -1556,8 +1558,8 @@ func TestVerifyBinaryIntegrityOpenError(t *testing.T) { checker.verifyBinaryIntegrity() - if !containsIssue(checker.result, "Cannot open executable") { - t.Errorf("expected error about cannot open executable, got %+v", checker.result.Issues) + if !containsIssue(checker.result, "Cannot stat executable") { + t.Errorf("expected error about cannot stat executable, got %+v", checker.result.Issues) } } From 1266a0b2c98e4f57e0f9c02b52ea754f2484646d Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 12:53:46 +0100 Subject: [PATCH 27/29] Fix GFS daily minimum normalization across retention paths Ensure GFS retention consistently normalizes RETENTION_DAILY<=0 to an effective daily minimum of 1 across classification, storage backends, and runtime summaries. This prevents current-period backups from being misclassified or deleted when daily retention is unset/zero, aligns internal comments and tests with the documented contract, and separates pure GFS config normalization from the logging wrapper used in operational paths. LiveReview Pre-Commit Check: skipped (iter:2, coverage:67%) --- cmd/proxsave/runtime_helpers.go | 3 ++ cmd/proxsave/runtime_helpers_more_test.go | 12 ++++++-- internal/config/config.go | 2 +- internal/storage/cloud.go | 1 + internal/storage/cloud_test.go | 13 ++++---- internal/storage/local.go | 1 + internal/storage/retention.go | 18 +++++------- internal/storage/retention_normalize.go | 16 ++++++++-- internal/storage/retention_test.go | 36 ++++++++++++++++------- internal/storage/secondary.go | 1 + internal/storage/storage_test.go | 17 +++++++++++ 11 files changed, 84 insertions(+), 36 deletions(-) diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go index 2cb4f482..0c8e8794 100644 --- a/cmd/proxsave/runtime_helpers.go +++ b/cmd/proxsave/runtime_helpers.go @@ -344,6 +344,9 @@ func fetchStorageStats(ctx context.Context, backend storage.Storage, logger *log func formatStorageInitSummary(name string, cfg *config.Config, location storage.BackupLocation, stats *storage.StorageStats, backups []*types.BackupMetadata) string { retentionConfig := storage.NewRetentionConfigFromConfig(cfg, location) + if retentionConfig.Policy == "gfs" { + retentionConfig = storage.EffectiveGFSRetentionConfig(retentionConfig) + } if stats == nil { reason := "unable to gather stats" diff --git a/cmd/proxsave/runtime_helpers_more_test.go b/cmd/proxsave/runtime_helpers_more_test.go index 2573e8ed..f71a813b 100644 --- a/cmd/proxsave/runtime_helpers_more_test.go +++ b/cmd/proxsave/runtime_helpers_more_test.go @@ -278,15 +278,15 @@ func TestFormatStorageInitSummary(t *testing.T) { cfgGFS := &config.Config{ RetentionPolicy: "gfs", - RetentionDaily: 1, - RetentionWeekly: 0, + RetentionDaily: 0, + RetentionWeekly: 1, RetentionMonthly: 0, RetentionYearly: -1, } now := time.Now() backups := []*types.BackupMetadata{ {Timestamp: now.Add(-1 * time.Hour)}, - {Timestamp: now.Add(-2 * time.Hour)}, + {Timestamp: now.Add(-8 * 24 * time.Hour)}, } stats := &storage.StorageStats{TotalBackups: 2} @@ -294,6 +294,12 @@ func TestFormatStorageInitSummary(t *testing.T) { if !bytes.Contains([]byte(summary), []byte("Kept (est.):")) { t.Fatalf("expected GFS summary to include retention estimates, got: %s", summary) } + if !bytes.Contains([]byte(summary), []byte("Daily: 1/1")) { + t.Fatalf("expected GFS summary to normalize daily tier, got: %s", summary) + } + if !bytes.Contains([]byte(summary), []byte("Weekly: 1/1")) { + t.Fatalf("expected GFS summary to keep one weekly backup, got: %s", summary) + } } func TestCleanupAfterRun(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index fa8211f0..e5f684c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,7 +113,7 @@ type Config struct { // GFS (Grandfather-Father-Son) retention settings // If ANY of these is > 0, GFS retention is enabled (overrides simple retention) - RetentionDaily int // Keep backups from last N days (0 = disabled) + RetentionDaily int // Keep the GFS daily tier; in GFS mode values <= 0 are normalized to 1 RetentionWeekly int // Keep N weekly backups, one per week (0 = disabled) RetentionMonthly int // Keep N monthly backups, one per month (0 = disabled) RetentionYearly int // Keep N yearly backups, one per year (0 = keep all yearly) diff --git a/internal/storage/cloud.go b/internal/storage/cloud.go index 1282b82e..4853ded4 100644 --- a/internal/storage/cloud.go +++ b/internal/storage/cloud.go @@ -1459,6 +1459,7 @@ func (c *CloudStorage) ApplyRetention(ctx context.Context, config RetentionConfi // applyGFSRetention applies GFS (Grandfather-Father-Son) retention policy func (c *CloudStorage) applyGFSRetention(ctx context.Context, backups []*types.BackupMetadata, config RetentionConfig) (int, error) { + config = EffectiveGFSRetentionConfig(config) c.logger.Debug("Applying GFS retention policy (daily=%d, weekly=%d, monthly=%d, yearly=%d)", config.Daily, config.Weekly, config.Monthly, config.Yearly) diff --git a/internal/storage/cloud_test.go b/internal/storage/cloud_test.go index b5cbf21f..429edac3 100644 --- a/internal/storage/cloud_test.go +++ b/internal/storage/cloud_test.go @@ -916,7 +916,7 @@ func TestRemoteDirRef(t *testing.T) { } } -func TestCloudStorageApplyGFSRetentionDeletesMarkedBackups(t *testing.T) { +func TestCloudStorageApplyGFSRetentionKeepsMinimumDailyBackup(t *testing.T) { cfg := &config.Config{ CloudEnabled: true, CloudRemote: "remote", @@ -934,7 +934,6 @@ func TestCloudStorageApplyGFSRetentionDeletesMarkedBackups(t *testing.T) { queue := &commandQueue{ t: t, queue: []queuedResponse{ - {name: "rclone", args: []string{"deletefile", "remote:alpha-backup.tar.zst"}}, {name: "rclone", args: []string{"deletefile", "remote:beta-backup.tar.zst"}}, }, } @@ -951,15 +950,15 @@ func TestCloudStorageApplyGFSRetentionDeletesMarkedBackups(t *testing.T) { if err != nil { t.Fatalf("applyGFSRetention() error = %v", err) } - if deleted != 2 { - t.Fatalf("applyGFSRetention() deleted = %d, want 2", deleted) + if deleted != 1 { + t.Fatalf("applyGFSRetention() deleted = %d, want 1", deleted) } - if len(queue.calls) != 2 { - t.Fatalf("expected 2 delete commands, got %d", len(queue.calls)) + if len(queue.calls) != 1 { + t.Fatalf("expected 1 delete command, got %d", len(queue.calls)) } summary := cs.LastRetentionSummary() - if summary.BackupsDeleted != 2 || summary.BackupsRemaining != 0 { + if summary.BackupsDeleted != 1 || summary.BackupsRemaining != 1 { t.Fatalf("unexpected retention summary: %+v", summary) } } diff --git a/internal/storage/local.go b/internal/storage/local.go index f6bbc421..b9cc03f9 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -461,6 +461,7 @@ func (l *LocalStorage) ApplyRetention(ctx context.Context, config RetentionConfi // applyGFSRetention applies GFS (Grandfather-Father-Son) retention policy func (l *LocalStorage) applyGFSRetention(ctx context.Context, backups []*types.BackupMetadata, config RetentionConfig) (int, error) { + config = EffectiveGFSRetentionConfig(config) l.logger.Debug("Applying GFS retention policy (daily=%d, weekly=%d, monthly=%d, yearly=%d)", config.Daily, config.Weekly, config.Monthly, config.Yearly) diff --git a/internal/storage/retention.go b/internal/storage/retention.go index 2939bb85..38bbf3b1 100644 --- a/internal/storage/retention.go +++ b/internal/storage/retention.go @@ -73,6 +73,7 @@ func ClassifyBackupsGFS(backups []*types.BackupMetadata, config RetentionConfig) if len(backups) == 0 { return make(map[*types.BackupMetadata]RetentionCategory) } + config = EffectiveGFSRetentionConfig(config) // Sort by timestamp descending (newest first) sort.Slice(backups, func(i, j int) bool { @@ -87,20 +88,15 @@ func ClassifyBackupsGFS(backups []*types.BackupMetadata, config RetentionConfig) // 1. DAILY: Keep the last N backups (newest first) dailyLimit := config.Daily - if dailyLimit < 0 { - dailyLimit = 0 - } dailyCount := 0 dailyCutIndex := len(backups) - if dailyLimit > 0 { - for i, b := range backups { - if dailyCount >= dailyLimit { - dailyCutIndex = i - break - } - classification[b] = CategoryDaily - dailyCount++ + for i, b := range backups { + if dailyCount >= dailyLimit { + dailyCutIndex = i + break } + classification[b] = CategoryDaily + dailyCount++ } if dailyCount < dailyLimit { dailyCutIndex = len(backups) diff --git a/internal/storage/retention_normalize.go b/internal/storage/retention_normalize.go index fa86cf3b..3c7fb91b 100644 --- a/internal/storage/retention_normalize.go +++ b/internal/storage/retention_normalize.go @@ -4,6 +4,17 @@ import ( "github.com/tis24dev/proxsave/internal/logging" ) +// EffectiveGFSRetentionConfig returns the effective GFS configuration without side effects. +// It applies the same value normalization used by retention execution paths, but does not log. +func EffectiveGFSRetentionConfig(cfg RetentionConfig) RetentionConfig { + effective := cfg + if effective.Daily <= 0 { + effective.Daily = 1 + } + + return effective +} + // NormalizeGFSRetentionConfig applies the required adjustments to the GFS configuration // before running retention. Currently: // - ensures the DAILY tier is at least 1 (minimum accepted value) @@ -14,12 +25,11 @@ func NormalizeGFSRetentionConfig(logger *logging.Logger, backendName string, cfg return cfg } - effective := cfg - if effective.Daily <= 0 { + effective := EffectiveGFSRetentionConfig(cfg) + if effective.Daily != cfg.Daily { if logger != nil { logger.Info("%s: RETENTION_DAILY is %d or not set, enforcing minimum of 1 daily backup", backendName, cfg.Daily) } - effective.Daily = 1 } return effective diff --git a/internal/storage/retention_test.go b/internal/storage/retention_test.go index 73337fad..d761f2fe 100644 --- a/internal/storage/retention_test.go +++ b/internal/storage/retention_test.go @@ -192,24 +192,38 @@ func TestClassifyBackupsGFS_DailyOnly(t *testing.T) { func TestClassifyBackupsGFS_ZeroDaily(t *testing.T) { now := time.Now() backups := []*types.BackupMetadata{ - {Timestamp: now.Add(-24 * time.Hour)}, - {Timestamp: now.Add(-48 * time.Hour)}, + {Timestamp: now.Add(-1 * time.Hour)}, + {Timestamp: now.Add(-8 * 24 * time.Hour)}, + {Timestamp: now.Add(-15 * 24 * time.Hour)}, } config := RetentionConfig{ Daily: 0, Weekly: 1, Monthly: 0, - Yearly: 0, + Yearly: -1, } classification := ClassifyBackupsGFS(backups, config) stats := GetRetentionStats(classification) - // With Daily=0, backups should go to weekly/monthly/yearly or delete - if stats[CategoryDaily] != 0 { - t.Errorf("Expected 0 daily backups with Daily=0, got %d", stats[CategoryDaily]) + // In GFS, Daily=0 is normalized to 1 to ensure the current period is protected. + if stats[CategoryDaily] != 1 { + t.Errorf("Expected 1 daily backup with Daily=0, got %d", stats[CategoryDaily]) + } + if stats[CategoryWeekly] != 1 { + t.Errorf("Expected 1 weekly backup with Daily=0, got %d", stats[CategoryWeekly]) + } + if stats[CategoryDelete] != 1 { + t.Errorf("Expected 1 backup marked for deletion with Daily=0, got %d", stats[CategoryDelete]) + } + + if classification[backups[0]] != CategoryDaily { + t.Errorf("Expected newest backup to be daily with Daily=0, got %v", classification[backups[0]]) + } + if classification[backups[1]] != CategoryWeekly { + t.Errorf("Expected previous-week backup to be weekly with Daily=0, got %v", classification[backups[1]]) } } @@ -439,7 +453,7 @@ func TestClassifyBackupsGFS_NegativeDaily(t *testing.T) { } config := RetentionConfig{ - Daily: -5, // Negative should be treated as 0 + Daily: -5, // Negative values are normalized to the minimum daily tier. Weekly: 0, Monthly: 0, Yearly: -1, // Disable yearly retention so older-year backups aren't implicitly kept. @@ -449,12 +463,12 @@ func TestClassifyBackupsGFS_NegativeDaily(t *testing.T) { stats := GetRetentionStats(classification) - if stats[CategoryDaily] != 0 { - t.Errorf("Expected 0 daily backups with negative daily config, got %d", stats[CategoryDaily]) + if stats[CategoryDaily] != 1 { + t.Errorf("Expected 1 daily backup with negative daily config, got %d", stats[CategoryDaily]) } - if stats[CategoryDelete] != 2 { - t.Errorf("Expected all backups marked for deletion, got %d", stats[CategoryDelete]) + if stats[CategoryDelete] != 1 { + t.Errorf("Expected 1 backup marked for deletion, got %d", stats[CategoryDelete]) } } diff --git a/internal/storage/secondary.go b/internal/storage/secondary.go index eca5cd86..eb0f7e30 100644 --- a/internal/storage/secondary.go +++ b/internal/storage/secondary.go @@ -528,6 +528,7 @@ func (s *SecondaryStorage) ApplyRetention(ctx context.Context, config RetentionC // applyGFSRetention applies GFS (Grandfather-Father-Son) retention policy func (s *SecondaryStorage) applyGFSRetention(ctx context.Context, backups []*types.BackupMetadata, config RetentionConfig) (int, error) { + config = EffectiveGFSRetentionConfig(config) s.logger.Debug("Applying GFS retention policy (daily=%d, weekly=%d, monthly=%d, yearly=%d)", config.Daily, config.Weekly, config.Monthly, config.Yearly) diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 398aa7f0..625f6650 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -49,6 +49,23 @@ func TestNormalizeGFSRetentionConfigEnforcesDailyMinimum(t *testing.T) { } } +func TestEffectiveGFSRetentionConfigEnforcesDailyMinimumWithoutLogging(t *testing.T) { + cfg := RetentionConfig{ + Policy: "gfs", + Daily: 0, + Weekly: 4, + } + + effective := EffectiveGFSRetentionConfig(cfg) + + if effective.Daily != 1 { + t.Fatalf("EffectiveGFSRetentionConfig() Daily = %d; want 1", effective.Daily) + } + if effective.Weekly != cfg.Weekly { + t.Fatalf("EffectiveGFSRetentionConfig() Weekly = %d; want %d", effective.Weekly, cfg.Weekly) + } +} + func TestLocalStorageListSkipsAssociatedFilesAndSortsByTimestamp(t *testing.T) { t.Parallel() From c2307d5ecd6087d921ab325bc6a0162b59f414d7 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 13:26:29 +0100 Subject: [PATCH 28/29] notify: honor context cancellation in email, relay, and webhook paths Propagate context-aware cancellation through notification retry and diagnostic flows. Switch email mail-log inspection helpers to use context and CommandContext, replace blocking sleeps with a context-aware wait helper, and stop relay/webhook retry loops immediately when the context is canceled. Add focused tests covering cancellation behavior. LiveReview Pre-Commit Check: ran (iter:1, coverage:0%) --- internal/notify/context_helpers.go | 25 +++++++++++++++ internal/notify/email.go | 38 +++++++++++++++------- internal/notify/email_mailq_test.go | 33 +++++++++++++++++++ internal/notify/email_parsing_test.go | 7 ++-- internal/notify/email_relay.go | 18 +++++++++-- internal/notify/email_relay_test.go | 42 ++++++++++++++++++++++++ internal/notify/webhook.go | 18 +++++++++-- internal/notify/webhook_test.go | 46 +++++++++++++++++++++++++++ 8 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 internal/notify/context_helpers.go diff --git a/internal/notify/context_helpers.go b/internal/notify/context_helpers.go new file mode 100644 index 00000000..46955b33 --- /dev/null +++ b/internal/notify/context_helpers.go @@ -0,0 +1,25 @@ +package notify + +import ( + "context" + "time" +) + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + if d <= 0 { + return nil + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/notify/email.go b/internal/notify/email.go index 20a3afac..ec25bc12 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -760,15 +760,22 @@ func (e *EmailNotifier) detectQueueEntry(ctx context.Context, recipient string) } // tailMailLog reads the last maxLines from the first available mail log file. -func (e *EmailNotifier) tailMailLog(maxLines int) ([]string, string) { +func (e *EmailNotifier) tailMailLog(ctx context.Context, maxLines int) ([]string, string) { + if err := ctx.Err(); err != nil { + return nil, "" + } + for _, logFile := range mailLogPaths { if _, err := os.Stat(logFile); err != nil { continue } - cmd := exec.Command("tail", "-n", strconv.Itoa(maxLines), logFile) + cmd := exec.CommandContext(ctx, "tail", "-n", strconv.Itoa(maxLines), logFile) output, err := cmd.Output() if err != nil { + if ctx.Err() != nil { + return nil, "" + } continue } @@ -777,13 +784,16 @@ func (e *EmailNotifier) tailMailLog(maxLines int) ([]string, string) { } // Fallback to journald if traditional log files are unavailable + if err := ctx.Err(); err != nil { + return nil, "" + } if _, err := exec.LookPath("journalctl"); err == nil { args := []string{"--no-pager", "-n", strconv.Itoa(maxLines)} for _, unit := range []string{"postfix.service", "sendmail.service", "exim4.service"} { args = append(args, "-u", unit) } - cmd := exec.Command("journalctl", args...) + cmd := exec.CommandContext(ctx, "journalctl", args...) output, err := cmd.Output() if err == nil && len(output) > 0 { lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n") @@ -795,8 +805,8 @@ func (e *EmailNotifier) tailMailLog(maxLines int) ([]string, string) { } // checkRecentMailLogs checks recent mail log entries for errors -func (e *EmailNotifier) checkRecentMailLogs() []string { - lines, _ := e.tailMailLog(50) +func (e *EmailNotifier) checkRecentMailLogs(ctx context.Context) []string { + lines, _ := e.tailMailLog(ctx, 50) if len(lines) == 0 { return nil } @@ -832,8 +842,8 @@ func extractQueueID(outputs ...string) string { } // inspectMailLogStatus looks for a delivery status line for the given queue ID. -func (e *EmailNotifier) inspectMailLogStatus(queueID string) (status, matchedLine, logPath string) { - lines, logPath := e.tailMailLog(80) +func (e *EmailNotifier) inspectMailLogStatus(ctx context.Context, queueID string) (status, matchedLine, logPath string) { + lines, logPath := e.tailMailLog(ctx, 80) if len(lines) == 0 || logPath == "" { return "", "", logPath } @@ -1297,7 +1307,13 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, e.logger.Debug("=== Post-send verification ===") // Brief pause to let sendmail process the message - time.Sleep(500 * time.Millisecond) + if err := sleepWithContext(ctx, 500*time.Millisecond); err != nil { + e.logger.Debug("Skipping post-send verification because context ended: %v", err) + e.logger.Debug("✅ Email handed off to sendmail successfully") + e.logger.Info("NOTE: Sendmail exit code 0 means email accepted to queue, not necessarily delivered") + e.logger.Info(" To verify actual delivery, check: mailq and /var/log/mail.log") + return queueID, "sendmail", sendmailPath, nil + } // Check queue again to see if message is stuck if queueCount, err := e.checkMailQueue(ctx); err == nil { @@ -1317,7 +1333,7 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, } // Check recent mail logs for errors (always surface summary, details only in debug) - recentErrors := e.checkRecentMailLogs() + recentErrors := e.checkRecentMailLogs(ctx) if len(recentErrors) > 0 { e.logger.Warning("⚠ Recent mail log entries indicate potential delivery issues (found %d error-like lines)", len(recentErrors)) e.logger.Info(" Suggestion: inspect /var/log/mail.log (or maillog/mail.err) on this host for details") @@ -1345,7 +1361,7 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, } if queueID != "" { - status, matchedLine, logPath := e.inspectMailLogStatus(queueID) + status, matchedLine, logPath := e.inspectMailLogStatus(ctx, queueID) e.logMailLogStatus(queueID, status, matchedLine, logPath) } else { e.logger.Debug("Sendmail did not report a queue ID; attempting to detect from mail queue output") @@ -1356,7 +1372,7 @@ func (e *EmailNotifier) sendViaSendmail(ctx context.Context, recipient, subject, if queueLine != "" && e.logger.GetLevel() >= types.LogLevelDebug { e.logger.Debug("Mail queue entry: %s", queueLine) } - status, matchedLine, logPath := e.inspectMailLogStatus(queueID) + status, matchedLine, logPath := e.inspectMailLogStatus(ctx, queueID) e.logMailLogStatus(queueID, status, matchedLine, logPath) } else { e.logger.Debug("No matching mail queue entry found for %s immediately after sending", recipient) diff --git a/internal/notify/email_mailq_test.go b/internal/notify/email_mailq_test.go index 2b7733f6..8c10cdab 100644 --- a/internal/notify/email_mailq_test.go +++ b/internal/notify/email_mailq_test.go @@ -2,6 +2,8 @@ package notify import ( "context" + "os" + "path/filepath" "testing" "github.com/tis24dev/proxsave/internal/logging" @@ -95,3 +97,34 @@ func TestEmailNotifierDetectQueueEntryNotFound(t *testing.T) { t.Fatalf("detectQueueEntry()=(%q,%q) want empty", queueID, line) } } + +func TestEmailNotifierTailMailLogSkipsWorkWhenContextCanceled(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + notifier, err := NewEmailNotifier(EmailConfig{Enabled: true, DeliveryMethod: EmailDeliverySendmail}, types.ProxmoxBS, logger) + if err != nil { + t.Fatalf("NewEmailNotifier() error=%v", err) + } + + origMailLogPaths := mailLogPaths + t.Cleanup(func() { mailLogPaths = origMailLogPaths }) + + logDir := t.TempDir() + logFile := filepath.Join(logDir, "mail.log") + if err := os.WriteFile(logFile, []byte("postfix/smtp[2]: ABC123: status=sent\n"), 0o600); err != nil { + t.Fatalf("write log file: %v", err) + } + mailLogPaths = []string{logFile} + + mockCmdEnv(t, "tail", "postfix/smtp[2]: ABC123: status=sent", 0) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + lines, logPath := notifier.tailMailLog(ctx, 50) + if len(lines) != 0 { + t.Fatalf("tailMailLog() returned lines after context cancellation: %#v", lines) + } + if logPath != "" { + t.Fatalf("tailMailLog() returned log path %q after context cancellation", logPath) + } +} diff --git a/internal/notify/email_parsing_test.go b/internal/notify/email_parsing_test.go index 41c9a153..92c9d6f1 100644 --- a/internal/notify/email_parsing_test.go +++ b/internal/notify/email_parsing_test.go @@ -2,6 +2,7 @@ package notify import ( "bytes" + "context" "io" "os" "path/filepath" @@ -84,7 +85,7 @@ func TestInspectMailLogStatus(t *testing.T) { t.Fatalf("NewEmailNotifier() error=%v", err) } - status, matchedLine, usedPath := notifier.inspectMailLogStatus(queueID) + status, matchedLine, usedPath := notifier.inspectMailLogStatus(context.Background(), queueID) if status != "sent" { t.Fatalf("status=%q want %q (matchedLine=%q)", status, "sent", matchedLine) } @@ -126,7 +127,7 @@ func TestEmailNotifierCheckRecentMailLogsDetectsErrors(t *testing.T) { t.Fatalf("NewEmailNotifier() error=%v", err) } - lines := notifier.checkRecentMailLogs() + lines := notifier.checkRecentMailLogs(context.Background()) if len(lines) < 3 { t.Fatalf("expected >=3 error-like lines, got %d: %#v", len(lines), lines) } @@ -184,7 +185,7 @@ func TestInspectMailLogStatus_Variants(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - status, matched, usedPath := notifier.inspectMailLogStatus(tt.queueID) + status, matched, usedPath := notifier.inspectMailLogStatus(context.Background(), tt.queueID) if status != tt.want { t.Fatalf("status=%q want %q (matched=%q)", status, tt.want, matched) } diff --git a/internal/notify/email_relay.go b/internal/notify/email_relay.go index 241dfd32..8d99bd9a 100644 --- a/internal/notify/email_relay.go +++ b/internal/notify/email_relay.go @@ -86,13 +86,19 @@ func sendViaCloudRelay( var lastErr error skipDefaultDelay := false for attempt := 0; attempt <= config.MaxRetries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if attempt > 0 { if skipDefaultDelay { logger.Debug("Retry attempt %d/%d resuming after rate-limit cooldown (no extra delay)", attempt, config.MaxRetries) skipDefaultDelay = false } else { logger.Debug("Retry attempt %d/%d after %ds delay...", attempt, config.MaxRetries, config.RetryDelay) - time.Sleep(time.Duration(config.RetryDelay) * time.Second) + if err := sleepWithContext(ctx, time.Duration(config.RetryDelay)*time.Second); err != nil { + return err + } } } @@ -114,6 +120,9 @@ func sendViaCloudRelay( // Send request resp, err := client.Do(req) if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } lastErr = fmt.Errorf("request failed: %w", err) logger.Warning("Cloud relay request failed (attempt %d/%d): %v", attempt+1, config.MaxRetries+1, err) continue @@ -123,6 +132,9 @@ func sendViaCloudRelay( body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } lastErr = fmt.Errorf("failed to read response: %w", err) continue } @@ -174,7 +186,9 @@ func sendViaCloudRelay( // Otherwise, retry with longer delay logger.Debug("Waiting 5 seconds before retry due to rate limiting...") - time.Sleep(5 * time.Second) + if err := sleepWithContext(ctx, 5*time.Second); err != nil { + return err + } skipDefaultDelay = true lastErr = fmt.Errorf("rate limit exceeded") continue diff --git a/internal/notify/email_relay_test.go b/internal/notify/email_relay_test.go index d2bf68e7..7cff1320 100644 --- a/internal/notify/email_relay_test.go +++ b/internal/notify/email_relay_test.go @@ -5,6 +5,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" "net/http" "net/http/httptest" "testing" @@ -214,3 +215,44 @@ func TestSendViaCloudRelay_RetryOnServerError(t *testing.T) { t.Fatalf("expected 3 attempts, got %d", attempts) } } + +func TestSendViaCloudRelay_StopsRetryingWhenContextCanceled(t *testing.T) { + attempts := 0 + ctx, cancel := context.WithCancel(context.Background()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + cancel() + } + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"temporary"}`)) + })) + defer server.Close() + + cfg := CloudRelayConfig{ + WorkerURL: server.URL, + WorkerToken: "token", + HMACSecret: "secret", + Timeout: 5, + MaxRetries: 3, + RetryDelay: 0, + } + + logger := logging.New(types.LogLevelDebug, false) + err := sendViaCloudRelay(ctx, cfg, EmailRelayPayload{ + To: "dest@test.invalid", + Subject: "subject", + Report: map[string]interface{}{"ok": true}, + Timestamp: time.Now().Unix(), + ServerMAC: "00:11:22:33:44:55", + ScriptVersion: "0.0.1", + ServerID: "server-id", + }, logger) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", err) + } + if attempts != 1 { + t.Fatalf("expected 1 attempt after cancellation, got %d", attempts) + } +} diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index d317a0f5..87e0f1c1 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -218,9 +218,15 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if attempt > 0 { w.logger.Debug("Retry attempt %d/%d after %ds delay...", attempt, maxRetries, retryDelay) - time.Sleep(time.Duration(retryDelay) * time.Second) + if err := sleepWithContext(ctx, time.Duration(retryDelay)*time.Second); err != nil { + return err + } } // Determine HTTP method @@ -315,6 +321,9 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We requestDuration := time.Since(requestStart) if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } lastErr = fmt.Errorf("request failed: %w", err) w.logger.Warning("⚠️ Request failed after %dms (attempt %d/%d): %v", requestDuration.Milliseconds(), attempt+1, maxRetries+1, err) @@ -327,6 +336,9 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We resp.Body.Close() if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } lastErr = fmt.Errorf("failed to read response: %w", err) w.logger.Warning("Failed to read response body: %v", err) continue @@ -376,7 +388,9 @@ func (w *WebhookNotifier) sendToEndpoint(ctx context.Context, endpoint config.We w.logger.Warning("⚠️ Rate limited (HTTP 429): %s", string(body)) if attempt < maxRetries { w.logger.Debug("Waiting 10 seconds before retry due to rate limiting...") - time.Sleep(10 * time.Second) + if err := sleepWithContext(ctx, 10*time.Second); err != nil { + return err + } } lastErr = fmt.Errorf("rate limit exceeded (HTTP 429)") continue diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go index 78926cba..841c828f 100644 --- a/internal/notify/webhook_test.go +++ b/internal/notify/webhook_test.go @@ -277,6 +277,52 @@ func TestWebhookNotifier_Send_Success(t *testing.T) { } } +func TestWebhookNotifier_SendToEndpoint_StopsRetryingWhenContextCanceled(t *testing.T) { + logger := logging.New(types.LogLevelDebug, false) + + attempts := 0 + ctx, cancel := context.WithCancel(context.Background()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + cancel() + } + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"temporary"}`)) + })) + defer server.Close() + + cfg := config.WebhookConfig{ + Enabled: true, + DefaultFormat: "generic", + Timeout: 30, + MaxRetries: 3, + RetryDelay: 0, + Endpoints: []config.WebhookEndpoint{ + { + Name: "test-webhook", + URL: server.URL, + Format: "generic", + Method: "POST", + Auth: config.WebhookAuth{Type: "none"}, + }, + }, + } + + notifier, err := NewWebhookNotifier(&cfg, logger) + if err != nil { + t.Fatalf("Failed to create notifier: %v", err) + } + + err = notifier.sendToEndpoint(ctx, cfg.Endpoints[0], createTestNotificationData()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", err) + } + if attempts != 1 { + t.Fatalf("expected 1 attempt after cancellation, got %d", attempts) + } +} + func TestWebhookNotifier_Send_Retry(t *testing.T) { logger := logging.New(types.LogLevelDebug, false) attempts := 0 From b26805f3c9b838d113f2f760dd32f57df9a494d5 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 18 Mar 2026 13:48:24 +0100 Subject: [PATCH 29/29] fixstorage: normalize bundle paths in cloud and secondary store flows Normalize bundle path handling when BundleAssociatedFiles is enabled so storage backends accept either the raw archive path or an already bundled path without deriving *.bundle.tar.bundle.tar. Reuse a shared helper for canonical bundle naming, add regression tests for double-suffix decoys, and log unexpected bundle stat errors while still ignoring missing optional bundle files. LiveReview Pre-Commit Check: skipped (iter:2, coverage:85%) --- internal/storage/backup_files.go | 18 ++++++++++++---- internal/storage/cloud.go | 23 +++++++++++++------- internal/storage/cloud_test.go | 34 ++++++++++++++++++++++++++++++ internal/storage/secondary.go | 20 ++++++++++++------ internal/storage/storage_test.go | 36 ++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/internal/storage/backup_files.go b/internal/storage/backup_files.go index 05d8ab61..2aaa0c4b 100644 --- a/internal/storage/backup_files.go +++ b/internal/storage/backup_files.go @@ -2,15 +2,24 @@ package storage import "strings" +const bundleSuffix = ".bundle.tar" + // trimBundleSuffix removes the .bundle.tar suffix from a path if present. // It returns the trimmed path and whether the suffix was removed. func trimBundleSuffix(path string) (string, bool) { - if strings.HasSuffix(path, ".bundle.tar") { - return strings.TrimSuffix(path, ".bundle.tar"), true + if strings.HasSuffix(path, bundleSuffix) { + return strings.TrimSuffix(path, bundleSuffix), true } return path, false } +// bundlePathFor returns the canonical bundle path for either a raw archive path +// or a path that already points to a bundle. +func bundlePathFor(path string) string { + base, _ := trimBundleSuffix(path) + return base + bundleSuffix +} + // buildBackupCandidatePaths returns the list of files that belong to a backup. // When includeBundle is true, both the bundle and the legacy single-file layout // are included so retention can clean up either form. @@ -29,8 +38,9 @@ func buildBackupCandidatePaths(base string, includeBundle bool) []string { files := make([]string, 0, 5) if includeBundle { - if add(base + ".bundle.tar") { - files = append(files, base+".bundle.tar") + bundlePath := bundlePathFor(base) + if add(bundlePath) { + files = append(files, bundlePath) } } candidates := []string{ diff --git a/internal/storage/cloud.go b/internal/storage/cloud.go index 4853ded4..ad18892c 100644 --- a/internal/storage/cloud.go +++ b/internal/storage/cloud.go @@ -651,14 +651,21 @@ func (c *CloudStorage) Store(ctx context.Context, backupFile string, metadata *t }) } } else { - // Upload bundle file - bundleFile := backupFile + ".bundle.tar" - if _, err := os.Stat(bundleFile); err == nil { - tasks = append(tasks, uploadTask{ - local: bundleFile, - remote: c.remotePathFor(filepath.Base(bundleFile)), - verify: c.parallelVerify, - }) + // When bundling is enabled, callers may pass either the raw archive path + // or the bundle path itself. Normalize to avoid looking for + // "*.bundle.tar.bundle.tar". + bundleFile := bundlePathFor(backupFile) + if bundleFile != backupFile { + if _, err := os.Stat(bundleFile); err == nil { + tasks = append(tasks, uploadTask{ + local: bundleFile, + remote: c.remotePathFor(filepath.Base(bundleFile)), + verify: c.parallelVerify, + }) + } else if !errors.Is(err, os.ErrNotExist) { + c.logger.Warning("WARNING: Cloud storage - unable to inspect bundle %s: %v", + filepath.Base(bundleFile), err) + } } } diff --git a/internal/storage/cloud_test.go b/internal/storage/cloud_test.go index 429edac3..7783186b 100644 --- a/internal/storage/cloud_test.go +++ b/internal/storage/cloud_test.go @@ -504,6 +504,40 @@ func TestCloudStorageStoreUploadsWithRemotePrefix(t *testing.T) { } } +func TestCloudStorageStoreBundleInputSkipsDoubleBundleUpload(t *testing.T) { + tmpDir := t.TempDir() + bundleFile := filepath.Join(tmpDir, "pbs1-backup.tar.zst.bundle.tar") + writeTestFile(t, bundleFile, "bundle") + writeTestFile(t, bundleFile+".bundle.tar", "decoy") + + cfg := &config.Config{ + CloudEnabled: true, + CloudRemote: "remote", + BundleAssociatedFiles: true, + RcloneRetries: 1, + RcloneTimeoutOperation: 10, + } + + cs := newCloudStorageForTest(cfg) + cs.sleep = func(time.Duration) {} + queue := &commandQueue{ + t: t, + queue: []queuedResponse{ + {name: "rclone", args: []string{"copyto", "--progress", "--stats", "10s", bundleFile, "remote:pbs1-backup.tar.zst.bundle.tar"}}, + {name: "rclone", args: []string{"lsl", "remote:pbs1-backup.tar.zst.bundle.tar"}, out: "6 2025-11-13 10:00:00 pbs1-backup.tar.zst.bundle.tar"}, + {name: "rclone", args: []string{"lsl", "remote:"}, out: "6 2025-11-13 10:00:00 pbs1-backup.tar.zst.bundle.tar"}, + }, + } + cs.execCommand = queue.exec + + if err := cs.Store(context.Background(), bundleFile, nil); err != nil { + t.Fatalf("Store() error = %v", err) + } + if len(queue.calls) != 3 { + t.Fatalf("expected 3 rclone calls, got %d", len(queue.calls)) + } +} + func TestCloudStorageStorePrimaryFailure(t *testing.T) { tmpDir := t.TempDir() backupFile := filepath.Join(tmpDir, "pbs1-backup.tar.zst") diff --git a/internal/storage/secondary.go b/internal/storage/secondary.go index eb0f7e30..6c3345e0 100644 --- a/internal/storage/secondary.go +++ b/internal/storage/secondary.go @@ -2,6 +2,7 @@ package storage import ( "context" + "errors" "fmt" "io" "os" @@ -183,12 +184,19 @@ func (s *SecondaryStorage) Store(ctx context.Context, backupFile string, metadat len(failedAssoc), failedAssoc) } } else { - // Copy bundle file - bundleFile := backupFile + ".bundle.tar" - if _, err := os.Stat(bundleFile); err == nil { - destBundle := filepath.Join(s.basePath, filepath.Base(bundleFile)) - if err := s.copyFile(ctx, bundleFile, destBundle); err != nil { - s.logger.Warning("WARNING: Secondary Storage: Failed to copy bundle %s: %v", + // When bundling is enabled, callers may pass either the raw archive path + // or the bundle path itself. Normalize to avoid looking for + // "*.bundle.tar.bundle.tar". + bundleFile := bundlePathFor(backupFile) + if bundleFile != backupFile { + if _, err := os.Stat(bundleFile); err == nil { + destBundle := filepath.Join(s.basePath, filepath.Base(bundleFile)) + if err := s.copyFile(ctx, bundleFile, destBundle); err != nil { + s.logger.Warning("WARNING: Secondary Storage: Failed to copy bundle %s: %v", + filepath.Base(bundleFile), err) + } + } else if !errors.Is(err, os.ErrNotExist) { + s.logger.Warning("WARNING: Secondary Storage: Unable to inspect bundle %s: %v", filepath.Base(bundleFile), err) } } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 625f6650..428518e8 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -1439,6 +1439,42 @@ func TestSecondaryStorageStoreHandlesBundles(t *testing.T) { } } +func TestSecondaryStorageStoreBundleInputSkipsDoubleBundleCopy(t *testing.T) { + t.Parallel() + + srcDir := t.TempDir() + destDir := t.TempDir() + cfg := &config.Config{ + SecondaryEnabled: true, + SecondaryPath: destDir, + BundleAssociatedFiles: true, + } + storage := newSecondaryStorageForTest(t, cfg) + + bundleFile := filepath.Join(srcDir, "node-bundle-backup-20240202-020202.tar.zst.bundle.tar") + if err := os.WriteFile(bundleFile, []byte("bundle"), 0o600); err != nil { + t.Fatalf("write bundle: %v", err) + } + doubleBundle := bundleFile + ".bundle.tar" + if err := os.WriteFile(doubleBundle, []byte("decoy"), 0o600); err != nil { + t.Fatalf("write double bundle decoy: %v", err) + } + + if err := storage.Store(context.Background(), bundleFile, &types.BackupMetadata{}); err != nil { + t.Fatalf("Store() error = %v", err) + } + + destBundle := filepath.Join(destDir, filepath.Base(bundleFile)) + if _, err := os.Stat(destBundle); err != nil { + t.Fatalf("expected bundle to be copied: %v", err) + } + + destDoubleBundle := filepath.Join(destDir, filepath.Base(doubleBundle)) + if _, err := os.Stat(destDoubleBundle); !os.IsNotExist(err) { + t.Fatalf("double bundle decoy should not be copied, err=%v", err) + } +} + func TestSecondaryStorageStoreHonorsContextCancellation(t *testing.T) { t.Parallel()