fix(handlers): fix deferred response close in loop, add bootstrap and partition tests
- commit
- 46b99e83273f7d501efe685cf1a2c3dcc818795b
- author
- brice · · 2026-03-13 06:00 UTC
- parent
- 7f26589
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +252 -48
M internal/application/handlers/bootstrap_handler.go +15 -2
| @@ -76,7 +76,7 @@ func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, er | |||
| 76 | 76 | return result, nil | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | - | // cloneRepo clones the archup repository | |
| 79 | + | // cloneRepo clones the archup repository at the configured git ref. | |
| 80 | 80 | func (h *BootstrapHandler) cloneRepo(ctx context.Context) error { | |
| 81 | 81 | repoDir := config.DefaultInstallRepoDir | |
| 82 | 82 | ||
| @@ -176,11 +176,24 @@ func (h *BootstrapHandler) downloadFiles(_ context.Context) error { | |||
| 176 | 176 | if err != nil { | |
| 177 | 177 | return fmt.Errorf("failed to download %s: %w", f.urlPath, err) | |
| 178 | 178 | } | |
| 179 | - | defer resp.Close() | |
| 179 | + | ||
| 180 | + | if resp.StatusCode() < 200 || resp.StatusCode() > 299 { | |
| 181 | + | if closeErr := resp.Close(); closeErr != nil { | |
| 182 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |
| 183 | + | } | |
| 184 | + | return fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url) | |
| 185 | + | } | |
| 180 | 186 | ||
| 181 | 187 | if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil { | |
| 188 | + | if closeErr := resp.Close(); closeErr != nil { | |
| 189 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |
| 190 | + | } | |
| 182 | 191 | return fmt.Errorf("failed to write %s: %w", f.destPath, err) | |
| 183 | 192 | } | |
| 193 | + | ||
| 194 | + | if closeErr := resp.Close(); closeErr != nil { | |
| 195 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |
| 196 | + | } | |
| 184 | 197 | } | |
| 185 | 198 | ||
| 186 | 199 | return nil | |
| @@ -76,7 +76,7 @@ func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, er | |||||
| 76 | return result, nil | 76 | return result, nil | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | - | // cloneRepo clones the archup repository | 79 | + | // cloneRepo clones the archup repository at the configured git ref. |
| 80 | func (h *BootstrapHandler) cloneRepo(ctx context.Context) error { | 80 | func (h *BootstrapHandler) cloneRepo(ctx context.Context) error { | ||
| 81 | repoDir := config.DefaultInstallRepoDir | 81 | repoDir := config.DefaultInstallRepoDir | ||
| 82 | 82 | ||||
| @@ -176,11 +176,24 @@ func (h *BootstrapHandler) downloadFiles(_ context.Context) error { | |||||
| 176 | if err != nil { | 176 | if err != nil { | ||
| 177 | return fmt.Errorf("failed to download %s: %w", f.urlPath, err) | 177 | return fmt.Errorf("failed to download %s: %w", f.urlPath, err) | ||
| 178 | } | 178 | } | ||
| 179 | - | defer resp.Close() | 179 | + | |
| 180 | + | if resp.StatusCode() < 200 || resp.StatusCode() > 299 { | |||
| 181 | + | if closeErr := resp.Close(); closeErr != nil { | |||
| 182 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |||
| 183 | + | } | |||
| 184 | + | return fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url) | |||
| 185 | + | } | |||
| 180 | 186 | ||||
| 181 | if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil { | 187 | if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil { | ||
| 188 | + | if closeErr := resp.Close(); closeErr != nil { | |||
| 189 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |||
| 190 | + | } | |||
| 182 | return fmt.Errorf("failed to write %s: %w", f.destPath, err) | 191 | return fmt.Errorf("failed to write %s: %w", f.destPath, err) | ||
| 183 | } | 192 | } | ||
| 193 | + | ||||
| 194 | + | if closeErr := resp.Close(); closeErr != nil { | |||
| 195 | + | h.logger.Warn("Failed to close HTTP response", "error", closeErr) | |||
| 196 | + | } | |||
| 184 | } | 197 | } | ||
| 185 | 198 | ||||
| 186 | return nil | 199 | return nil | ||
A internal/application/handlers/bootstrap_handler_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/archup/internal/domain/ports/mocks" | |
| 10 | + | "go.uber.org/mock/gomock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestBootstrapHandler_DownloadFiles_RejectsHTTPErrors(t *testing.T) { | |
| 14 | + | ctrl := gomock.NewController(t) | |
| 15 | + | defer ctrl.Finish() | |
| 16 | + | ||
| 17 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |
| 18 | + | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |
| 19 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 20 | + | ||
| 21 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 22 | + | mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() | |
| 23 | + | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |
| 24 | + | mockHTTP.EXPECT(). | |
| 25 | + | Get("https://raw.githubusercontent.com/bnema/archup/v1.2.3/install/base.packages"). | |
| 26 | + | Return(newMockResponse(ctrl, http.StatusNotFound, []byte("not found")), nil) | |
| 27 | + | ||
| 28 | + | handler := NewBootstrapHandler( | |
| 29 | + | mockFS, | |
| 30 | + | mockHTTP, | |
| 31 | + | mockLogger, | |
| 32 | + | "https://github.com/bnema/archup", | |
| 33 | + | "https://raw.githubusercontent.com/bnema/archup/v1.2.3", | |
| 34 | + | "v1.2.3", | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | err := handler.downloadFiles(context.Background()) | |
| 38 | + | if err == nil { | |
| 39 | + | t.Fatal("expected downloadFiles to fail on non-2xx response") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | if !strings.Contains(err.Error(), "unexpected status 404") { | |
| 43 | + | t.Fatalf("expected status error, got %v", err) | |
| 44 | + | } | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/archup/internal/domain/ports/mocks" | |||
| 10 | + | "go.uber.org/mock/gomock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestBootstrapHandler_DownloadFiles_RejectsHTTPErrors(t *testing.T) { | |||
| 14 | + | ctrl := gomock.NewController(t) | |||
| 15 | + | defer ctrl.Finish() | |||
| 16 | + | ||||
| 17 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 18 | + | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 19 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 20 | + | ||||
| 21 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 22 | + | mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 23 | + | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 24 | + | mockHTTP.EXPECT(). | |||
| 25 | + | Get("https://raw.githubusercontent.com/bnema/archup/v1.2.3/install/base.packages"). | |||
| 26 | + | Return(newMockResponse(ctrl, http.StatusNotFound, []byte("not found")), nil) | |||
| 27 | + | ||||
| 28 | + | handler := NewBootstrapHandler( | |||
| 29 | + | mockFS, | |||
| 30 | + | mockHTTP, | |||
| 31 | + | mockLogger, | |||
| 32 | + | "https://github.com/bnema/archup", | |||
| 33 | + | "https://raw.githubusercontent.com/bnema/archup/v1.2.3", | |||
| 34 | + | "v1.2.3", | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | err := handler.downloadFiles(context.Background()) | |||
| 38 | + | if err == nil { | |||
| 39 | + | t.Fatal("expected downloadFiles to fail on non-2xx response") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | if !strings.Contains(err.Error(), "unexpected status 404") { | |||
| 43 | + | t.Fatalf("expected status error, got %v", err) | |||
| 44 | + | } | |||
| 45 | + | } | |||
M internal/application/handlers/partition_handler.go +17 -10
| @@ -249,21 +249,28 @@ func (h *PartitionHandler) setupLUKSEncryption(ctx context.Context, partitionPat | |||
| 249 | 249 | return "", fmt.Errorf("wipefs root partition failed: %w", err) | |
| 250 | 250 | } | |
| 251 | 251 | ||
| 252 | - | // Create LUKS2 container with Argon2id | |
| 253 | - | // Note: Using printf to pipe password securely | |
| 254 | - | luksFormatCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file=- %s", | |
| 255 | - | password, partitionPath) | |
| 256 | - | ||
| 257 | - | if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksFormatCmd); err != nil { | |
| 252 | + | // Feed the passphrase over stdin so shell metacharacters are treated as data. | |
| 253 | + | if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup", | |
| 254 | + | "luksFormat", | |
| 255 | + | "--type", "luks2", | |
| 256 | + | "--batch-mode", | |
| 257 | + | "--pbkdf", "argon2id", | |
| 258 | + | "--iter-time", "2000", | |
| 259 | + | "--label", "ARCHUP_LUKS", | |
| 260 | + | "--key-file=-", | |
| 261 | + | partitionPath, | |
| 262 | + | ); err != nil { | |
| 258 | 263 | return "", fmt.Errorf("luksFormat failed: %w", err) | |
| 259 | 264 | } | |
| 260 | 265 | ||
| 261 | 266 | // Open LUKS container | |
| 262 | 267 | cryptDevice := "/dev/mapper/cryptroot" | |
| 263 | - | luksOpenCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot", | |
| 264 | - | password, partitionPath) | |
| 265 | - | ||
| 266 | - | if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksOpenCmd); err != nil { | |
| 268 | + | if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup", | |
| 269 | + | "open", | |
| 270 | + | "--key-file=-", | |
| 271 | + | partitionPath, | |
| 272 | + | "cryptroot", | |
| 273 | + | ); err != nil { | |
| 267 | 274 | return "", fmt.Errorf("cryptsetup open failed: %w", err) | |
| 268 | 275 | } | |
| 269 | 276 | ||
| @@ -249,21 +249,28 @@ func (h *PartitionHandler) setupLUKSEncryption(ctx context.Context, partitionPat | |||||
| 249 | return "", fmt.Errorf("wipefs root partition failed: %w", err) | 249 | return "", fmt.Errorf("wipefs root partition failed: %w", err) | ||
| 250 | } | 250 | } | ||
| 251 | 251 | ||||
| 252 | - | // Create LUKS2 container with Argon2id | 252 | + | // Feed the passphrase over stdin so shell metacharacters are treated as data. |
| 253 | - | // Note: Using printf to pipe password securely | 253 | + | if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup", |
| 254 | - | luksFormatCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file=- %s", | 254 | + | "luksFormat", |
| 255 | - | password, partitionPath) | 255 | + | "--type", "luks2", |
| 256 | - | 256 | + | "--batch-mode", | |
| 257 | - | if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksFormatCmd); err != nil { | 257 | + | "--pbkdf", "argon2id", |
| 258 | + | "--iter-time", "2000", | |||
| 259 | + | "--label", "ARCHUP_LUKS", | |||
| 260 | + | "--key-file=-", | |||
| 261 | + | partitionPath, | |||
| 262 | + | ); err != nil { | |||
| 258 | return "", fmt.Errorf("luksFormat failed: %w", err) | 263 | return "", fmt.Errorf("luksFormat failed: %w", err) | ||
| 259 | } | 264 | } | ||
| 260 | 265 | ||||
| 261 | // Open LUKS container | 266 | // Open LUKS container | ||
| 262 | cryptDevice := "/dev/mapper/cryptroot" | 267 | cryptDevice := "/dev/mapper/cryptroot" | ||
| 263 | - | luksOpenCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot", | 268 | + | if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup", |
| 264 | - | password, partitionPath) | 269 | + | "open", |
| 265 | - | 270 | + | "--key-file=-", | |
| 266 | - | if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksOpenCmd); err != nil { | 271 | + | partitionPath, |
| 272 | + | "cryptroot", | |||
| 273 | + | ); err != nil { | |||
| 267 | return "", fmt.Errorf("cryptsetup open failed: %w", err) | 274 | return "", fmt.Errorf("cryptsetup open failed: %w", err) | ||
| 268 | } | 275 | } | ||
| 269 | 276 | ||||
M internal/application/handlers/partition_handler_test.go +46 -0
| @@ -147,3 +147,49 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) { | |||
| 147 | 147 | t.Errorf("expected success, got error: %s", result.ErrorDetail) | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| 150 | + | ||
| 151 | + | func TestPartitionHandler_SetupLUKSEncryption_UsesStdinForPassword(t *testing.T) { | |
| 152 | + | ctrl := gomock.NewController(t) | |
| 153 | + | defer ctrl.Finish() | |
| 154 | + | ||
| 155 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |
| 156 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 157 | + | ||
| 158 | + | password := "pa'ss$word !" | |
| 159 | + | ||
| 160 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 161 | + | mockExec.EXPECT().Execute(gomock.Any(), "wipefs", "-af", "/dev/sda2").Return([]byte{}, nil) | |
| 162 | + | mockExec.EXPECT().ExecuteWithStdin( | |
| 163 | + | gomock.Any(), | |
| 164 | + | password, | |
| 165 | + | "cryptsetup", | |
| 166 | + | "luksFormat", | |
| 167 | + | "--type", "luks2", | |
| 168 | + | "--batch-mode", | |
| 169 | + | "--pbkdf", "argon2id", | |
| 170 | + | "--iter-time", "2000", | |
| 171 | + | "--label", "ARCHUP_LUKS", | |
| 172 | + | "--key-file=-", | |
| 173 | + | "/dev/sda2", | |
| 174 | + | ).Return([]byte{}, nil) | |
| 175 | + | mockExec.EXPECT().ExecuteWithStdin( | |
| 176 | + | gomock.Any(), | |
| 177 | + | password, | |
| 178 | + | "cryptsetup", | |
| 179 | + | "open", | |
| 180 | + | "--key-file=-", | |
| 181 | + | "/dev/sda2", | |
| 182 | + | "cryptroot", | |
| 183 | + | ).Return([]byte{}, nil) | |
| 184 | + | ||
| 185 | + | handler := NewPartitionHandler(mockExec, mockLogger) | |
| 186 | + | ||
| 187 | + | cryptDevice, err := handler.setupLUKSEncryption(context.Background(), "/dev/sda2", password) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatalf("expected no error, got %v", err) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | if cryptDevice != "/dev/mapper/cryptroot" { | |
| 193 | + | t.Fatalf("expected cryptroot mapper path, got %q", cryptDevice) | |
| 194 | + | } | |
| 195 | + | } | |
| @@ -147,3 +147,49 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) { | |||||
| 147 | t.Errorf("expected success, got error: %s", result.ErrorDetail) | 147 | t.Errorf("expected success, got error: %s", result.ErrorDetail) | ||
| 148 | } | 148 | } | ||
| 149 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | func TestPartitionHandler_SetupLUKSEncryption_UsesStdinForPassword(t *testing.T) { | |||
| 152 | + | ctrl := gomock.NewController(t) | |||
| 153 | + | defer ctrl.Finish() | |||
| 154 | + | ||||
| 155 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |||
| 156 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 157 | + | ||||
| 158 | + | password := "pa'ss$word !" | |||
| 159 | + | ||||
| 160 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 161 | + | mockExec.EXPECT().Execute(gomock.Any(), "wipefs", "-af", "/dev/sda2").Return([]byte{}, nil) | |||
| 162 | + | mockExec.EXPECT().ExecuteWithStdin( | |||
| 163 | + | gomock.Any(), | |||
| 164 | + | password, | |||
| 165 | + | "cryptsetup", | |||
| 166 | + | "luksFormat", | |||
| 167 | + | "--type", "luks2", | |||
| 168 | + | "--batch-mode", | |||
| 169 | + | "--pbkdf", "argon2id", | |||
| 170 | + | "--iter-time", "2000", | |||
| 171 | + | "--label", "ARCHUP_LUKS", | |||
| 172 | + | "--key-file=-", | |||
| 173 | + | "/dev/sda2", | |||
| 174 | + | ).Return([]byte{}, nil) | |||
| 175 | + | mockExec.EXPECT().ExecuteWithStdin( | |||
| 176 | + | gomock.Any(), | |||
| 177 | + | password, | |||
| 178 | + | "cryptsetup", | |||
| 179 | + | "open", | |||
| 180 | + | "--key-file=-", | |||
| 181 | + | "/dev/sda2", | |||
| 182 | + | "cryptroot", | |||
| 183 | + | ).Return([]byte{}, nil) | |||
| 184 | + | ||||
| 185 | + | handler := NewPartitionHandler(mockExec, mockLogger) | |||
| 186 | + | ||||
| 187 | + | cryptDevice, err := handler.setupLUKSEncryption(context.Background(), "/dev/sda2", password) | |||
| 188 | + | if err != nil { | |||
| 189 | + | t.Fatalf("expected no error, got %v", err) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | if cryptDevice != "/dev/mapper/cryptroot" { | |||
| 193 | + | t.Fatalf("expected cryptroot mapper path, got %q", cryptDevice) | |||
| 194 | + | } | |||
| 195 | + | } | |||
M internal/config/config.go +24 -22
| @@ -184,27 +184,11 @@ type Config struct { | |||
| 184 | 184 | RawURL string | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | - | // NewConfig creates a new Config with sensible defaults | |
| 188 | - | // version parameter determines which branch to use for downloads (dev builds use dev branch) | |
| 189 | - | // ENV=dev environment variable takes precedence over version-based detection | |
| 187 | + | // NewConfig creates a new Config with sensible defaults. | |
| 188 | + | // version determines which git ref to use for downloads and bootstrap assets. | |
| 189 | + | // ENV=dev forces the dev ref regardless of the version string. | |
| 190 | 190 | func NewConfig(version string) *Config { | |
| 191 | - | // Determine which branch to use | |
| 192 | - | // ENV=dev takes precedence, then fall back to version-based detection | |
| 193 | - | var branch string | |
| 194 | - | if os.Getenv("ENV") == "dev" { | |
| 195 | - | branch = "dev" | |
| 196 | - | } else { | |
| 197 | - | switch { | |
| 198 | - | case version == "dev" || version == "": | |
| 199 | - | branch = "dev" | |
| 200 | - | case strings.Contains(version, "-dev"): | |
| 201 | - | branch = "dev" | |
| 202 | - | case strings.Contains(version, "-next"): | |
| 203 | - | branch = "dev" | |
| 204 | - | default: | |
| 205 | - | branch = "main" | |
| 206 | - | } | |
| 207 | - | } | |
| 191 | + | ref := bootstrapRef(version) | |
| 208 | 192 | ||
| 209 | 193 | return &Config{ | |
| 210 | 194 | Hostname: "arch", | |
| @@ -221,11 +205,29 @@ func NewConfig(version string) *Config { | |||
| 221 | 205 | ConfigPath: DefaultConfigPath, | |
| 222 | 206 | LogPath: DefaultLogPath, | |
| 223 | 207 | RepoURL: "https://github.com/bnema/archup", | |
| 224 | - | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch), | |
| 208 | + | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", ref), | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func bootstrapRef(version string) string { | |
| 213 | + | if os.Getenv("ENV") == "dev" { | |
| 214 | + | return "dev" | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | version = strings.TrimSpace(version) | |
| 218 | + | switch { | |
| 219 | + | case version == "" || version == "dev": | |
| 220 | + | return "dev" | |
| 221 | + | case strings.Contains(version, "-dev"): | |
| 222 | + | return "dev" | |
| 223 | + | case strings.Contains(version, "-next"): | |
| 224 | + | return "dev" | |
| 225 | + | default: | |
| 226 | + | return version | |
| 225 | 227 | } | |
| 226 | 228 | } | |
| 227 | 229 | ||
| 228 | - | // Branch returns the git branch to use for asset downloads and repo cloning. | |
| 230 | + | // Branch returns the git ref to use for asset downloads and repo cloning. | |
| 229 | 231 | func (c *Config) Branch() string { | |
| 230 | 232 | rawURL := strings.TrimSpace(c.RawURL) | |
| 231 | 233 | if rawURL == "" { | |
| @@ -184,27 +184,11 @@ type Config struct { | |||||
| 184 | RawURL string | 184 | RawURL string | ||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| 187 | - | // NewConfig creates a new Config with sensible defaults | 187 | + | // NewConfig creates a new Config with sensible defaults. |
| 188 | - | // version parameter determines which branch to use for downloads (dev builds use dev branch) | 188 | + | // version determines which git ref to use for downloads and bootstrap assets. |
| 189 | - | // ENV=dev environment variable takes precedence over version-based detection | 189 | + | // ENV=dev forces the dev ref regardless of the version string. |
| 190 | func NewConfig(version string) *Config { | 190 | func NewConfig(version string) *Config { | ||
| 191 | - | // Determine which branch to use | 191 | + | ref := bootstrapRef(version) |
| 192 | - | // ENV=dev takes precedence, then fall back to version-based detection | |||
| 193 | - | var branch string | |||
| 194 | - | if os.Getenv("ENV") == "dev" { | |||
| 195 | - | branch = "dev" | |||
| 196 | - | } else { | |||
| 197 | - | switch { | |||
| 198 | - | case version == "dev" || version == "": | |||
| 199 | - | branch = "dev" | |||
| 200 | - | case strings.Contains(version, "-dev"): | |||
| 201 | - | branch = "dev" | |||
| 202 | - | case strings.Contains(version, "-next"): | |||
| 203 | - | branch = "dev" | |||
| 204 | - | default: | |||
| 205 | - | branch = "main" | |||
| 206 | - | } | |||
| 207 | - | } | |||
| 208 | 192 | ||||
| 209 | return &Config{ | 193 | return &Config{ | ||
| 210 | Hostname: "arch", | 194 | Hostname: "arch", | ||
| @@ -221,11 +205,29 @@ func NewConfig(version string) *Config { | |||||
| 221 | ConfigPath: DefaultConfigPath, | 205 | ConfigPath: DefaultConfigPath, | ||
| 222 | LogPath: DefaultLogPath, | 206 | LogPath: DefaultLogPath, | ||
| 223 | RepoURL: "https://github.com/bnema/archup", | 207 | RepoURL: "https://github.com/bnema/archup", | ||
| 224 | - | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch), | 208 | + | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", ref), |
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func bootstrapRef(version string) string { | |||
| 213 | + | if os.Getenv("ENV") == "dev" { | |||
| 214 | + | return "dev" | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | version = strings.TrimSpace(version) | |||
| 218 | + | switch { | |||
| 219 | + | case version == "" || version == "dev": | |||
| 220 | + | return "dev" | |||
| 221 | + | case strings.Contains(version, "-dev"): | |||
| 222 | + | return "dev" | |||
| 223 | + | case strings.Contains(version, "-next"): | |||
| 224 | + | return "dev" | |||
| 225 | + | default: | |||
| 226 | + | return version | |||
| 225 | } | 227 | } | ||
| 226 | } | 228 | } | ||
| 227 | 229 | ||||
| 228 | - | // Branch returns the git branch to use for asset downloads and repo cloning. | 230 | + | // Branch returns the git ref to use for asset downloads and repo cloning. |
| 229 | func (c *Config) Branch() string { | 231 | func (c *Config) Branch() string { | ||
| 230 | rawURL := strings.TrimSpace(c.RawURL) | 232 | rawURL := strings.TrimSpace(c.RawURL) | ||
| 231 | if rawURL == "" { | 233 | if rawURL == "" { | ||
A internal/config/config_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestNewConfig_UsesReleaseVersionAsBootstrapRef(t *testing.T) { | |
| 6 | + | cfg := NewConfig("v1.2.3") | |
| 7 | + | ||
| 8 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/v1.2.3" { | |
| 9 | + | t.Fatalf("expected release raw URL, got %q", cfg.RawURL) | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | if cfg.Branch() != "v1.2.3" { | |
| 13 | + | t.Fatalf("expected release ref, got %q", cfg.Branch()) | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestNewConfig_UsesDevRefForDevelopmentBuilds(t *testing.T) { | |
| 18 | + | cfg := NewConfig("v1.2.3-dev") | |
| 19 | + | ||
| 20 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" { | |
| 21 | + | t.Fatalf("expected dev raw URL, got %q", cfg.RawURL) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | if cfg.Branch() != "dev" { | |
| 25 | + | t.Fatalf("expected dev ref, got %q", cfg.Branch()) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestNewConfig_ENVDevOverridesReleaseVersion(t *testing.T) { | |
| 30 | + | t.Setenv("ENV", "dev") | |
| 31 | + | ||
| 32 | + | cfg := NewConfig("v1.2.3") | |
| 33 | + | ||
| 34 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" { | |
| 35 | + | t.Fatalf("expected dev raw URL override, got %q", cfg.RawURL) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | if cfg.Branch() != "dev" { | |
| 39 | + | t.Fatalf("expected dev ref override, got %q", cfg.Branch()) | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestNewConfig_UsesReleaseVersionAsBootstrapRef(t *testing.T) { | |||
| 6 | + | cfg := NewConfig("v1.2.3") | |||
| 7 | + | ||||
| 8 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/v1.2.3" { | |||
| 9 | + | t.Fatalf("expected release raw URL, got %q", cfg.RawURL) | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | if cfg.Branch() != "v1.2.3" { | |||
| 13 | + | t.Fatalf("expected release ref, got %q", cfg.Branch()) | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestNewConfig_UsesDevRefForDevelopmentBuilds(t *testing.T) { | |||
| 18 | + | cfg := NewConfig("v1.2.3-dev") | |||
| 19 | + | ||||
| 20 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" { | |||
| 21 | + | t.Fatalf("expected dev raw URL, got %q", cfg.RawURL) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | if cfg.Branch() != "dev" { | |||
| 25 | + | t.Fatalf("expected dev ref, got %q", cfg.Branch()) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestNewConfig_ENVDevOverridesReleaseVersion(t *testing.T) { | |||
| 30 | + | t.Setenv("ENV", "dev") | |||
| 31 | + | ||||
| 32 | + | cfg := NewConfig("v1.2.3") | |||
| 33 | + | ||||
| 34 | + | if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" { | |||
| 35 | + | t.Fatalf("expected dev raw URL override, got %q", cfg.RawURL) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | if cfg.Branch() != "dev" { | |||
| 39 | + | t.Fatalf("expected dev ref override, got %q", cfg.Branch()) | |||
| 40 | + | } | |||
| 41 | + | } | |||
M internal/domain/ports/mocks/mock_ports.go +20 -0
| @@ -270,6 +270,26 @@ func (mr *MockCommandExecutorMockRecorder) Execute(ctx, command any, args ...any | |||
| 270 | 270 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...) | |
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | + | // ExecuteWithStdin mocks base method. | |
| 274 | + | func (m *MockCommandExecutor) ExecuteWithStdin(ctx context.Context, stdin, command string, args ...string) ([]byte, error) { | |
| 275 | + | m.ctrl.T.Helper() | |
| 276 | + | varargs := []any{ctx, stdin, command} | |
| 277 | + | for _, a := range args { | |
| 278 | + | varargs = append(varargs, a) | |
| 279 | + | } | |
| 280 | + | ret := m.ctrl.Call(m, "ExecuteWithStdin", varargs...) | |
| 281 | + | ret0, _ := ret[0].([]byte) | |
| 282 | + | ret1, _ := ret[1].(error) | |
| 283 | + | return ret0, ret1 | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // ExecuteWithStdin indicates an expected call of ExecuteWithStdin. | |
| 287 | + | func (mr *MockCommandExecutorMockRecorder) ExecuteWithStdin(ctx, stdin, command any, args ...any) *gomock.Call { | |
| 288 | + | mr.mock.ctrl.T.Helper() | |
| 289 | + | varargs := append([]any{ctx, stdin, command}, args...) | |
| 290 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteWithStdin", reflect.TypeOf((*MockCommandExecutor)(nil).ExecuteWithStdin), varargs...) | |
| 291 | + | } | |
| 292 | + | ||
| 273 | 293 | // ExecuteWithEnv mocks base method. | |
| 274 | 294 | func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | |
| 275 | 295 | m.ctrl.T.Helper() | |
| @@ -270,6 +270,26 @@ func (mr *MockCommandExecutorMockRecorder) Execute(ctx, command any, args ...any | |||||
| 270 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...) | 270 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...) | ||
| 271 | } | 271 | } | ||
| 272 | 272 | ||||
| 273 | + | // ExecuteWithStdin mocks base method. | |||
| 274 | + | func (m *MockCommandExecutor) ExecuteWithStdin(ctx context.Context, stdin, command string, args ...string) ([]byte, error) { | |||
| 275 | + | m.ctrl.T.Helper() | |||
| 276 | + | varargs := []any{ctx, stdin, command} | |||
| 277 | + | for _, a := range args { | |||
| 278 | + | varargs = append(varargs, a) | |||
| 279 | + | } | |||
| 280 | + | ret := m.ctrl.Call(m, "ExecuteWithStdin", varargs...) | |||
| 281 | + | ret0, _ := ret[0].([]byte) | |||
| 282 | + | ret1, _ := ret[1].(error) | |||
| 283 | + | return ret0, ret1 | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // ExecuteWithStdin indicates an expected call of ExecuteWithStdin. | |||
| 287 | + | func (mr *MockCommandExecutorMockRecorder) ExecuteWithStdin(ctx, stdin, command any, args ...any) *gomock.Call { | |||
| 288 | + | mr.mock.ctrl.T.Helper() | |||
| 289 | + | varargs := append([]any{ctx, stdin, command}, args...) | |||
| 290 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteWithStdin", reflect.TypeOf((*MockCommandExecutor)(nil).ExecuteWithStdin), varargs...) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 273 | // ExecuteWithEnv mocks base method. | 293 | // ExecuteWithEnv mocks base method. | ||
| 274 | func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | 294 | func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | ||
| 275 | m.ctrl.T.Helper() | 295 | m.ctrl.T.Helper() | ||
M internal/domain/ports/ports.go +3 -0
| @@ -52,6 +52,9 @@ type CommandExecutor interface { | |||
| 52 | 52 | // Execute runs a command and returns output | |
| 53 | 53 | Execute(ctx context.Context, command string, args ...string) ([]byte, error) | |
| 54 | 54 | ||
| 55 | + | // ExecuteWithStdin runs a command with stdin content and returns output | |
| 56 | + | ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) | |
| 57 | + | ||
| 55 | 58 | // ExecuteWithEnv runs a command with custom environment variables | |
| 56 | 59 | ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) | |
| 57 | 60 | } | |
| @@ -52,6 +52,9 @@ type CommandExecutor interface { | |||||
| 52 | // Execute runs a command and returns output | 52 | // Execute runs a command and returns output | ||
| 53 | Execute(ctx context.Context, command string, args ...string) ([]byte, error) | 53 | Execute(ctx context.Context, command string, args ...string) ([]byte, error) | ||
| 54 | 54 | ||||
| 55 | + | // ExecuteWithStdin runs a command with stdin content and returns output | |||
| 56 | + | ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) | |||
| 57 | + | ||||
| 55 | // ExecuteWithEnv runs a command with custom environment variables | 58 | // ExecuteWithEnv runs a command with custom environment variables | ||
| 56 | ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) | 59 | ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) | ||
| 57 | } | 60 | } | ||
M internal/infrastructure/executor/shell.go +19 -14
| @@ -24,31 +24,36 @@ func NewShellExecutor(logger ports.Logger) *ShellExecutor { | |||
| 24 | 24 | ||
| 25 | 25 | // Execute runs a command and returns output | |
| 26 | 26 | func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { | |
| 27 | - | cmd := exec.CommandContext(ctx, command, args...) | |
| 28 | - | var stdout, stderr bytes.Buffer | |
| 29 | - | cmd.Stdout = &stdout | |
| 30 | - | cmd.Stderr = &stderr | |
| 31 | - | ||
| 32 | - | if err := cmd.Run(); err != nil { | |
| 33 | - | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |
| 34 | - | } | |
| 27 | + | return se.run(ctx, "", nil, command, args...) | |
| 28 | + | } | |
| 35 | 29 | ||
| 36 | - | return stdout.Bytes(), nil | |
| 30 | + | // ExecuteWithStdin runs a command with stdin content and returns output | |
| 31 | + | func (se *ShellExecutor) ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) { | |
| 32 | + | return se.run(ctx, stdin, nil, command, args...) | |
| 37 | 33 | } | |
| 38 | 34 | ||
| 39 | 35 | // ExecuteWithEnv runs a command with custom environment variables | |
| 40 | 36 | func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | |
| 37 | + | return se.run(ctx, "", env, command, args...) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (se *ShellExecutor) run(ctx context.Context, stdin string, env map[string]string, command string, args ...string) ([]byte, error) { | |
| 41 | 41 | cmd := exec.CommandContext(ctx, command, args...) | |
| 42 | 42 | var stdout, stderr bytes.Buffer | |
| 43 | 43 | cmd.Stdout = &stdout | |
| 44 | 44 | cmd.Stderr = &stderr | |
| 45 | + | if stdin != "" { | |
| 46 | + | cmd.Stdin = bytes.NewBufferString(stdin) | |
| 47 | + | } | |
| 45 | 48 | ||
| 46 | - | // Build environment: start with current environment, then add/override with provided env | |
| 47 | - | cmdEnv := os.Environ() | |
| 48 | - | for key, value := range env { | |
| 49 | - | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |
| 49 | + | if env != nil { | |
| 50 | + | // Build environment: start with current environment, then add/override with provided env | |
| 51 | + | cmdEnv := os.Environ() | |
| 52 | + | for key, value := range env { | |
| 53 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |
| 54 | + | } | |
| 55 | + | cmd.Env = cmdEnv | |
| 50 | 56 | } | |
| 51 | - | cmd.Env = cmdEnv | |
| 52 | 57 | ||
| 53 | 58 | if err := cmd.Run(); err != nil { | |
| 54 | 59 | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |
| @@ -24,31 +24,36 @@ func NewShellExecutor(logger ports.Logger) *ShellExecutor { | |||||
| 24 | 24 | ||||
| 25 | // Execute runs a command and returns output | 25 | // Execute runs a command and returns output | ||
| 26 | func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { | 26 | func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { | ||
| 27 | - | cmd := exec.CommandContext(ctx, command, args...) | 27 | + | return se.run(ctx, "", nil, command, args...) |
| 28 | - | var stdout, stderr bytes.Buffer | 28 | + | } |
| 29 | - | cmd.Stdout = &stdout | |||
| 30 | - | cmd.Stderr = &stderr | |||
| 31 | - | ||||
| 32 | - | if err := cmd.Run(); err != nil { | |||
| 33 | - | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |||
| 34 | - | } | |||
| 35 | 29 | ||||
| 36 | - | return stdout.Bytes(), nil | 30 | + | // ExecuteWithStdin runs a command with stdin content and returns output |
| 31 | + | func (se *ShellExecutor) ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) { | |||
| 32 | + | return se.run(ctx, stdin, nil, command, args...) | |||
| 37 | } | 33 | } | ||
| 38 | 34 | ||||
| 39 | // ExecuteWithEnv runs a command with custom environment variables | 35 | // ExecuteWithEnv runs a command with custom environment variables | ||
| 40 | func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | 36 | func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | ||
| 37 | + | return se.run(ctx, "", env, command, args...) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (se *ShellExecutor) run(ctx context.Context, stdin string, env map[string]string, command string, args ...string) ([]byte, error) { | |||
| 41 | cmd := exec.CommandContext(ctx, command, args...) | 41 | cmd := exec.CommandContext(ctx, command, args...) | ||
| 42 | var stdout, stderr bytes.Buffer | 42 | var stdout, stderr bytes.Buffer | ||
| 43 | cmd.Stdout = &stdout | 43 | cmd.Stdout = &stdout | ||
| 44 | cmd.Stderr = &stderr | 44 | cmd.Stderr = &stderr | ||
| 45 | + | if stdin != "" { | |||
| 46 | + | cmd.Stdin = bytes.NewBufferString(stdin) | |||
| 47 | + | } | |||
| 45 | 48 | ||||
| 46 | - | // Build environment: start with current environment, then add/override with provided env | 49 | + | if env != nil { |
| 47 | - | cmdEnv := os.Environ() | 50 | + | // Build environment: start with current environment, then add/override with provided env |
| 48 | - | for key, value := range env { | 51 | + | cmdEnv := os.Environ() |
| 49 | - | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | 52 | + | for key, value := range env { |
| 53 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |||
| 54 | + | } | |||
| 55 | + | cmd.Env = cmdEnv | |||
| 50 | } | 56 | } | ||
| 51 | - | cmd.Env = cmdEnv | |||
| 52 | 57 | ||||
| 53 | if err := cmd.Run(); err != nil { | 58 | if err := cmd.Run(); err != nil { | ||
| 54 | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | 59 | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | ||
M internal/infrastructure/executor/shell_test.go +22 -0
| @@ -148,6 +148,28 @@ func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) { | |||
| 148 | 148 | } | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | + | func TestShellExecutor_ExecuteWithStdin_Success(t *testing.T) { | |
| 152 | + | ctrl := gomock.NewController(t) | |
| 153 | + | defer ctrl.Finish() | |
| 154 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 155 | + | mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() | |
| 156 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 157 | + | mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() | |
| 158 | + | mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes() | |
| 159 | + | ||
| 160 | + | executor := NewShellExecutor(mockLogger) | |
| 161 | + | ctx := context.Background() | |
| 162 | + | ||
| 163 | + | output, err := executor.ExecuteWithStdin(ctx, "hello stdin", "cat") | |
| 164 | + | if err != nil { | |
| 165 | + | t.Fatalf("expected no error, got %v", err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | if string(output) != "hello stdin" { | |
| 169 | + | t.Errorf("expected stdin to be forwarded, got %q", string(output)) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 151 | 173 | func TestShellExecutor_Execute_ContextCancellation(t *testing.T) { | |
| 152 | 174 | ctrl := gomock.NewController(t) | |
| 153 | 175 | defer ctrl.Finish() | |
| @@ -148,6 +148,28 @@ func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) { | |||||
| 148 | } | 148 | } | ||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | + | func TestShellExecutor_ExecuteWithStdin_Success(t *testing.T) { | |||
| 152 | + | ctrl := gomock.NewController(t) | |||
| 153 | + | defer ctrl.Finish() | |||
| 154 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 155 | + | mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 156 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 157 | + | mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 158 | + | mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 159 | + | ||||
| 160 | + | executor := NewShellExecutor(mockLogger) | |||
| 161 | + | ctx := context.Background() | |||
| 162 | + | ||||
| 163 | + | output, err := executor.ExecuteWithStdin(ctx, "hello stdin", "cat") | |||
| 164 | + | if err != nil { | |||
| 165 | + | t.Fatalf("expected no error, got %v", err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | if string(output) != "hello stdin" { | |||
| 169 | + | t.Errorf("expected stdin to be forwarded, got %q", string(output)) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 151 | func TestShellExecutor_Execute_ContextCancellation(t *testing.T) { | 173 | func TestShellExecutor_Execute_ContextCancellation(t *testing.T) { | ||
| 152 | ctrl := gomock.NewController(t) | 174 | ctrl := gomock.NewController(t) | ||
| 153 | defer ctrl.Finish() | 175 | defer ctrl.Finish() | ||