package hostupdate import ( "archive/zip" "context" "crypto/sha256" "encoding/hex" "io" "os" "path/filepath" "strings" "testing" ) type recordingRunner struct { calls [][]string } func (r *recordingRunner) Run(_ context.Context, _ string, args, _ []string, stdout, _ io.Writer) error { r.calls = append(r.calls, append([]string(nil), args...)) if stdout != nil { _, _ = io.WriteString(stdout, "backup-fixture") } return nil } func TestSetEnvValuePreservesOtherSettings(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, ".env") if err := os.WriteFile(path, []byte("# keep\nCANVAS_IMAGE_TAG=1.0.0\nPOSTGRES_DB=canvas\n"), 0o640); err != nil { t.Fatal(err) } if err := setEnvValue(path, "CANVAS_IMAGE_TAG", "1.2.2-preview.1"); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } value := string(data) if !strings.Contains(value, "# keep\n") || !strings.Contains(value, "POSTGRES_DB=canvas\n") || !strings.Contains(value, "CANVAS_IMAGE_TAG=1.2.2-preview.1\n") { t.Fatalf("unexpected env contents: %q", value) } stat, err := os.Stat(path) if err != nil { t.Fatal(err) } if stat.Mode().Perm() != 0o640 { t.Fatalf("mode=%o, want 640", stat.Mode().Perm()) } } func TestVerifyZipBackupRejectsCorruption(t *testing.T) { path := filepath.Join(t.TempDir(), "backup.zip") file, err := os.Create(path) if err != nil { t.Fatal(err) } archive := zip.NewWriter(file) for name, content := range map[string]string{ "metadata.json": "{}", "database.dump": "database", "backend-data.tar": "data", } { entry, createErr := archive.Create(name) if createErr != nil { t.Fatal(createErr) } if _, writeErr := io.WriteString(entry, content); writeErr != nil { t.Fatal(writeErr) } } if err := archive.Close(); err != nil { t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } hash := sha256.Sum256(data) checksum := "sha256:" + hex.EncodeToString(hash[:]) if err := verifyZipBackup(path, checksum); err != nil { t.Fatalf("valid backup rejected: %v", err) } if err := os.WriteFile(path, append(data, byte(1)), 0o600); err != nil { t.Fatal(err) } if err := verifyZipBackup(path, checksum); err == nil { t.Fatal("corrupted backup was accepted") } } func TestCurrentVersionRejectsLatest(t *testing.T) { installDir := t.TempDir() if err := os.WriteFile(filepath.Join(installDir, ".env"), []byte("CANVAS_IMAGE_TAG=latest\n"), 0o600); err != nil { t.Fatal(err) } manager := &Manager{config: Config{InstallDir: installDir, EnvFile: ".env"}} if _, err := manager.currentVersion(); err == nil { t.Fatal("latest tag was accepted") } } func TestCreateBackupReadsBackendDataAsRoot(t *testing.T) { installDir := t.TempDir() backupDir := filepath.Join(installDir, "backups") if err := os.MkdirAll(backupDir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(installDir, ".env"), []byte("POSTGRES_USER=canvas\nPOSTGRES_DB=canvas\n"), 0o600); err != nil { t.Fatal(err) } runner := &recordingRunner{} manager := &Manager{ config: Config{InstallDir: installDir, ComposeFile: "docker-compose.deploy.yml", EnvFile: ".env", BackupDir: backupDir}, runner: runner, } if _, err := manager.createBackup("v1.2.2-preview.2"); err != nil { t.Fatal(err) } for _, call := range runner.calls { joined := strings.Join(call, " ") if strings.Contains(joined, "exec -T --user root backend tar -C /data -cf - .") { return } } t.Fatalf("backend data backup did not use root: %#v", runner.calls) } func TestCheckWritableDirectory(t *testing.T) { directory := t.TempDir() if err := checkWritableDirectory(directory); err != nil { t.Fatalf("writable directory rejected: %v", err) } entries, err := os.ReadDir(directory) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Fatalf("write probe was not cleaned up: %v", entries) } if err := checkWritableDirectory(filepath.Join(directory, "missing")); err == nil { t.Fatal("missing directory was accepted") } }