Skip to content

Commit 100906a

Browse files
committed
test: add S3 e2e tests with testcontainers-go and MinIO
Add end-to-end tests that exercise the S3 interactor against a real MinIO container started via testcontainers-go. Tests are guarded by //go:build e2e so they do not run during normal go test ./... Test cases: - Full lifecycle: post -> fetch -> put -> fetch -> delete -> fetch - ETag caching: verify 304 cache hit on unchanged objects - Prefix filtering: only prefixed .json files returned - Concurrent registration: 10 goroutines posting simultaneously - Unhealthy target filtering: old LastSeen round-trips correctly Also adds .github/workflows/e2e_s3.yml to run these tests in CI on changes to the S3 package or e2e/s3/ directory.
1 parent d1f8db1 commit 100906a

5 files changed

Lines changed: 521 additions & 3 deletions

File tree

.github/workflows/e2e_s3.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
name: Test - E2E S3
6+
7+
on:
8+
pull_request:
9+
paths:
10+
- "pkg/sparrow/targets/remote/s3/**"
11+
- "e2e/s3/**"
12+
- "go.mod"
13+
- "go.sum"
14+
push:
15+
paths:
16+
- "pkg/sparrow/targets/remote/s3/**"
17+
- "e2e/s3/**"
18+
- "go.mod"
19+
- "go.sum"
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
e2e_s3:
26+
runs-on: ubuntu-latest
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v6
31+
32+
- name: Set up Go
33+
uses: actions/setup-go@v6
34+
with:
35+
go-version-file: go.mod
36+
37+
- name: Run S3 E2E tests
38+
run: |
39+
go mod download
40+
go test -tags e2e -race -count=1 -v -timeout=5m ./e2e/s3/

e2e/s3/s3_test.go

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//go:build e2e
6+
7+
package s3_test
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"log"
13+
"os"
14+
"sync"
15+
"testing"
16+
"time"
17+
18+
minioclient "github.com/minio/minio-go/v7"
19+
"github.com/minio/minio-go/v7/pkg/credentials"
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
"github.com/testcontainers/testcontainers-go/modules/minio"
23+
24+
"github.com/telekom/sparrow/pkg/checks"
25+
"github.com/telekom/sparrow/pkg/sparrow/targets/remote"
26+
"github.com/telekom/sparrow/pkg/sparrow/targets/remote/s3"
27+
)
28+
29+
const (
30+
testBucket = "sparrow-targets"
31+
testUser = "minioadmin"
32+
testPassword = "minioadmin"
33+
)
34+
35+
var (
36+
testEndpoint string
37+
testCtx = context.Background()
38+
)
39+
40+
// TestMain starts a MinIO container, creates the test bucket,
41+
// and tears everything down after all tests complete.
42+
func TestMain(m *testing.M) {
43+
container, err := minio.Run(
44+
testCtx,
45+
"minio/minio:RELEASE.2024-01-16T16-07-38Z",
46+
minio.WithUsername(testUser),
47+
minio.WithPassword(testPassword),
48+
)
49+
if err != nil {
50+
log.Fatalf("failed to start MinIO container: %s", err)
51+
}
52+
53+
connStr, err := container.ConnectionString(testCtx)
54+
if err != nil {
55+
log.Fatalf("failed to get connection string: %s", err)
56+
}
57+
testEndpoint = connStr
58+
59+
// Create the test bucket
60+
mc, err := minioclient.New(testEndpoint, &minioclient.Options{
61+
Creds: credentials.NewStaticV4(testUser, testPassword, ""),
62+
Secure: false,
63+
})
64+
if err != nil {
65+
log.Fatalf("failed to create MinIO client: %s", err)
66+
}
67+
if err := mc.MakeBucket(testCtx, testBucket, minioclient.MakeBucketOptions{}); err != nil {
68+
log.Fatalf("failed to create bucket: %s", err)
69+
}
70+
71+
code := m.Run()
72+
73+
if err := container.Terminate(testCtx); err != nil {
74+
log.Printf("failed to terminate MinIO container: %s", err)
75+
}
76+
77+
os.Exit(code)
78+
}
79+
80+
// newInteractor creates a new S3 interactor pointing at the test MinIO.
81+
func newInteractor(t *testing.T, prefix string) remote.Interactor {
82+
t.Helper()
83+
useSSL := false
84+
i, err := s3.New(&s3.Config{
85+
Endpoint: testEndpoint,
86+
Bucket: testBucket,
87+
Region: "us-east-1",
88+
Prefix: prefix,
89+
UseSSL: &useSSL,
90+
Auth: s3.AuthConfig{
91+
Provider: "static",
92+
Static: s3.StaticAuthConfig{
93+
AccessKeyID: testUser,
94+
SecretAccessKey: testPassword,
95+
},
96+
},
97+
})
98+
require.NoError(t, err)
99+
return i
100+
}
101+
102+
// cleanBucket removes all objects from the bucket.
103+
func cleanBucket(t *testing.T) {
104+
t.Helper()
105+
mc, err := minioclient.New(testEndpoint, &minioclient.Options{
106+
Creds: credentials.NewStaticV4(testUser, testPassword, ""),
107+
Secure: false,
108+
})
109+
require.NoError(t, err)
110+
111+
for obj := range mc.ListObjects(testCtx, testBucket, minioclient.ListObjectsOptions{Recursive: true}) {
112+
require.NoError(t, obj.Err)
113+
err := mc.RemoveObject(testCtx, testBucket, obj.Key, minioclient.RemoveObjectOptions{})
114+
require.NoError(t, err)
115+
}
116+
}
117+
118+
// TestS3_FullLifecycle exercises the complete interactor lifecycle:
119+
// post -> fetch (present) -> put (update) -> fetch (updated) -> delete -> fetch (gone).
120+
func TestS3_FullLifecycle(t *testing.T) {
121+
cleanBucket(t)
122+
i := newInteractor(t, "")
123+
124+
now := time.Now().UTC().Truncate(time.Second)
125+
target := checks.GlobalTarget{
126+
Url: "https://sparrow-1.example.com",
127+
LastSeen: now,
128+
}
129+
130+
// Post
131+
err := i.PostFile(testCtx, remote.File{
132+
Name: "sparrow-1.json",
133+
Content: target,
134+
})
135+
require.NoError(t, err)
136+
137+
// Fetch — should find the target
138+
targets, err := i.FetchFiles(testCtx)
139+
require.NoError(t, err)
140+
require.Len(t, targets, 1)
141+
assert.Equal(t, target.Url, targets[0].Url)
142+
assert.Equal(t, target.LastSeen, targets[0].LastSeen)
143+
144+
// Put — update LastSeen
145+
updatedTime := now.Add(5 * time.Minute)
146+
target.LastSeen = updatedTime
147+
err = i.PutFile(testCtx, remote.File{
148+
Name: "sparrow-1.json",
149+
Content: target,
150+
})
151+
require.NoError(t, err)
152+
153+
// Fetch — should see updated LastSeen
154+
targets, err = i.FetchFiles(testCtx)
155+
require.NoError(t, err)
156+
require.Len(t, targets, 1)
157+
assert.Equal(t, updatedTime, targets[0].LastSeen)
158+
159+
// Delete
160+
err = i.DeleteFile(testCtx, remote.File{Name: "sparrow-1.json"})
161+
require.NoError(t, err)
162+
163+
// Fetch — should be empty
164+
targets, err = i.FetchFiles(testCtx)
165+
require.NoError(t, err)
166+
assert.Empty(t, targets)
167+
}
168+
169+
// TestS3_ETagCaching verifies that a second FetchFiles returns
170+
// cached data when the objects have not changed (304 Not Modified).
171+
func TestS3_ETagCaching(t *testing.T) {
172+
cleanBucket(t)
173+
i := newInteractor(t, "")
174+
175+
target := checks.GlobalTarget{
176+
Url: "https://sparrow-cached.example.com",
177+
LastSeen: time.Now().UTC().Truncate(time.Second),
178+
}
179+
180+
err := i.PostFile(testCtx, remote.File{
181+
Name: "sparrow-cached.json",
182+
Content: target,
183+
})
184+
require.NoError(t, err)
185+
186+
// First fetch — populates ETag cache
187+
targets1, err := i.FetchFiles(testCtx)
188+
require.NoError(t, err)
189+
require.Len(t, targets1, 1)
190+
191+
// Second fetch — should use cache (304)
192+
targets2, err := i.FetchFiles(testCtx)
193+
require.NoError(t, err)
194+
require.Len(t, targets2, 1)
195+
assert.Equal(t, targets1[0].Url, targets2[0].Url)
196+
assert.Equal(t, targets1[0].LastSeen, targets2[0].LastSeen)
197+
}
198+
199+
// TestS3_PrefixFiltering verifies that only objects under the
200+
// configured prefix are returned, and non-.json files are excluded.
201+
func TestS3_PrefixFiltering(t *testing.T) {
202+
cleanBucket(t)
203+
204+
// Create interactor with prefix
205+
prefixed := newInteractor(t, "env/prod")
206+
unprefixed := newInteractor(t, "")
207+
208+
target := checks.GlobalTarget{
209+
Url: "https://sparrow-prefixed.example.com",
210+
LastSeen: time.Now().UTC().Truncate(time.Second),
211+
}
212+
213+
// Post via prefixed interactor
214+
err := prefixed.PostFile(testCtx, remote.File{
215+
Name: "sparrow-a.json",
216+
Content: target,
217+
})
218+
require.NoError(t, err)
219+
220+
// Post via unprefixed interactor (different key space)
221+
err = unprefixed.PostFile(testCtx, remote.File{
222+
Name: "sparrow-b.json",
223+
Content: target,
224+
})
225+
require.NoError(t, err)
226+
227+
// Also create a non-json file under the prefix via raw client
228+
mc, err := minioclient.New(testEndpoint, &minioclient.Options{
229+
Creds: credentials.NewStaticV4(testUser, testPassword, ""),
230+
Secure: false,
231+
})
232+
require.NoError(t, err)
233+
234+
_, err = mc.PutObject(testCtx, testBucket, "env/prod/README.md",
235+
nil, 0, minioclient.PutObjectOptions{})
236+
require.NoError(t, err)
237+
238+
// Prefixed interactor should only see sparrow-a.json
239+
targets, err := prefixed.FetchFiles(testCtx)
240+
require.NoError(t, err)
241+
require.Len(t, targets, 1)
242+
assert.Equal(t, "https://sparrow-prefixed.example.com", targets[0].Url)
243+
244+
// Unprefixed interactor sees sparrow-b.json and
245+
// env/prod/sparrow-a.json (both are .json in the bucket)
246+
allTargets, err := unprefixed.FetchFiles(testCtx)
247+
require.NoError(t, err)
248+
assert.Len(t, allTargets, 2)
249+
}
250+
251+
// TestS3_ConcurrentRegistration verifies that multiple goroutines
252+
// can register simultaneously without data loss or errors.
253+
func TestS3_ConcurrentRegistration(t *testing.T) {
254+
cleanBucket(t)
255+
i := newInteractor(t, "concurrent")
256+
257+
const numWorkers = 10
258+
now := time.Now().UTC().Truncate(time.Second)
259+
260+
var wg sync.WaitGroup
261+
errs := make([]error, numWorkers)
262+
263+
for n := range numWorkers {
264+
wg.Add(1)
265+
go func(id int) {
266+
defer wg.Done()
267+
errs[id] = i.PostFile(testCtx, remote.File{
268+
Name: fmt.Sprintf("sparrow-%d.json", id),
269+
Content: checks.GlobalTarget{
270+
Url: fmt.Sprintf("https://sparrow-%d.example.com", id),
271+
LastSeen: now,
272+
},
273+
})
274+
}(n)
275+
}
276+
277+
wg.Wait()
278+
279+
for idx, err := range errs {
280+
assert.NoError(t, err, "worker %d failed", idx)
281+
}
282+
283+
// A separate interactor fetches (to avoid sharing cache state)
284+
fetcher := newInteractor(t, "concurrent")
285+
targets, err := fetcher.FetchFiles(testCtx)
286+
require.NoError(t, err)
287+
assert.Len(t, targets, numWorkers)
288+
}
289+
290+
// TestS3_UnhealthyTargetFiltering verifies that targets with old
291+
// LastSeen timestamps can be identified for filtering.
292+
// Note: the interactor itself does not filter — that is the manager's
293+
// job. This test confirms the data round-trips correctly for the
294+
// manager to make the filtering decision.
295+
func TestS3_UnhealthyTargetFiltering(t *testing.T) {
296+
cleanBucket(t)
297+
i := newInteractor(t, "")
298+
299+
now := time.Now().UTC().Truncate(time.Second)
300+
healthy := checks.GlobalTarget{
301+
Url: "https://healthy.example.com",
302+
LastSeen: now,
303+
}
304+
unhealthy := checks.GlobalTarget{
305+
Url: "https://unhealthy.example.com",
306+
LastSeen: now.Add(-24 * time.Hour),
307+
}
308+
309+
err := i.PostFile(testCtx, remote.File{
310+
Name: "healthy.json",
311+
Content: healthy,
312+
})
313+
require.NoError(t, err)
314+
315+
err = i.PostFile(testCtx, remote.File{
316+
Name: "unhealthy.json",
317+
Content: unhealthy,
318+
})
319+
require.NoError(t, err)
320+
321+
targets, err := i.FetchFiles(testCtx)
322+
require.NoError(t, err)
323+
require.Len(t, targets, 2)
324+
325+
// Simulate manager filtering with 1h threshold
326+
threshold := 1 * time.Hour
327+
var healthyTargets []checks.GlobalTarget
328+
for _, tgt := range targets {
329+
if time.Since(tgt.LastSeen) < threshold {
330+
healthyTargets = append(healthyTargets, tgt)
331+
}
332+
}
333+
334+
assert.Len(t, healthyTargets, 1)
335+
assert.Equal(t, "https://healthy.example.com", healthyTargets[0].Url)
336+
}

e2e/s3/test.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# S3 e2e tests require Docker (testcontainers-go).
7+
# Skip gracefully if Docker is not available.
8+
if ! command -v docker &>/dev/null || ! docker info &>/dev/null 2>&1; then
9+
echo "[ SKIP ]: Docker not available, skipping S3 e2e tests"
10+
exit 0
11+
fi
12+
13+
cd "$(git rev-parse --show-toplevel)" || exit 1
14+
go test -tags e2e -race -count=1 -v -timeout=5m ./e2e/s3/

0 commit comments

Comments
 (0)