| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package storage |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "io" |
| 10 | "os" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | // s3FromEnv returns an S3Store configured from SHITHUB_TEST_S3_* env vars, |
| 17 | // or skips the test when those aren't set. Tests that exercise the s3 |
| 18 | // backend end-to-end run only when CI or a developer has wired up MinIO. |
| 19 | // |
| 20 | // SHITHUB_TEST_S3_ENDPOINT (e.g. 127.0.0.1:9000) |
| 21 | // SHITHUB_TEST_S3_ACCESS_KEY_ID |
| 22 | // SHITHUB_TEST_S3_SECRET_ACCESS_KEY |
| 23 | // SHITHUB_TEST_S3_BUCKET (e.g. shithub-dev) |
| 24 | func s3FromEnv(t *testing.T) *S3Store { |
| 25 | t.Helper() |
| 26 | endpoint := os.Getenv("SHITHUB_TEST_S3_ENDPOINT") |
| 27 | if endpoint == "" { |
| 28 | t.Skip("SHITHUB_TEST_S3_ENDPOINT not set; skipping s3 integration test") |
| 29 | } |
| 30 | store, err := NewS3Store(S3Config{ |
| 31 | Endpoint: endpoint, |
| 32 | Region: envOr("SHITHUB_TEST_S3_REGION", "us-east-1"), |
| 33 | AccessKeyID: os.Getenv("SHITHUB_TEST_S3_ACCESS_KEY_ID"), |
| 34 | SecretAccessKey: os.Getenv("SHITHUB_TEST_S3_SECRET_ACCESS_KEY"), |
| 35 | Bucket: os.Getenv("SHITHUB_TEST_S3_BUCKET"), |
| 36 | UseSSL: false, |
| 37 | ForcePathStyle: true, |
| 38 | }) |
| 39 | if err != nil { |
| 40 | t.Fatalf("NewS3Store: %v", err) |
| 41 | } |
| 42 | return store |
| 43 | } |
| 44 | |
| 45 | func envOr(key, def string) string { |
| 46 | if v := os.Getenv(key); v != "" { |
| 47 | return v |
| 48 | } |
| 49 | return def |
| 50 | } |
| 51 | |
| 52 | func TestS3Store_RoundTrip(t *testing.T) { |
| 53 | t.Parallel() |
| 54 | s := s3FromEnv(t) |
| 55 | ctx := context.Background() |
| 56 | key := "test/round-trip-" + time.Now().UTC().Format("20060102-150405.000000000") |
| 57 | |
| 58 | body := strings.NewReader("integration body") |
| 59 | res, err := s.Put(ctx, key, body, PutOpts{ContentType: "text/plain"}) |
| 60 | if err != nil { |
| 61 | t.Fatalf("Put: %v", err) |
| 62 | } |
| 63 | t.Cleanup(func() { _ = s.Delete(ctx, key) }) |
| 64 | |
| 65 | if res.Size != int64(len("integration body")) { |
| 66 | t.Fatalf("size = %d, want %d", res.Size, len("integration body")) |
| 67 | } |
| 68 | |
| 69 | rc, meta, err := s.Get(ctx, key) |
| 70 | if err != nil { |
| 71 | t.Fatalf("Get: %v", err) |
| 72 | } |
| 73 | defer func() { _ = rc.Close() }() |
| 74 | got, _ := io.ReadAll(rc) |
| 75 | if string(got) != "integration body" { |
| 76 | t.Fatalf("body mismatch: got %q", got) |
| 77 | } |
| 78 | if meta.Size != res.Size { |
| 79 | t.Fatalf("meta size mismatch") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestS3Store_PutIfNoneMatch(t *testing.T) { |
| 84 | t.Parallel() |
| 85 | s := s3FromEnv(t) |
| 86 | ctx := context.Background() |
| 87 | key := "test/inm-" + time.Now().UTC().Format("20060102-150405.000000000") |
| 88 | |
| 89 | if _, err := s.Put(ctx, key, strings.NewReader("first"), PutOpts{}); err != nil { |
| 90 | t.Fatalf("first put: %v", err) |
| 91 | } |
| 92 | t.Cleanup(func() { _ = s.Delete(ctx, key) }) |
| 93 | |
| 94 | _, err := s.Put(ctx, key, strings.NewReader("second"), PutOpts{IfNoneMatch: "*"}) |
| 95 | if !errors.Is(err, ErrPreconditionFailed) { |
| 96 | t.Fatalf("expected ErrPreconditionFailed, got %v", err) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestS3Store_LargeRoundTrip(t *testing.T) { |
| 101 | t.Parallel() |
| 102 | s := s3FromEnv(t) |
| 103 | ctx := context.Background() |
| 104 | key := "test/large-" + time.Now().UTC().Format("20060102-150405.000000000") |
| 105 | |
| 106 | body := bytes.Repeat([]byte{0xab}, 5*1024*1024) // 5 MiB |
| 107 | if _, err := s.Put(ctx, key, bytes.NewReader(body), PutOpts{ContentLength: int64(len(body))}); err != nil { |
| 108 | t.Fatalf("Put large: %v", err) |
| 109 | } |
| 110 | t.Cleanup(func() { _ = s.Delete(ctx, key) }) |
| 111 | |
| 112 | rc, _, err := s.Get(ctx, key) |
| 113 | if err != nil { |
| 114 | t.Fatalf("Get large: %v", err) |
| 115 | } |
| 116 | defer func() { _ = rc.Close() }() |
| 117 | got, _ := io.ReadAll(rc) |
| 118 | if !bytes.Equal(got, body) { |
| 119 | t.Fatalf("body mismatch (len got=%d want=%d)", len(got), len(body)) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestS3Store_GetMissing(t *testing.T) { |
| 124 | t.Parallel() |
| 125 | s := s3FromEnv(t) |
| 126 | _, _, err := s.Get(context.Background(), "test/should-not-exist-xyz-"+time.Now().UTC().Format("150405.000")) |
| 127 | if !errors.Is(err, ErrNotFound) { |
| 128 | t.Fatalf("expected ErrNotFound, got %v", err) |
| 129 | } |
| 130 | } |
| 131 |