• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

go-pkgz / testutils / 14371540395

10 Apr 2025 03:18AM UTC coverage: 70.031% (-2.2%) from 72.207%
14371540395

push

github

web-flow
Add file operations to all container types (#4)

* Add DeleteFile methods to containers and standardize file operations

- Add DeleteFile method to FTP container
- Add file operations (get, save, list, delete) to SSH container
- Add file operations (get, save, list, delete) to Localstack (S3) container
- Add comprehensive tests for all new methods
- Update README with examples for all file operations

* Increase test timeout to 300s for container tests

164 of 256 new or added lines in 3 files covered. (64.06%)

107 existing lines in 3 files now uncovered.

680 of 971 relevant lines covered (70.03%)

3.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

38.8
/containers/ftp.go
1
// Package containers implements various test containers for integration testing
2
package containers
3

4
import (
5
        "context"
6
        "fmt"
7
        "io"
8
        "os"
9
        "path/filepath"
10
        "strings"
11
        "testing"
12
        "time"
13

14
        "github.com/docker/go-connections/nat"
15
        "github.com/jlaffaye/ftp"
16
        "github.com/stretchr/testify/require"
17
        "github.com/testcontainers/testcontainers-go"
18
        "github.com/testcontainers/testcontainers-go/wait"
19
)
20

21
// FTPTestContainer is a wrapper around a testcontainers.Container that provides an FTP server
22
// for testing purposes. It allows file uploads, downloads, and directory operations.
23
type FTPTestContainer struct {
24
        Container testcontainers.Container
25
        Host      string
26
        Port      nat.Port // represents the *host* port struct
27
        User      string
28
        Password  string
29
}
30

31
// NewFTPTestContainer uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax.
32
func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
1✔
33
        const (
1✔
34
                defaultUser          = "ftpuser"
1✔
35
                defaultPassword      = "ftppass"
1✔
36
                pasvMinPort          = "21000" // default passive port range for the image
1✔
37
                pasvMaxPort          = "21010"
1✔
38
                fixedHostControlPort = "2121"
1✔
39
        )
1✔
40

1✔
41
        // set up logging for testcontainers if the appropriate API is available
1✔
42
        t.Logf("Setting up FTP test container")
1✔
43

1✔
44
        pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort)
1✔
45
        pasvPortRangeHost := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) // map 1:1
1✔
46
        exposedPortsWithBinding := []string{
1✔
47
                fmt.Sprintf("%s:21/tcp", fixedHostControlPort),                      // "2121:21/tcp"
1✔
48
                fmt.Sprintf("%s:%s/tcp", pasvPortRangeHost, pasvPortRangeContainer), // "21000-21010:21000-21010/tcp"
1✔
49
        }
1✔
50

1✔
51
        imageName := "delfer/alpine-ftp-server:latest"
1✔
52
        t.Logf("Using FTP server image: %s", imageName)
1✔
53

1✔
54
        req := testcontainers.ContainerRequest{
1✔
55
                Image:        imageName,
1✔
56
                ExposedPorts: exposedPortsWithBinding,
1✔
57
                Env: map[string]string{
1✔
58
                        "USERS": fmt.Sprintf("%s|%s", defaultUser, defaultPassword),
1✔
59
                },
1✔
60
                WaitingFor: wait.ForListeningPort(nat.Port("21/tcp")).WithStartupTimeout(2 * time.Minute),
1✔
61
        }
1✔
62

1✔
63
        t.Logf("creating FTP container using %s (minimal env vars, fixed host port %s)...", imageName, fixedHostControlPort)
1✔
64
        container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
1✔
65
                ContainerRequest: req,
1✔
66
                Started:          true,
1✔
67
        })
1✔
68
        // create the container instance to use its methods
1✔
69
        ftpContainer := &FTPTestContainer{}
1✔
70

1✔
71
        // error handling with detailed logging for container startup issues
1✔
72
        if err != nil {
1✔
73
                ftpContainer.logContainerError(ctx, t, container, err, imageName)
×
74
        }
×
75
        t.Logf("FTP container created and started (ID: %s)", container.GetContainerID())
1✔
76

1✔
77
        host, err := container.Host(ctx)
1✔
78
        require.NoError(t, err, "Failed to get container host")
1✔
79

1✔
80
        // since we requested a fixed port, construct the nat.Port struct directly
1✔
81
        // we still call MappedPort just to ensure the container is properly exposing *something* for port 21
1✔
82
        _, err = container.MappedPort(ctx, "21")
1✔
83
        require.NoError(t, err, "Failed to get mapped port info for container port 21/tcp (even though fixed)")
1✔
84

1✔
85
        // construct the Port struct based on our fixed request
1✔
86
        fixedHostNatPort, err := nat.NewPort("tcp", fixedHostControlPort)
1✔
87
        require.NoError(t, err, "Failed to create nat.Port for fixed host port")
1✔
88

1✔
89
        t.Logf("FTP container should be accessible at: %s:%s (Control Plane)", host, fixedHostControlPort)
1✔
90
        t.Logf("FTP server using default config, passive ports %s mapped to host %s", pasvPortRangeContainer, pasvPortRangeHost)
1✔
91

1✔
92
        time.Sleep(1 * time.Second)
1✔
93

1✔
94
        return &FTPTestContainer{
1✔
95
                Container: container,
1✔
96
                Host:      host,
1✔
97
                Port:      fixedHostNatPort, // use the manually constructed nat.Port for the fixed host port
1✔
98
                User:      defaultUser,
1✔
99
                Password:  defaultPassword,
1✔
100
        }
1✔
101
}
102

103
// connect function (Use default EPSV enabled)
104
func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error) {
1✔
105
        opts := []ftp.DialOption{
1✔
106
                ftp.DialWithTimeout(30 * time.Second),
1✔
107
                ftp.DialWithContext(ctx),
1✔
108
                ftp.DialWithDebugOutput(os.Stdout), // keep for debugging
1✔
109
                // *** Use default (EPSV enabled) ***
1✔
110
                // ftp.DialWithDisabledEPSV(true),
1✔
111
        }
1✔
112

1✔
113
        connStr := fc.ConnectionString() // will use the fixed host port (e.g., 2121)
1✔
114
        fmt.Printf("Attempting FTP connection to: %s (User: %s)\n", connStr, fc.User)
1✔
115

1✔
116
        c, err := ftp.Dial(connStr, opts...)
1✔
117
        if err != nil {
1✔
118
                fmt.Printf("FTP Dial Error to %s: %v\n", connStr, err)
×
119
                return nil, fmt.Errorf("failed to dial FTP server %s: %w", connStr, err)
×
120
        }
×
121
        fmt.Printf("FTP Dial successful to %s\n", connStr)
1✔
122

1✔
123
        fmt.Printf("Attempting FTP login with user: %s\n", fc.User)
1✔
124
        if err := c.Login(fc.User, fc.Password); err != nil {
1✔
125
                fmt.Printf("FTP Login Error for user %s: %v\n", fc.User, err)
×
126
                if quitErr := c.Quit(); quitErr != nil {
×
127
                        fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
×
128
                }
×
129
                return nil, fmt.Errorf("failed to login to FTP server with user %s: %w", fc.User, err)
×
130
        }
131
        fmt.Printf("FTP Login successful for user %s\n", fc.User)
1✔
132

1✔
133
        return c, nil
1✔
134
}
135

136
// createDirRecursive creates directories recursively relative to the current working directory.
137
func (fc *FTPTestContainer) createDirRecursive(c *ftp.ServerConn, remotePath string) error {
×
138
        parts := splitPath(remotePath)
×
139
        if len(parts) == 0 {
×
140
                return nil
×
141
        }
×
142

143
        // get current directory and setup return to it after the operation
144
        originalWD, err := fc.saveCurrentDirectory(c)
×
145
        if err == nil {
×
146
                defer fc.restoreWorkingDirectory(c, originalWD)
×
147
        }
×
148

149
        // process each directory in the path
150
        for _, part := range parts {
×
151
                if err := fc.ensureDirectoryExists(c, part); err != nil {
×
152
                        return err
×
153
                }
×
154
        }
155

156
        fmt.Printf("Finished ensuring directory structure for: %s\n", remotePath)
×
157
        return nil
×
158
}
159

160
// saveCurrentDirectory gets the current working directory and handles any errors
161
func (fc *FTPTestContainer) saveCurrentDirectory(c *ftp.ServerConn) (string, error) {
×
162
        originalWD, err := c.CurrentDir()
×
163
        if err != nil {
×
164
                fmt.Printf("Warning: failed to get current directory: %v. Will not return to original WD.\n", err)
×
165
                return "", err
×
166
        }
×
167
        return originalWD, nil
×
168
}
169

170
// restoreWorkingDirectory attempts to return to the original working directory
171
func (fc *FTPTestContainer) restoreWorkingDirectory(c *ftp.ServerConn, originalWD string) {
×
172
        if originalWD == "" {
×
173
                return
×
174
        }
×
175

176
        fmt.Printf("Returning to original directory: %s\n", originalWD)
×
177
        if err := c.ChangeDir(originalWD); err != nil {
×
178
                // try to go to root first and then to the original directory
×
179
                _ = c.ChangeDir("/")
×
180
                if err2 := c.ChangeDir(originalWD); err2 != nil {
×
181
                        fmt.Printf("Warning: failed to return to original directory '%s' (even from root): %v\n", originalWD, err2)
×
182
                }
×
183
        }
184
}
185

186
// ensureDirectoryExists checks if a directory exists, creates it if needed, and changes into it
187
func (fc *FTPTestContainer) ensureDirectoryExists(c *ftp.ServerConn, dirName string) error {
×
188
        fmt.Printf("Checking/Changing into directory segment: %s (relative to current)\n", dirName)
×
189

×
190
        // first try to change into the directory to see if it exists
×
191
        if err := c.ChangeDir(dirName); err == nil {
×
192
                fmt.Printf("Directory %s exists, changed into it.\n", dirName)
×
193
                return nil
×
194
        }
×
195

196
        // directory doesn't exist or can't be accessed, try to create it
197
        fmt.Printf("Directory %s does not exist or ChangeDir failed, attempting MakeDir...\n", dirName)
×
198
        if err := c.MakeDir(dirName); err != nil {
×
199
                return fc.handleMakeDirFailure(c, dirName, err)
×
200
        }
×
201

202
        // successfully created the directory, now change into it
203
        fmt.Printf("MakeDir %s succeeded. Changing into it...\n", dirName)
×
204
        if err := c.ChangeDir(dirName); err != nil {
×
205
                return fmt.Errorf("failed to change into newly created directory '%s': %w", dirName, err)
×
206
        }
×
207

208
        fmt.Printf("Successfully changed into newly created directory: %s\n", dirName)
×
209
        return nil
×
210
}
211

212
// handleMakeDirFailure handles the case where MakeDir fails
213
// (often because the directory already exists but ChangeDir initially failed for some reason)
214
func (fc *FTPTestContainer) handleMakeDirFailure(c *ftp.ServerConn, dirName string, makeDirErr error) error {
2✔
215
        fmt.Printf("MakeDir %s failed: %v. Checking again with ChangeDir...\n", dirName, makeDirErr)
2✔
216
        if err := c.ChangeDir(dirName); err != nil {
3✔
217
                return fmt.Errorf("failed to create directory '%s' (MakeDir error: %w) and "+
1✔
218
                        "failed to change into it subsequently (ChangeDir error: %w)",
1✔
219
                        dirName, makeDirErr, err)
1✔
220
        }
1✔
221

222
        fmt.Printf("ChangeDir %s succeeded after MakeDir failed. Assuming directory existed.\n", dirName)
1✔
223
        return nil
1✔
224
}
225

226
// ConnectionString returns the FTP connection string for this container
227
func (fc *FTPTestContainer) ConnectionString() string {
1✔
228
        return fmt.Sprintf("%s:%d", fc.Host, fc.Port.Int())
1✔
229
}
1✔
230

231
// GetIP returns the host IP address
232
func (fc *FTPTestContainer) GetIP() string { return fc.Host }
×
233

234
// GetPort returns the mapped port
235
func (fc *FTPTestContainer) GetPort() int { return fc.Port.Int() }
×
236

237
// GetUser returns the FTP username
238
func (fc *FTPTestContainer) GetUser() string { return fc.User }
×
239

240
// GetPassword returns the FTP password
241
func (fc *FTPTestContainer) GetPassword() string { return fc.Password }
×
242

243
// GetFile downloads a file from the FTP server
244
func (fc *FTPTestContainer) GetFile(ctx context.Context, remotePath, localPath string) error {
×
245
        c, err := fc.connect(ctx)
×
246
        if err != nil {
×
247
                return fmt.Errorf("failed to connect to FTP server for GetFile: %w", err)
×
248
        }
×
249
        defer func() {
×
250
                if quitErr := c.Quit(); quitErr != nil {
×
251
                        fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
×
252
                }
×
253
        }()
254
        localDir := filepath.Dir(localPath)
×
255
        if err := os.MkdirAll(localDir, 0o750); err != nil {
×
256
                return fmt.Errorf("failed to create local directory %s: %w", localDir, err)
×
257
        }
×
258
        // create file with secure permissions, validating path is within expected directory
259
        if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(localDir)) {
×
260
                return fmt.Errorf("localPath %s attempts to escape from directory %s", localPath, localDir)
×
261
        }
×
262
        f, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
×
263
        if err != nil {
×
264
                return fmt.Errorf("failed to create local file %s: %w", localPath, err)
×
265
        }
×
266
        defer f.Close()
×
267
        r, err := c.Retr(filepath.ToSlash(remotePath))
×
268
        if err != nil {
×
269
                return fmt.Errorf("failed to retrieve remote file %s: %w", remotePath, err)
×
270
        }
×
271
        defer r.Close()
×
272
        if _, err := io.Copy(f, r); err != nil {
×
273
                return fmt.Errorf("failed to copy file content from %s to %s: %w", remotePath, localPath, err)
×
274
        }
×
275
        return nil
×
276
}
277

278
// SaveFile uploads a file to the FTP server
279
func (fc *FTPTestContainer) SaveFile(ctx context.Context, localPath, remotePath string) error {
×
280
        c, err := fc.connect(ctx)
×
281
        if err != nil {
×
282
                return fmt.Errorf("failed to connect to FTP server for SaveFile: %w", err)
×
283
        }
×
284
        defer func() {
×
285
                if quitErr := c.Quit(); quitErr != nil {
×
286
                        fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
×
287
                }
×
288
        }()
289
        // validate the file path to prevent path traversal
290
        if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(filepath.Dir(localPath))) {
×
291
                return fmt.Errorf("localPath %s attempts to escape from its directory", localPath)
×
292
        }
×
293
        f, err := os.Open(localPath)
×
294
        if err != nil {
×
295
                return fmt.Errorf("failed to open local file %s: %w", localPath, err)
×
296
        }
×
297
        defer f.Close()
×
298
        remoteDir := filepath.Dir(filepath.ToSlash(remotePath))
×
299
        if remoteDir != "." && remoteDir != "/" {
×
300
                if err := fc.createDirRecursive(c, remoteDir); err != nil {
×
301
                        return fmt.Errorf("failed to create remote directory %s: %w", remoteDir, err)
×
302
                }
×
303
        }
304
        if err := c.Stor(filepath.ToSlash(remotePath), f); err != nil {
×
305
                return fmt.Errorf("failed to store file %s as %s: %w", localPath, remotePath, err)
×
306
        }
×
307
        return nil
×
308
}
309

310
// ListFiles lists files in a directory on the FTP server
311
func (fc *FTPTestContainer) ListFiles(ctx context.Context, remotePath string) ([]ftp.Entry, error) {
×
312
        c, err := fc.connect(ctx)
×
313
        if err != nil {
×
314
                return nil, fmt.Errorf("failed to connect to FTP server for ListFiles: %w", err)
×
315
        }
×
316
        defer func() {
×
317
                if quitErr := c.Quit(); quitErr != nil {
×
318
                        fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
×
319
                }
×
320
        }()
321
        cleanRemotePath := filepath.ToSlash(remotePath)
×
322
        if cleanRemotePath == "" || cleanRemotePath == "." {
×
323
                cleanRemotePath = "/"
×
324
        }
×
325
        ptrEntries, err := c.List(cleanRemotePath)
×
326
        if err != nil {
×
327
                return nil, fmt.Errorf("failed to list files in remote path '%s': %w", cleanRemotePath, err)
×
328
        }
×
329
        entries := make([]ftp.Entry, 0, len(ptrEntries))
×
330
        for _, entry := range ptrEntries {
×
331
                if entry != nil {
×
332
                        entries = append(entries, *entry)
×
333
                }
×
334
        }
335
        return entries, nil
×
336
}
337

338
// DeleteFile deletes a file from the FTP server
NEW
UNCOV
339
func (fc *FTPTestContainer) DeleteFile(ctx context.Context, remotePath string) error {
×
NEW
UNCOV
340
        c, err := fc.connect(ctx)
×
NEW
UNCOV
341
        if err != nil {
×
NEW
UNCOV
342
                return fmt.Errorf("failed to connect to FTP server for DeleteFile: %w", err)
×
NEW
UNCOV
343
        }
×
NEW
UNCOV
344
        defer func() {
×
NEW
UNCOV
345
                if quitErr := c.Quit(); quitErr != nil {
×
NEW
346
                        fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
×
NEW
347
                }
×
348
        }()
349

NEW
UNCOV
350
        cleanRemotePath := filepath.ToSlash(remotePath)
×
NEW
UNCOV
351
        if err := c.Delete(cleanRemotePath); err != nil {
×
NEW
UNCOV
352
                return fmt.Errorf("failed to delete remote file %s: %w", cleanRemotePath, err)
×
NEW
UNCOV
353
        }
×
NEW
UNCOV
354
        return nil
×
355
}
356

357
// Close terminates the container
358
func (fc *FTPTestContainer) Close(ctx context.Context) error {
1✔
359
        if fc.Container != nil {
2✔
360
                containerID := fc.Container.GetContainerID()
1✔
361
                fmt.Printf("terminating FTP container %s...\n", containerID)
1✔
362
                err := fc.Container.Terminate(ctx)
1✔
363
                fc.Container = nil
1✔
364
                if err != nil {
1✔
365
                        fmt.Printf("error terminating FTP container %s: %v\n", containerID, err)
×
366
                        return err
×
367
                }
×
368
                fmt.Printf("FTP container %s terminated.\n", containerID)
1✔
369
        }
370
        return nil
1✔
371
}
372
func splitPath(path string) []string {
6✔
373
        cleanPath := filepath.ToSlash(path)
6✔
374
        cleanPath = strings.Trim(cleanPath, "/")
6✔
375
        if cleanPath == "" {
8✔
376
                return []string{}
2✔
377
        }
2✔
378
        return strings.Split(cleanPath, "/")
4✔
379
}
380

381
// logContainerError handles container startup errors with detailed logging
382
func (fc *FTPTestContainer) logContainerError(_ context.Context, t *testing.T, container testcontainers.Container, err error, imageName string) {
×
UNCOV
383
        logCtx, logCancel := context.WithTimeout(context.Background(), 10*time.Second)
×
384
        defer logCancel()
×
385

×
386
        fc.logContainerLogs(logCtx, t, container)
×
387
        require.NoError(t, err, "Failed to create or start FTP container %s", imageName)
×
UNCOV
388
}
×
389

390
// logContainerLogs attempts to fetch and log container logs
UNCOV
391
func (fc *FTPTestContainer) logContainerLogs(ctx context.Context, t *testing.T, container testcontainers.Container) {
×
UNCOV
392
        if container == nil {
×
UNCOV
393
                t.Logf("Container object was nil after GenericContainer failure.")
×
UNCOV
394
                return
×
UNCOV
395
        }
×
396

UNCOV
397
        logs, logErr := container.Logs(ctx)
×
UNCOV
398
        if logErr != nil {
×
UNCOV
399
                t.Logf("Could not retrieve container logs after startup failure: %v", logErr)
×
UNCOV
400
                return
×
UNCOV
401
        }
×
402

UNCOV
403
        logBytes, _ := io.ReadAll(logs)
×
UNCOV
404
        if closeErr := logs.Close(); closeErr != nil {
×
UNCOV
405
                t.Logf("warning: failed to close logs reader: %v", closeErr)
×
UNCOV
406
        }
×
407

UNCOV
408
        t.Logf("Container logs on startup failure:\n%s", string(logBytes))
×
409
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc