Skip to content

Commit f3b522c

Browse files
authored
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
1 parent e4e2377 commit f3b522c

File tree

10 files changed

+708
-7
lines changed

10 files changed

+708
-7
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ jobs:
2323
- name: build and test
2424
run: |
2525
go get -v
26-
# Increase timeout to 180s for container tests
27-
go test -timeout=180s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
26+
go test -timeout=300s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
2827
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
2928
go build -race
3029
env:

README.md

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ These capture utilities are useful for testing functions that write directly to
2727

2828
The `containers` package provides several test containers for integration testing:
2929

30-
- `SSHTestContainer`: SSH server container for testing SSH connections and operations
31-
- `FTPTestContainer`: FTP server container for testing FTP file transfers and operations
30+
- `SSHTestContainer`: SSH server container for testing SSH connections with file operations (upload, download, list, delete)
31+
- `FTPTestContainer`: FTP server container for testing FTP file operations (upload, download, list, delete)
3232
- `PostgresTestContainer`: PostgreSQL database container with automatic database creation
3333
- `MySQLTestContainer`: MySQL database container with automatic database creation
3434
- `MongoTestContainer`: MongoDB container with support for multiple versions (5, 6, 7)
35-
- `LocalstackTestContainer`: LocalStack container with S3 service for AWS testing
35+
- `LocalstackTestContainer`: LocalStack container with S3 service for AWS testing, including file operations (upload, download, list, delete)
3636

3737
## Install and update
3838

@@ -179,6 +179,28 @@ func TestWithSSH(t *testing.T) {
179179
// use ssh.Address() to get host:port
180180
// default user is "test"
181181
sshAddr := ssh.Address()
182+
183+
// Upload a file to the SSH server
184+
localFile := "/path/to/local/file.txt"
185+
remotePath := "/config/file.txt"
186+
err := ssh.SaveFile(ctx, localFile, remotePath)
187+
require.NoError(t, err)
188+
189+
// Download a file from the SSH server
190+
downloadPath := "/path/to/download/location.txt"
191+
err = ssh.GetFile(ctx, remotePath, downloadPath)
192+
require.NoError(t, err)
193+
194+
// List files on the SSH server
195+
files, err := ssh.ListFiles(ctx, "/config")
196+
require.NoError(t, err)
197+
for _, file := range files {
198+
fmt.Println(file.Name(), file.Mode(), file.Size())
199+
}
200+
201+
// Delete a file from the SSH server
202+
err = ssh.DeleteFile(ctx, remotePath)
203+
require.NoError(t, err)
182204
}
183205

184206
// Localstack (S3) test container
@@ -189,13 +211,37 @@ func TestWithS3(t *testing.T) {
189211

190212
s3Client, bucketName := ls.MakeS3Connection(ctx, t)
191213

192-
// put object example
214+
// put object example (using direct S3 API)
193215
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
194216
Bucket: aws.String(bucketName),
195217
Key: aws.String("test-key"),
196218
Body: strings.NewReader("test content"),
197219
})
198220
require.NoError(t, err)
221+
222+
// File operations using higher-level container methods
223+
224+
// Upload a file to S3
225+
localFile := "/path/to/local/file.txt"
226+
objectKey := "documents/file.txt"
227+
err = ls.SaveFile(ctx, localFile, bucketName, objectKey)
228+
require.NoError(t, err)
229+
230+
// Download a file from S3
231+
downloadPath := "/path/to/download/location.txt"
232+
err = ls.GetFile(ctx, bucketName, objectKey, downloadPath)
233+
require.NoError(t, err)
234+
235+
// List objects in bucket (optionally with prefix)
236+
objects, err := ls.ListFiles(ctx, bucketName, "documents/")
237+
require.NoError(t, err)
238+
for _, obj := range objects {
239+
fmt.Println(*obj.Key, *obj.Size)
240+
}
241+
242+
// Delete an object from S3
243+
err = ls.DeleteFile(ctx, bucketName, objectKey)
244+
require.NoError(t, err)
199245
}
200246

201247
// FTP test container
@@ -227,5 +273,9 @@ func TestWithFTP(t *testing.T) {
227273
for _, entry := range entries {
228274
fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory
229275
}
276+
277+
// Delete a file
278+
err = ftpContainer.DeleteFile(ctx, remotePath)
279+
require.NoError(t, err)
230280
}
231281
```

containers/ftp.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
3838
fixedHostControlPort = "2121"
3939
)
4040

41-
// Set up logging for testcontainers if the appropriate API is available
41+
// set up logging for testcontainers if the appropriate API is available
4242
t.Logf("Setting up FTP test container")
4343

4444
pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort)
@@ -335,6 +335,25 @@ func (fc *FTPTestContainer) ListFiles(ctx context.Context, remotePath string) ([
335335
return entries, nil
336336
}
337337

338+
// DeleteFile deletes a file from the FTP server
339+
func (fc *FTPTestContainer) DeleteFile(ctx context.Context, remotePath string) error {
340+
c, err := fc.connect(ctx)
341+
if err != nil {
342+
return fmt.Errorf("failed to connect to FTP server for DeleteFile: %w", err)
343+
}
344+
defer func() {
345+
if quitErr := c.Quit(); quitErr != nil {
346+
fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
347+
}
348+
}()
349+
350+
cleanRemotePath := filepath.ToSlash(remotePath)
351+
if err := c.Delete(cleanRemotePath); err != nil {
352+
return fmt.Errorf("failed to delete remote file %s: %w", cleanRemotePath, err)
353+
}
354+
return nil
355+
}
356+
338357
// Close terminates the container
339358
func (fc *FTPTestContainer) Close(ctx context.Context) error {
340359
if fc.Container != nil {

containers/ftp_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,62 @@ func TestSplitPath(t *testing.T) {
328328
})
329329
}
330330
}
331+
332+
// TestFTPDeleteFile tests the DeleteFile method for the FTP container
333+
func TestFTPDeleteFile(t *testing.T) {
334+
if testing.Short() {
335+
t.Skip("skipping FTP delete file test in short mode")
336+
}
337+
if os.Getenv("CI") != "" && os.Getenv("RUN_FTP_TESTS_ON_CI") == "" {
338+
t.Skip("skipping FTP delete file test in CI environment unless RUN_FTP_TESTS_ON_CI is set")
339+
}
340+
341+
ctx := context.Background()
342+
ftpContainer := NewFTPTestContainer(ctx, t)
343+
defer func() { assert.NoError(t, ftpContainer.Close(context.Background())) }()
344+
345+
// create a temporary directory and test file
346+
tempDir := t.TempDir()
347+
testFile := filepath.Join(tempDir, "delete-test.txt")
348+
testContent := "This file will be deleted"
349+
require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0o600))
350+
351+
// upload the file
352+
remotePath := "/ftp/ftpuser/delete-test.txt"
353+
err := ftpContainer.SaveFile(ctx, testFile, remotePath)
354+
require.NoError(t, err, "Failed to upload file for deletion test")
355+
356+
// verify file exists on FTP server
357+
entries, err := ftpContainer.ListFiles(ctx, "/ftp/ftpuser")
358+
require.NoError(t, err)
359+
360+
found := false
361+
for _, entry := range entries {
362+
if entry.Name == "delete-test.txt" {
363+
found = true
364+
break
365+
}
366+
}
367+
require.True(t, found, "File should exist before deletion")
368+
369+
// delete the file
370+
err = ftpContainer.DeleteFile(ctx, remotePath)
371+
require.NoError(t, err, "Failed to delete file")
372+
373+
// verify file no longer exists
374+
entries, err = ftpContainer.ListFiles(ctx, "/ftp/ftpuser")
375+
require.NoError(t, err)
376+
377+
found = false
378+
for _, entry := range entries {
379+
if entry.Name == "delete-test.txt" {
380+
found = true
381+
break
382+
}
383+
}
384+
require.False(t, found, "File should have been deleted")
385+
386+
// test deleting a non-existent file
387+
err = ftpContainer.DeleteFile(ctx, "non-existent-file.txt")
388+
require.Error(t, err, "Deleting a non-existent file should return an error")
389+
}

containers/localstack.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package containers
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
611
"sync/atomic"
712
"testing"
813
"time"
@@ -11,6 +16,7 @@ import (
1116
"github.com/aws/aws-sdk-go-v2/config"
1217
"github.com/aws/aws-sdk-go-v2/credentials"
1318
"github.com/aws/aws-sdk-go-v2/service/s3"
19+
"github.com/aws/aws-sdk-go-v2/service/s3/types"
1420
"github.com/stretchr/testify/require"
1521
"github.com/testcontainers/testcontainers-go"
1622
"github.com/testcontainers/testcontainers-go/wait"
@@ -79,6 +85,139 @@ func (lc *LocalstackTestContainer) MakeS3Connection(ctx context.Context, t *test
7985
return client, bucketName
8086
}
8187

88+
// createS3Client creates an S3 client connected to the test container
89+
func (lc *LocalstackTestContainer) createS3Client(ctx context.Context) (*s3.Client, error) {
90+
cfg, err := config.LoadDefaultConfig(ctx,
91+
config.WithRegion("us-east-1"),
92+
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
93+
)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to load AWS config: %w", err)
96+
}
97+
98+
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
99+
o.BaseEndpoint = aws.String(lc.Endpoint)
100+
o.UsePathStyle = true
101+
})
102+
103+
return client, nil
104+
}
105+
106+
// GetFile downloads a file from S3
107+
func (lc *LocalstackTestContainer) GetFile(ctx context.Context, bucketName, objectKey, localPath string) error {
108+
client, err := lc.createS3Client(ctx)
109+
if err != nil {
110+
return fmt.Errorf("failed to create S3 client: %w", err)
111+
}
112+
113+
localDir := filepath.Dir(localPath)
114+
if err := os.MkdirAll(localDir, 0o750); err != nil {
115+
return fmt.Errorf("failed to create local directory %s: %w", localDir, err)
116+
}
117+
118+
if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(localDir)) {
119+
return fmt.Errorf("localPath %s attempts to escape from directory %s", localPath, localDir)
120+
}
121+
122+
// get object from S3
123+
output, err := client.GetObject(ctx, &s3.GetObjectInput{
124+
Bucket: aws.String(bucketName),
125+
Key: aws.String(objectKey),
126+
})
127+
if err != nil {
128+
return fmt.Errorf("failed to get object %s from bucket %s: %w", objectKey, bucketName, err)
129+
}
130+
defer output.Body.Close()
131+
132+
// create local file
133+
file, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
134+
if err != nil {
135+
return fmt.Errorf("failed to create local file %s: %w", localPath, err)
136+
}
137+
defer file.Close()
138+
139+
// copy object content to local file
140+
if _, err := io.Copy(file, output.Body); err != nil {
141+
return fmt.Errorf("failed to copy content from S3 object to local file: %w", err)
142+
}
143+
144+
return nil
145+
}
146+
147+
// SaveFile uploads a file to S3
148+
func (lc *LocalstackTestContainer) SaveFile(ctx context.Context, localPath, bucketName, objectKey string) error {
149+
client, err := lc.createS3Client(ctx)
150+
if err != nil {
151+
return fmt.Errorf("failed to create S3 client: %w", err)
152+
}
153+
154+
if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(filepath.Dir(localPath))) {
155+
return fmt.Errorf("localPath %s attempts to escape from its directory", localPath)
156+
}
157+
158+
// read local file
159+
fileData, err := os.ReadFile(localPath)
160+
if err != nil {
161+
return fmt.Errorf("failed to read local file %s: %w", localPath, err)
162+
}
163+
164+
// upload to S3
165+
_, err = client.PutObject(ctx, &s3.PutObjectInput{
166+
Bucket: aws.String(bucketName),
167+
Key: aws.String(objectKey),
168+
Body: bytes.NewReader(fileData),
169+
})
170+
if err != nil {
171+
return fmt.Errorf("failed to upload file to S3: %w", err)
172+
}
173+
174+
return nil
175+
}
176+
177+
// ListFiles lists objects in an S3 bucket, optionally with a prefix
178+
func (lc *LocalstackTestContainer) ListFiles(ctx context.Context, bucketName, prefix string) ([]types.Object, error) {
179+
client, err := lc.createS3Client(ctx)
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to create S3 client: %w", err)
182+
}
183+
184+
// list objects
185+
input := &s3.ListObjectsV2Input{
186+
Bucket: aws.String(bucketName),
187+
}
188+
189+
// add prefix if provided
190+
if prefix != "" {
191+
input.Prefix = aws.String(prefix)
192+
}
193+
194+
output, err := client.ListObjectsV2(ctx, input)
195+
if err != nil {
196+
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
197+
}
198+
199+
return output.Contents, nil
200+
}
201+
202+
// DeleteFile deletes an object from S3
203+
func (lc *LocalstackTestContainer) DeleteFile(ctx context.Context, bucketName, objectKey string) error {
204+
client, err := lc.createS3Client(ctx)
205+
if err != nil {
206+
return fmt.Errorf("failed to create S3 client: %w", err)
207+
}
208+
209+
// delete object
210+
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
211+
Bucket: aws.String(bucketName),
212+
Key: aws.String(objectKey),
213+
})
214+
if err != nil {
215+
return fmt.Errorf("failed to delete object %s from bucket %s: %w", objectKey, bucketName, err)
216+
}
217+
218+
return nil
219+
}
220+
82221
// Close terminates the container
83222
func (lc *LocalstackTestContainer) Close(ctx context.Context) error {
84223
return lc.Container.Terminate(ctx)

0 commit comments

Comments
 (0)