Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ jobs:
- name: build and test
run: |
go get -v
# Increase timeout to 180s for container tests
go test -timeout=180s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
go test -timeout=300s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
go build -race
env:
Expand Down
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ These capture utilities are useful for testing functions that write directly to

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

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

## Install and update

Expand Down Expand Up @@ -179,6 +179,28 @@ func TestWithSSH(t *testing.T) {
// use ssh.Address() to get host:port
// default user is "test"
sshAddr := ssh.Address()

// Upload a file to the SSH server
localFile := "/path/to/local/file.txt"
remotePath := "/config/file.txt"
err := ssh.SaveFile(ctx, localFile, remotePath)
require.NoError(t, err)

// Download a file from the SSH server
downloadPath := "/path/to/download/location.txt"
err = ssh.GetFile(ctx, remotePath, downloadPath)
require.NoError(t, err)

// List files on the SSH server
files, err := ssh.ListFiles(ctx, "/config")
require.NoError(t, err)
for _, file := range files {
fmt.Println(file.Name(), file.Mode(), file.Size())
}

// Delete a file from the SSH server
err = ssh.DeleteFile(ctx, remotePath)
require.NoError(t, err)
}

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

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

// put object example
// put object example (using direct S3 API)
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("test-key"),
Body: strings.NewReader("test content"),
})
require.NoError(t, err)

// File operations using higher-level container methods

// Upload a file to S3
localFile := "/path/to/local/file.txt"
objectKey := "documents/file.txt"
err = ls.SaveFile(ctx, localFile, bucketName, objectKey)
require.NoError(t, err)

// Download a file from S3
downloadPath := "/path/to/download/location.txt"
err = ls.GetFile(ctx, bucketName, objectKey, downloadPath)
require.NoError(t, err)

// List objects in bucket (optionally with prefix)
objects, err := ls.ListFiles(ctx, bucketName, "documents/")
require.NoError(t, err)
for _, obj := range objects {
fmt.Println(*obj.Key, *obj.Size)
}

// Delete an object from S3
err = ls.DeleteFile(ctx, bucketName, objectKey)
require.NoError(t, err)
}

// FTP test container
Expand Down Expand Up @@ -227,5 +273,9 @@ func TestWithFTP(t *testing.T) {
for _, entry := range entries {
fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory
}

// Delete a file
err = ftpContainer.DeleteFile(ctx, remotePath)
require.NoError(t, err)
}
```
21 changes: 20 additions & 1 deletion containers/ftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
fixedHostControlPort = "2121"
)

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

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

// DeleteFile deletes a file from the FTP server
func (fc *FTPTestContainer) DeleteFile(ctx context.Context, remotePath string) error {
c, err := fc.connect(ctx)
if err != nil {
return fmt.Errorf("failed to connect to FTP server for DeleteFile: %w", err)
}
defer func() {
if quitErr := c.Quit(); quitErr != nil {
fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
}
}()

cleanRemotePath := filepath.ToSlash(remotePath)
if err := c.Delete(cleanRemotePath); err != nil {
return fmt.Errorf("failed to delete remote file %s: %w", cleanRemotePath, err)
}
return nil
}

// Close terminates the container
func (fc *FTPTestContainer) Close(ctx context.Context) error {
if fc.Container != nil {
Expand Down
59 changes: 59 additions & 0 deletions containers/ftp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,62 @@ func TestSplitPath(t *testing.T) {
})
}
}

// TestFTPDeleteFile tests the DeleteFile method for the FTP container
func TestFTPDeleteFile(t *testing.T) {
if testing.Short() {
t.Skip("skipping FTP delete file test in short mode")
}
if os.Getenv("CI") != "" && os.Getenv("RUN_FTP_TESTS_ON_CI") == "" {
t.Skip("skipping FTP delete file test in CI environment unless RUN_FTP_TESTS_ON_CI is set")
}

ctx := context.Background()
ftpContainer := NewFTPTestContainer(ctx, t)
defer func() { assert.NoError(t, ftpContainer.Close(context.Background())) }()

// create a temporary directory and test file
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "delete-test.txt")
testContent := "This file will be deleted"
require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0o600))

// upload the file
remotePath := "/ftp/ftpuser/delete-test.txt"
err := ftpContainer.SaveFile(ctx, testFile, remotePath)
require.NoError(t, err, "Failed to upload file for deletion test")

// verify file exists on FTP server
entries, err := ftpContainer.ListFiles(ctx, "/ftp/ftpuser")
require.NoError(t, err)

found := false
for _, entry := range entries {
if entry.Name == "delete-test.txt" {
found = true
break
}
}
require.True(t, found, "File should exist before deletion")

// delete the file
err = ftpContainer.DeleteFile(ctx, remotePath)
require.NoError(t, err, "Failed to delete file")

// verify file no longer exists
entries, err = ftpContainer.ListFiles(ctx, "/ftp/ftpuser")
require.NoError(t, err)

found = false
for _, entry := range entries {
if entry.Name == "delete-test.txt" {
found = true
break
}
}
require.False(t, found, "File should have been deleted")

// test deleting a non-existent file
err = ftpContainer.DeleteFile(ctx, "non-existent-file.txt")
require.Error(t, err, "Deleting a non-existent file should return an error")
}
139 changes: 139 additions & 0 deletions containers/localstack.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package containers

import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
Expand All @@ -11,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
Expand Down Expand Up @@ -79,6 +85,139 @@ func (lc *LocalstackTestContainer) MakeS3Connection(ctx context.Context, t *test
return client, bucketName
}

// createS3Client creates an S3 client connected to the test container
func (lc *LocalstackTestContainer) createS3Client(ctx context.Context) (*s3.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}

client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(lc.Endpoint)
o.UsePathStyle = true
})

return client, nil
}

// GetFile downloads a file from S3
func (lc *LocalstackTestContainer) GetFile(ctx context.Context, bucketName, objectKey, localPath string) error {
client, err := lc.createS3Client(ctx)
if err != nil {
return fmt.Errorf("failed to create S3 client: %w", err)
}

localDir := filepath.Dir(localPath)
if err := os.MkdirAll(localDir, 0o750); err != nil {
return fmt.Errorf("failed to create local directory %s: %w", localDir, err)
}

if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(localDir)) {
return fmt.Errorf("localPath %s attempts to escape from directory %s", localPath, localDir)
}

// get object from S3
output, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
return fmt.Errorf("failed to get object %s from bucket %s: %w", objectKey, bucketName, err)
}
defer output.Body.Close()

// create local file
file, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create local file %s: %w", localPath, err)
}
defer file.Close()

// copy object content to local file
if _, err := io.Copy(file, output.Body); err != nil {
return fmt.Errorf("failed to copy content from S3 object to local file: %w", err)
}

return nil
}

// SaveFile uploads a file to S3
func (lc *LocalstackTestContainer) SaveFile(ctx context.Context, localPath, bucketName, objectKey string) error {
client, err := lc.createS3Client(ctx)
if err != nil {
return fmt.Errorf("failed to create S3 client: %w", err)
}

if !strings.HasPrefix(filepath.Clean(localPath), filepath.Clean(filepath.Dir(localPath))) {
return fmt.Errorf("localPath %s attempts to escape from its directory", localPath)
}

// read local file
fileData, err := os.ReadFile(localPath)
if err != nil {
return fmt.Errorf("failed to read local file %s: %w", localPath, err)
}

// upload to S3
_, err = client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: bytes.NewReader(fileData),
})
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
}

return nil
}

// ListFiles lists objects in an S3 bucket, optionally with a prefix
func (lc *LocalstackTestContainer) ListFiles(ctx context.Context, bucketName, prefix string) ([]types.Object, error) {
client, err := lc.createS3Client(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create S3 client: %w", err)
}

// list objects
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
}

// add prefix if provided
if prefix != "" {
input.Prefix = aws.String(prefix)
}

output, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}

return output.Contents, nil
}

// DeleteFile deletes an object from S3
func (lc *LocalstackTestContainer) DeleteFile(ctx context.Context, bucketName, objectKey string) error {
client, err := lc.createS3Client(ctx)
if err != nil {
return fmt.Errorf("failed to create S3 client: %w", err)
}

// delete object
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", objectKey, bucketName, err)
}

return nil
}

// Close terminates the container
func (lc *LocalstackTestContainer) Close(ctx context.Context) error {
return lc.Container.Terminate(ctx)
Expand Down
Loading
Loading