carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "testing"
10 "time"
11
12 "github.com/aws/aws-sdk-go-v2/aws"
13 "github.com/aws/aws-sdk-go-v2/credentials"
14 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
15)
16
17// TestS3StoreMinioContract runs release.S3Store against a real S3-compatible
18// server: the conditional GET (a 304 for the current ETag), the ETag listing
19// agreeing with Get, and the change index bump through every S3Store
20// mutation path. A fake client cannot show any of these: the 304 and the
21// ETag forms are the server's. Skipped unless CARLOS_MINIO_TEST=1;
22// .amadan/ci.d/30-minio-contract stands the server up and requires this
23// test's PASS line, so a skip cannot pass for coverage there.
24func TestS3StoreMinioContract(t *testing.T) {
25 if os.Getenv("CARLOS_MINIO_TEST") != "1" {
26 t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the S3Store contract test; see .amadan/ci.d/30-minio-contract, which stands one up")
27 }
28 endpoint := minioEnv(t, "MINIO_ENDPOINT")
29 key := minioEnv(t, "MINIO_SCOPED_KEY")
30 secret := minioEnv(t, "MINIO_SCOPED_SECRET")
31 bucket := minioEnv(t, "MINIO_BUCKET")
32
33 client := awss3.New(awss3.Options{
34 Region: "us-east-1",
35 Credentials: credentials.NewStaticCredentialsProvider(key, secret, ""),
36 BaseEndpoint: aws.String(endpoint),
37 UsePathStyle: true,
38 RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
39 ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
40 })
41 s := &S3Store{Client: client, Bucket: bucket}
42 s.EnableChangeIndex()
43 ctx := context.Background()
44
45 // One scope per run, so nothing else in the shared bucket moves its
46 // generation. control/changes.json is the one shared key: the test
47 // compares generations before and after and leaves it in place.
48 app := fmt.Sprintf("mt%d", time.Now().UnixNano())
49 scope := "apps/" + app + "/shop"
50 base := scope + "/"
51 var created []string
52 t.Cleanup(func() {
53 for _, k := range created {
54 _ = s.deleteRaw(context.Background(), k) // not Delete: no bump on the way out
55 }
56 })
57 keyA, keyB := base+"a.json", base+"b.json"
58 created = append(created, keyA, keyB)
59
60 gen := func() uint64 {
61 t.Helper()
62 idx, _, err := ReadChangeIndex(ctx, s) // an absent index reads as empty
63 if err != nil {
64 t.Fatalf("read change index: %v", err)
65 }
66 return idx.Entries[scope]
67 }
68 bumped := func(name string, before uint64) uint64 {
69 t.Helper()
70 after := gen()
71 if after != before+1 {
72 t.Fatalf("%s: generation %d -> %d, want exactly +1", name, before, after)
73 }
74 return after
75 }
76
77 t.Run("conditional get and etag listing", func(t *testing.T) {
78 etag, err := s.putRaw(ctx, keyA, []byte("one"), "")
79 if err != nil {
80 t.Fatal(err)
81 }
82 body, e, changed, err := s.GetIfChanged(ctx, keyA, "")
83 if err != nil || !changed || string(body) != "one" || e != etag {
84 t.Fatalf("empty etag: %q %q (put %q) %v %v", body, e, etag, changed, err)
85 }
86 body, e2, changed, err := s.GetIfChanged(ctx, keyA, e)
87 if err != nil || changed || body != nil || e2 != e {
88 t.Fatalf("current etag must be a 304: %q %q %v %v", body, e2, changed, err)
89 }
90 if _, err := s.putRaw(ctx, keyA, []byte("two"), e); err != nil {
91 t.Fatal(err)
92 }
93 body, e3, changed, err := s.GetIfChanged(ctx, keyA, e)
94 if err != nil || !changed || string(body) != "two" || e3 == e {
95 t.Fatalf("after rewrite: %q %q %v %v", body, e3, changed, err)
96 }
97 if _, _, _, err := s.GetIfChanged(ctx, base+"absent.json", e); !errors.Is(err, os.ErrNotExist) {
98 t.Fatalf("absent: %v", err)
99 }
100 if _, err := s.putRaw(ctx, keyB, []byte("b"), ""); err != nil {
101 t.Fatal(err)
102 }
103 listed, err := s.ListETags(ctx, base)
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(listed) != 2 || listed[0].Key != keyA || listed[1].Key != keyB {
108 t.Fatalf("listed %v, want %s and %s sorted", listed, keyA, keyB)
109 }
110 for _, ke := range listed {
111 _, ge, err := s.Get(ctx, ke.Key)
112 if err != nil || ge != ke.ETag {
113 t.Fatalf("%s: listed etag %q, Get etag %q (%v)", ke.Key, ke.ETag, ge, err)
114 }
115 }
116 for _, k := range []string{keyA, keyB} {
117 if err := s.deleteRaw(ctx, k); err != nil {
118 t.Fatal(err)
119 }
120 }
121 })
122
123 t.Run("bump through every mutation path", func(t *testing.T) {
124 g := gen()
125 etag, err := s.Put(ctx, keyA, []byte("one"), "")
126 if err != nil {
127 t.Fatal(err)
128 }
129 g = bumped("Put", g)
130
131 // A Put whose ifMatch is stale loses and must not bump.
132 if _, err := s.Put(ctx, keyA, []byte("stale"), `"00000000000000000000000000000000"`); !errors.Is(err, ErrCASConflict) {
133 t.Fatalf("stale Put: %v", err)
134 }
135 if after := gen(); after != g {
136 t.Fatalf("a refused Put moved the generation %d -> %d", g, after)
137 }
138
139 etag, err = s.PutFrom(ctx, keyA, bytes.NewReader([]byte("two")), 3, etag)
140 if err != nil {
141 t.Fatal(err)
142 }
143 g = bumped("PutFrom", g)
144
145 if err := DeleteIfMatch(ctx, s, keyA, etag); err != nil {
146 t.Fatal(err)
147 }
148 g = bumped("DeleteIfMatch", g)
149
150 if _, err := s.Put(ctx, keyA, []byte("three"), ""); err != nil {
151 t.Fatal(err)
152 }
153 g = bumped("Put again", g)
154 if err := s.Delete(ctx, keyA); err != nil {
155 t.Fatal(err)
156 }
157 bumped("Delete", g)
158 })
159}
160
161func minioEnv(t *testing.T, name string) string {
162 t.Helper()
163 v := os.Getenv(name)
164 if v == "" {
165 t.Fatalf("%s is not set (required when CARLOS_MINIO_TEST=1)", name)
166 }
167 return v
168}
169