From 0ca6190e3cba41b9eaab78a60edb42fb601c1f37 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 13 Mar 2026 11:12:28 +0100 Subject: [PATCH 01/14] 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 e27d735..dd3490f 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 5560623..f6f7636 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 fc9b835..2c7f574 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 15b906a..fe9ca65 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 b95d90e..2cb4f48 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 c985046..5b69b53 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 240b112..3e4161b 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 e0f3d82..63dfe6c 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 bb35388..c2918df 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 3fb7ab0..b3611ee 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 653ddd4..d48b9b7 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 450d770..eb30065 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 ad5a9b0..8369ff6 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 0000000..0656420 --- /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 0000000..b1aeb75 --- /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 66484b9..cab928a 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 4a7c960..8f8641e 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/14] 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 f6f7636..7a042da 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 2c7f574..bc618e8 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 73d06bd..0c758f5 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 0000000..4ff0fdc --- /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 0000000..441a463 --- /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 9f4ff1f..374a319 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 e799db9..ba826a3 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 ebe3b18..a7267fc 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/14] 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 7a042da..5e97157 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 0000000..a243d1f --- /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 0000000..8de7a58 --- /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 bc618e8..525b0ad 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 93fb39c..de2d841 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 5b69b53..0b62735 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 43b7954..fe02893 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 cab928a..b81269d 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 8f8641e..e2a0407 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/14] 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 0000000..0d493c3 --- /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 0000000..df348d0 --- /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 5e97157..dd8d716 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 de2d841..441463e 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 9099f65..6a68065 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 0b62735..7e71de4 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 63dcad1..99fffc3 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 0000000..172d2f0 --- /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 0000000..b6a9b4a --- /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 0000000..e4a344e --- /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 0000000..33a6730 --- /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 aacfbb4..4d15676 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 912f991..096b8f4 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 524fcf1..35afd18 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 1a64a1f..0e30c79 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 0000000..cca0719 --- /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 0000000..1babaf3 --- /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/14] 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 dd8d716..3feb332 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 441463e..696727b 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 0000000..9511565 --- /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 0000000..bd01835 --- /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 7e71de4..83a6be8 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 63dfe6c..6ae7669 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 fe02893..d19a514 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 0000000..1e5429c --- /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 0000000..ad65566 --- /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 90e2098..ea79eb6 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 19e7a8f..47ef7c7 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/14] 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 7602d8a..5da7250 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 9a65f1c..d574a13 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 f08f9a0..d94b78e 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 7de3f69..f03a413 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 0000000..069b55c --- /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 27dd3d0..2c705ee 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 88c83b3..8849a04 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 0000000..eb78fd2 --- /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 0000000..8abf48a --- /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 0000000..3488bbb --- /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/14] 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 0000000..d9af216 --- /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 0000000..11cee74 --- /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/14] 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 3feb332..325cc33 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 525b0ad..827f22f 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 83a6be8..4c41b69 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 d19a514..172acc5 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 0000000..5ade607 --- /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 0000000..6b434b6 --- /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 b81269d..2098b87 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 e2a0407..e5d77cd 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/14] 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 325cc33..42a8ccf 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 827f22f..1531d69 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 696727b..52195df 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 4b7da8a..d8d5b27 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 1e906af..fd268f5 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 374a319..8b89e36 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 4c41b69..2812a7b 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 172acc5..e09b6b7 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 0000000..4c6c519 --- /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 0000000..4007750 --- /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 2098b87..58ad1ed 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/14] 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 42a8ccf..d6d49a6 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 1531d69..4f3f83d 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 58ad1ed..a33e9bf 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 e5d77cd..fc20e37 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/14] 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 d9af216..df56478 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 11cee74..6f471eb 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/14] 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 52195df..8a177b3 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/14] 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 6a68065..8ae155f 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 0000000..52ddd9a --- /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/14] 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 2c705ee..f40d594 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 1d303c7..ec73455 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 0000000..4cf1798 --- /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 eb78fd2..55bc35c 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 8abf48a..28ddc7f 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") + } +}