Skip to content

Commit aa615e5

Browse files
test(fetcher/s3): add unit tests for the S3 fetcher
Adds a package-level clientFactory var to s3.go (one-line production change) so tests can inject a fake httptest.Server in place of real AWS. 19 tests cover: Name(), missing-bucket/key validation, file download with filename derived from key base, dest-dir creation, all four checksum algorithms (SHA256/SHA512/SHA1/MD5) happy paths, checksum mismatch (returns error + removes partial file), SHA256 priority over MD5, server 500/404 error propagation. Test client sets RetryMaxAttempts=1 so 5xx tests don't wait on SDK back-off. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c26c26e commit aa615e5

2 files changed

Lines changed: 298 additions & 1 deletion

File tree

internal/fetcher/s3/s3.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@ type S3Fetcher struct{}
3838

3939
func (f *S3Fetcher) Name() string { return "s3" }
4040

41+
// clientFactory creates an S3 client; replaced in tests to inject a fake server.
42+
var clientFactory = newClient
43+
4144
func (f *S3Fetcher) Fetch(ctx context.Context, src *software.Source, destDir string) error {
4245
if src.S3Bucket == "" || src.S3Key == "" {
4346
return fmt.Errorf("s3 source requires both s3_bucket and s3_key")
4447
}
4548

46-
client, err := newClient(ctx, src)
49+
client, err := clientFactory(ctx, src)
4750
if err != nil {
4851
return fmt.Errorf("s3 client: %w", err)
4952
}

internal/fetcher/s3/s3_test.go

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package s3
2+
3+
import (
4+
"context"
5+
"crypto/md5" //nolint:gosec
6+
"crypto/sha1" //nolint:gosec
7+
"crypto/sha256"
8+
"crypto/sha512"
9+
"encoding/hex"
10+
"net/http"
11+
"net/http/httptest"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"testing"
16+
17+
"github.com/aws/aws-sdk-go-v2/aws"
18+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
19+
"github.com/aws/aws-sdk-go-v2/credentials"
20+
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
21+
22+
"github.com/syntaxroot-cc/gomnibus/internal/software"
23+
)
24+
25+
// ── Name ─────────────────────────────────────────────────────────────────────
26+
27+
func TestName(t *testing.T) {
28+
if (&S3Fetcher{}).Name() != "s3" {
29+
t.Error("Name: want s3")
30+
}
31+
}
32+
33+
// ── validation (no AWS call) ──────────────────────────────────────────────────
34+
35+
func TestFetch_MissingBucket_Error(t *testing.T) {
36+
err := (&S3Fetcher{}).Fetch(context.Background(), &software.Source{S3Key: "k"}, t.TempDir())
37+
if err == nil || !strings.Contains(err.Error(), "s3_bucket") {
38+
t.Errorf("expected s3_bucket error, got: %v", err)
39+
}
40+
}
41+
42+
func TestFetch_MissingKey_Error(t *testing.T) {
43+
err := (&S3Fetcher{}).Fetch(context.Background(), &software.Source{S3Bucket: "b"}, t.TempDir())
44+
if err == nil || !strings.Contains(err.Error(), "s3_key") {
45+
t.Errorf("expected s3_key error, got: %v", err)
46+
}
47+
}
48+
49+
func TestFetch_MissingBothBucketAndKey_Error(t *testing.T) {
50+
err := (&S3Fetcher{}).Fetch(context.Background(), &software.Source{}, t.TempDir())
51+
if err == nil {
52+
t.Error("expected error for missing bucket and key")
53+
}
54+
}
55+
56+
// ── fake-server helpers ───────────────────────────────────────────────────────
57+
58+
// fakeS3 starts an httptest server that returns statusCode and body for every
59+
// request, and patches clientFactory to use it. Returns a cleanup function.
60+
func fakeS3(t *testing.T, statusCode int, body []byte) (*httptest.Server, func()) {
61+
t.Helper()
62+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63+
w.WriteHeader(statusCode)
64+
w.Write(body) //nolint:errcheck
65+
}))
66+
67+
orig := clientFactory
68+
clientFactory = func(ctx context.Context, src *software.Source) (*awss3.Client, error) {
69+
cfg, err := awsconfig.LoadDefaultConfig(ctx,
70+
awsconfig.WithRegion("us-east-1"),
71+
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
72+
awsconfig.WithRetryMaxAttempts(1),
73+
)
74+
if err != nil {
75+
return nil, err
76+
}
77+
return awss3.NewFromConfig(cfg, func(o *awss3.Options) {
78+
o.BaseEndpoint = aws.String(srv.URL)
79+
o.UsePathStyle = true
80+
}), nil
81+
}
82+
83+
return srv, func() {
84+
srv.Close()
85+
clientFactory = orig
86+
}
87+
}
88+
89+
func src(bucket, key string, extra ...func(*software.Source)) *software.Source {
90+
s := &software.Source{S3Bucket: bucket, S3Key: key}
91+
for _, fn := range extra {
92+
fn(s)
93+
}
94+
return s
95+
}
96+
97+
// ── download ──────────────────────────────────────────────────────────────────
98+
99+
func TestFetch_DownloadsFileContents(t *testing.T) {
100+
body := []byte("hello from S3")
101+
_, cleanup := fakeS3(t, http.StatusOK, body)
102+
defer cleanup()
103+
104+
destDir := t.TempDir()
105+
if err := (&S3Fetcher{}).Fetch(context.Background(), src("my-bucket", "path/to/file.tar.gz"), destDir); err != nil {
106+
t.Fatalf("Fetch: %v", err)
107+
}
108+
109+
got, err := os.ReadFile(filepath.Join(destDir, "file.tar.gz"))
110+
if err != nil {
111+
t.Fatalf("ReadFile: %v", err)
112+
}
113+
if string(got) != string(body) {
114+
t.Errorf("content: got %q, want %q", got, body)
115+
}
116+
}
117+
118+
func TestFetch_FilenameFromKeyBase(t *testing.T) {
119+
_, cleanup := fakeS3(t, http.StatusOK, []byte("data"))
120+
defer cleanup()
121+
122+
destDir := t.TempDir()
123+
if err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "deep/nested/artifact.zip"), destDir); err != nil {
124+
t.Fatalf("Fetch: %v", err)
125+
}
126+
127+
if _, err := os.Stat(filepath.Join(destDir, "artifact.zip")); err != nil {
128+
t.Error("expected artifact.zip in dest dir")
129+
}
130+
}
131+
132+
func TestFetch_CreatesDestDir(t *testing.T) {
133+
_, cleanup := fakeS3(t, http.StatusOK, []byte("data"))
134+
defer cleanup()
135+
136+
destDir := filepath.Join(t.TempDir(), "new", "dest")
137+
if err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "k"), destDir); err != nil {
138+
t.Fatalf("Fetch: %v", err)
139+
}
140+
if _, err := os.Stat(destDir); err != nil {
141+
t.Error("dest dir should be created")
142+
}
143+
}
144+
145+
func TestFetch_KeyWithoutPath_UsesKeyAsFilename(t *testing.T) {
146+
_, cleanup := fakeS3(t, http.StatusOK, []byte("data"))
147+
defer cleanup()
148+
149+
destDir := t.TempDir()
150+
if err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "flatkey.tar.gz"), destDir); err != nil {
151+
t.Fatalf("Fetch: %v", err)
152+
}
153+
if _, err := os.Stat(filepath.Join(destDir, "flatkey.tar.gz")); err != nil {
154+
t.Error("expected flatkey.tar.gz in dest dir")
155+
}
156+
}
157+
158+
// ── checksum happy paths ──────────────────────────────────────────────────────
159+
160+
func TestFetch_NoChecksum_Succeeds(t *testing.T) {
161+
_, cleanup := fakeS3(t, http.StatusOK, []byte("any content"))
162+
defer cleanup()
163+
164+
if err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "k"), t.TempDir()); err != nil {
165+
t.Fatalf("no-checksum fetch: %v", err)
166+
}
167+
}
168+
169+
func TestFetch_SHA256_Match(t *testing.T) {
170+
body := []byte("sha256 content")
171+
h := sha256.Sum256(body)
172+
_, cleanup := fakeS3(t, http.StatusOK, body)
173+
defer cleanup()
174+
175+
s := src("b", "k", func(s *software.Source) { s.SHA256 = hex.EncodeToString(h[:]) })
176+
if err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir()); err != nil {
177+
t.Fatalf("SHA256 match: %v", err)
178+
}
179+
}
180+
181+
func TestFetch_SHA512_Match(t *testing.T) {
182+
body := []byte("sha512 content")
183+
h := sha512.Sum512(body)
184+
_, cleanup := fakeS3(t, http.StatusOK, body)
185+
defer cleanup()
186+
187+
s := src("b", "k", func(s *software.Source) { s.SHA512 = hex.EncodeToString(h[:]) })
188+
if err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir()); err != nil {
189+
t.Fatalf("SHA512 match: %v", err)
190+
}
191+
}
192+
193+
func TestFetch_SHA1_Match(t *testing.T) {
194+
body := []byte("sha1 content")
195+
h := sha1.Sum(body) //nolint:gosec
196+
_, cleanup := fakeS3(t, http.StatusOK, body)
197+
defer cleanup()
198+
199+
s := src("b", "k", func(s *software.Source) { s.SHA1 = hex.EncodeToString(h[:]) })
200+
if err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir()); err != nil {
201+
t.Fatalf("SHA1 match: %v", err)
202+
}
203+
}
204+
205+
func TestFetch_MD5_Match(t *testing.T) {
206+
body := []byte("md5 content")
207+
h := md5.Sum(body) //nolint:gosec
208+
_, cleanup := fakeS3(t, http.StatusOK, body)
209+
defer cleanup()
210+
211+
s := src("b", "k", func(s *software.Source) { s.MD5 = hex.EncodeToString(h[:]) })
212+
if err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir()); err != nil {
213+
t.Fatalf("MD5 match: %v", err)
214+
}
215+
}
216+
217+
// ── checksum mismatch ─────────────────────────────────────────────────────────
218+
219+
func TestFetch_SHA256_Mismatch_ReturnsError(t *testing.T) {
220+
_, cleanup := fakeS3(t, http.StatusOK, []byte("content"))
221+
defer cleanup()
222+
223+
s := src("b", "k", func(s *software.Source) { s.SHA256 = "badhash" })
224+
err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir())
225+
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
226+
t.Errorf("expected checksum mismatch error, got: %v", err)
227+
}
228+
}
229+
230+
func TestFetch_SHA256_Mismatch_RemovesPartialFile(t *testing.T) {
231+
_, cleanup := fakeS3(t, http.StatusOK, []byte("content"))
232+
defer cleanup()
233+
234+
destDir := t.TempDir()
235+
s := src("b", "k", func(s *software.Source) { s.SHA256 = "badhash" })
236+
(&S3Fetcher{}).Fetch(context.Background(), s, destDir) //nolint:errcheck
237+
238+
entries, _ := os.ReadDir(destDir)
239+
if len(entries) != 0 {
240+
t.Errorf("partial file should be removed after checksum mismatch; found %d entries", len(entries))
241+
}
242+
}
243+
244+
func TestFetch_MD5_Mismatch_ReturnsError(t *testing.T) {
245+
_, cleanup := fakeS3(t, http.StatusOK, []byte("content"))
246+
defer cleanup()
247+
248+
s := src("b", "k", func(s *software.Source) { s.MD5 = "deadbeef" })
249+
err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir())
250+
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
251+
t.Errorf("expected checksum mismatch error, got: %v", err)
252+
}
253+
}
254+
255+
// SHA256 takes priority over lower-precedence hashes when both are set.
256+
func TestFetch_SHA256_Priority_Over_MD5(t *testing.T) {
257+
body := []byte("priority test")
258+
sha := sha256.Sum256(body)
259+
_, cleanup := fakeS3(t, http.StatusOK, body)
260+
defer cleanup()
261+
262+
s := src("b", "k", func(s *software.Source) {
263+
s.SHA256 = hex.EncodeToString(sha[:])
264+
s.MD5 = "wrongmd5" // would fail if used
265+
})
266+
if err := (&S3Fetcher{}).Fetch(context.Background(), s, t.TempDir()); err != nil {
267+
t.Fatalf("SHA256 should win over MD5: %v", err)
268+
}
269+
}
270+
271+
// ── server errors ─────────────────────────────────────────────────────────────
272+
273+
func TestFetch_ServerError_ReturnsError(t *testing.T) {
274+
_, cleanup := fakeS3(t, http.StatusInternalServerError, nil)
275+
defer cleanup()
276+
277+
err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "k"), t.TempDir())
278+
if err == nil {
279+
t.Error("expected error for server 500")
280+
}
281+
}
282+
283+
func TestFetch_NotFound_ReturnsError(t *testing.T) {
284+
_, cleanup := fakeS3(t, http.StatusNotFound, []byte(`<?xml version="1.0"?><Error><Code>NoSuchKey</Code><Message>Key not found</Message><RequestId>1</RequestId><HostId>h</HostId></Error>`))
285+
defer cleanup()
286+
287+
err := (&S3Fetcher{}).Fetch(context.Background(), src("b", "missing-key.tar.gz"), t.TempDir())
288+
if err == nil {
289+
t.Error("expected error for 404 response")
290+
}
291+
if !strings.Contains(err.Error(), "s3 GetObject") {
292+
t.Errorf("error should mention s3 GetObject, got: %v", err)
293+
}
294+
}

0 commit comments

Comments
 (0)