Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0ca6190
Align secondary path validation across config load, CLI install, and TUI
tis24dev Mar 13, 2026
24f942f
Align --new-install confirmation flow across CLI and TUI
tis24dev Mar 13, 2026
51e9a66
Align existing backup.env handling across CLI and TUI
tis24dev Mar 13, 2026
62734f4
Fix AGE setup validation and install TUI messaging alignment
tis24dev Mar 13, 2026
18803d5
Align Telegram setup flow across CLI and TUI
tis24dev Mar 13, 2026
94b46e1
Align decrypt secret prompt semantics across CLI and TUI
tis24dev Mar 13, 2026
a10ce5a
Add end-to-end coverage for the production decrypt TUI flow
tis24dev Mar 13, 2026
a7a3df6
Align secondary disable semantics across CLI and TUI
tis24dev Mar 13, 2026
dfa28e0
Align install cron scheduling across CLI and TUI
tis24dev Mar 13, 2026
9550745
Add cron install regression coverage for CLI and TUI
tis24dev Mar 13, 2026
8dfd403
test(orchestrator): stabilize decrypt TUI end-to-end tests
tis24dev Mar 13, 2026
1534acb
fix(install): guard optional bootstrap logging in TUI install flow
tis24dev Mar 14, 2026
0adf76d
fix(newkey): guard success logging when bootstrap is nil
tis24dev Mar 14, 2026
42297e4
fix(decrypt): reject unchanged destination paths in CLI and TUI prompts
tis24dev Mar 14, 2026
2f7c89a
refactor: simplify ticker wait in rollback countdown
tis24dev Mar 14, 2026
7046236
fix: respect configured recipient file in --newkey
tis24dev Mar 14, 2026
0fb0f6d
test(tui): complete coverage for new install and telegram setup
tis24dev Mar 14, 2026
bf2b606
test(tui): add missing wizard coverage
tis24dev Mar 14, 2026
1327eab
test(tui): harden Telegram setup TUI tests
tis24dev Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions cmd/proxsave/encryption_setup.go
Original file line number Diff line number Diff line change
@@ -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
}
250 changes: 250 additions & 0 deletions cmd/proxsave/encryption_setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
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},
}

recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui)
if err != nil {
t.Fatalf("runNewKeySetup error: %v", err)
}

target := filepath.Join(baseDir, "identity", "age", "recipient.txt")
if recipientPath != target {
t.Fatalf("recipientPath=%q; want %q", recipientPath, target)
}
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile(%s): %v", target, err)
}
if got := string(content); got != id.Recipient().String()+"\n" {
t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n")
}
}

func TestRunNewKeySetupUsesConfiguredRecipientFile(t *testing.T) {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("GenerateX25519Identity: %v", err)
}

baseDir := t.TempDir()
configPath := filepath.Join(baseDir, "env", "backup.env")
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(configPath), err)
}

customPath := filepath.Join(baseDir, "custom", "recipient.txt")
content := "BASE_DIR=" + baseDir + "\nENCRYPT_ARCHIVE=true\nAGE_RECIPIENT_FILE=" + customPath + "\n"
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s): %v", configPath, err)
}

ui := &testAgeSetupUI{
overwrite: true,
drafts: []*orchestrator.AgeRecipientDraft{
{Kind: orchestrator.AgeRecipientInputExisting, PublicKey: id.Recipient().String()},
},
addMore: []bool{false},
}

recipientPath, err := runNewKeySetup(context.Background(), configPath, baseDir, nil, ui)
if err != nil {
t.Fatalf("runNewKeySetup error: %v", err)
}
if recipientPath != customPath {
t.Fatalf("recipientPath=%q; want %q", recipientPath, customPath)
}

customContent, err := os.ReadFile(customPath)
if err != nil {
t.Fatalf("ReadFile(%s): %v", customPath, err)
}
if got := string(customContent); got != id.Recipient().String()+"\n" {
t.Fatalf("content=%q; want %q", got, id.Recipient().String()+"\n")
}

defaultPath := filepath.Join(baseDir, "identity", "age", "recipient.txt")
if _, err := os.Stat(defaultPath); !os.IsNotExist(err) {
t.Fatalf("default path %s should not be written, stat err=%v", defaultPath, err)
}
}
51 changes: 49 additions & 2 deletions cmd/proxsave/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading
Loading