carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package leasetest
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/xml"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "strings"
15 "testing"
16
17 "github.com/carlosframework/platform/internal/lease"
18)
19
20// failingReader yields some bytes and then an error, like a dropped upload.
21type failingReader struct{ r io.Reader }
22
23func (f *failingReader) Read(p []byte) (int, error) {
24 n, err := f.r.Read(p)
25 if err == io.EOF {
26 return n, errors.New("connection reset")
27 }
28 return n, err
29}
30
31func TestS3ServerFailedUploadStoresNothing(t *testing.T) {
32 m := NewVersionedBucket()
33 m.AddBucket("data")
34 h, _ := NewS3Server(m)
35 before := m.Snapshot()
36 req := httptest.NewRequest(http.MethodPut, "/data/k", &failingReader{strings.NewReader("partial")})
37 req.Header.Set("If-None-Match", "*")
38 rec := httptest.NewRecorder()
39 h.ServeHTTP(rec, req)
40 if rec.Code < 400 {
41 t.Fatalf("failed upload answered %d", rec.Code)
42 }
43 if m.Snapshot() != before {
44 t.Fatalf("failed upload changed the bucket:\n%s", m.Snapshot())
45 }
46}
47
48func do(t *testing.T, h http.Handler, method, path string, body string, hdr map[string]string) *httptest.ResponseRecorder {
49 t.Helper()
50 req := httptest.NewRequest(method, path, strings.NewReader(body))
51 for k, v := range hdr {
52 req.Header.Set(k, v)
53 }
54 rec := httptest.NewRecorder()
55 h.ServeHTTP(rec, req)
56 return rec
57}
58
59// A plain PUT (no If-None-Match) stores a new version beside the existing
60// one, as S3 with versioning on does; the create-only PUT keeps refusing
61// an existing current object. A plain DELETE adds a delete marker.
62func TestS3ServerPlainPutCreatesAVersion(t *testing.T) {
63 m := NewVersionedBucket()
64 m.AddBucket("data")
65 h, _ := NewS3Server(m)
66 first := do(t, h, http.MethodPut, "/data/k", "one", nil)
67 second := do(t, h, http.MethodPut, "/data/k", "two", nil)
68 if first.Code != 200 || second.Code != 200 {
69 t.Fatalf("plain puts: %d %d", first.Code, second.Code)
70 }
71 v1, v2 := first.Header().Get("x-amz-version-id"), second.Header().Get("x-amz-version-id")
72 if v1 == "" || v2 == "" || v1 == v2 {
73 t.Fatalf("version ids %q %q", v1, v2)
74 }
75 if got, _ := m.Current("data", "k"); string(got) != "two" {
76 t.Fatalf("current %q", got)
77 }
78 if old, err := m.GetVersion(context.Background(), "data", "k", v1); err != nil || string(old) != "one" {
79 t.Fatalf("the first version is gone: %q %v", old, err)
80 }
81 // a GET naming no version reads the current one
82 if rec := do(t, h, http.MethodGet, "/data/k", "", nil); rec.Code != 200 || rec.Body.String() != "two" {
83 t.Fatalf("plain get: %d %q", rec.Code, rec.Body.String())
84 }
85 if rec := do(t, h, http.MethodGet, "/data/absent", "", nil); rec.Code != http.StatusNotFound {
86 t.Fatalf("plain get of an absent key: %d", rec.Code)
87 }
88 // the create-only path is unchanged
89 if rec := do(t, h, http.MethodPut, "/data/k", "three", map[string]string{"If-None-Match": "*"}); rec.Code != http.StatusPreconditionFailed {
90 t.Fatalf("create-only over a current object: %d", rec.Code)
91 }
92 if rec := do(t, h, http.MethodPut, "/data/fresh", "x", map[string]string{"If-None-Match": "*"}); rec.Code != 200 || rec.Header().Get("x-amz-version-id") == "" {
93 t.Fatalf("create-only on a fresh key: %d", rec.Code)
94 }
95 // a bare DELETE leaves a delete marker, the versions stay
96 if rec := do(t, h, http.MethodDelete, "/data/k", "", nil); rec.Code != http.StatusNoContent {
97 t.Fatalf("bare delete: %d", rec.Code)
98 }
99 if _, ok := m.Current("data", "k"); ok {
100 t.Fatal("the key still reads after a bare delete")
101 }
102 if old, err := m.GetVersion(context.Background(), "data", "k", v2); err != nil || string(old) != "two" {
103 t.Fatalf("a bare delete removed a version: %q %v", old, err)
104 }
105}
106
107// A copy that names no source version copies the source's current one, and
108// honors x-amz-copy-source-if-match against its ETag (S3's behavior; the
109// staging mirror copies this way).
110func TestS3ServerCopyOfCurrentVersion(t *testing.T) {
111 m := NewVersionedBucket()
112 m.AddBucket("src")
113 m.AddBucket("dst")
114 m.Put("src", "a/b c+d", []byte("body"))
115 h, _ := NewS3Server(m)
116 src := "/src/a/b%20c%2Bd"
117 rec := do(t, h, http.MethodPut, "/dst/out", "", map[string]string{"x-amz-copy-source": src, "x-amz-copy-source-if-match": `"` + md5hex([]byte("body")) + `"`})
118 if rec.Code != 200 || rec.Header().Get("x-amz-version-id") == "" || !strings.Contains(rec.Body.String(), md5hex([]byte("body"))) {
119 t.Fatalf("copy: %d %q %v", rec.Code, rec.Body.String(), rec.Header())
120 }
121 rec = do(t, h, http.MethodPut, "/dst/out2", "", map[string]string{"x-amz-copy-source": src, "x-amz-copy-source-if-match": `"deadbeef"`})
122 if rec.Code != http.StatusPreconditionFailed || !strings.Contains(rec.Body.String(), "<Code>PreconditionFailed</Code>") {
123 t.Fatalf("stale if-match: %d %q", rec.Code, rec.Body.String())
124 }
125 if _, ok := m.Current("dst", "out2"); ok {
126 t.Fatal("a failed precondition copied")
127 }
128}
129
130// Real S3 rejects a PUT whose body is not the length the request declares,
131// or whose sha256 is not the one the request signed, and stores nothing. The
132// fake must do the same, or a client that sends a short or altered body looks
133// like it succeeded (a transport can hand the server a partial body before it
134// fails).
135func TestS3ServerValidatesPutBody(t *testing.T) {
136 sum := sha256.Sum256([]byte("abcdefghij"))
137 good := hex.EncodeToString(sum[:])
138 wrong := hex.EncodeToString(make([]byte, 32))
139 put := func(h http.Handler, key, body string, declared int64, sha string, extra map[string]string) *httptest.ResponseRecorder {
140 req := httptest.NewRequest(http.MethodPut, "/data/"+key, strings.NewReader(body))
141 req.ContentLength = declared
142 if sha != "" {
143 req.Header.Set("x-amz-content-sha256", sha)
144 }
145 for k, v := range extra {
146 req.Header.Set(k, v)
147 }
148 rec := httptest.NewRecorder()
149 h.ServeHTTP(rec, req)
150 return rec
151 }
152 for _, c := range []struct {
153 name string
154 body string
155 declared int64
156 sha string
157 code string // empty: accepted
158 }{
159 {"exact", "abcdefghij", 10, good, ""},
160 {"unsigned payload", "abcdefghij", 10, "UNSIGNED-PAYLOAD", ""},
161 {"no sha header", "abcdefghij", 10, "", ""},
162 {"unknown length", "abcdefghij", -1, good, ""},
163 {"short body", "abc", 10, good, "IncompleteBody"},
164 {"short body unsigned", "abc", 10, "UNSIGNED-PAYLOAD", "IncompleteBody"},
165 {"long body", "abcdefghijkl", 10, "", "IncompleteBody"},
166 {"wrong sha", "abcdefghij", 10, wrong, "XAmzContentSHA256Mismatch"},
167 {"altered body", "abcdefghiX", 10, good, "XAmzContentSHA256Mismatch"},
168 } {
169 for _, createOnly := range []bool{false, true} {
170 m := NewVersionedBucket()
171 m.AddBucket("data")
172 h, _ := NewS3Server(m)
173 extra := map[string]string{}
174 if createOnly {
175 extra["If-None-Match"] = "*"
176 }
177 rec := put(h, "k", c.body, c.declared, c.sha, extra)
178 _, stored := m.Current("data", "k")
179 if c.code == "" {
180 if rec.Code != 200 || !stored {
181 t.Errorf("%s (create-only %v): %d, stored %v", c.name, createOnly, rec.Code, stored)
182 }
183 continue
184 }
185 if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "<Code>"+c.code+"</Code>") {
186 t.Errorf("%s (create-only %v): %d %q, want 400 %s", c.name, createOnly, rec.Code, rec.Body.String(), c.code)
187 }
188 if stored {
189 t.Errorf("%s (create-only %v): a rejected PUT stored the body", c.name, createOnly)
190 }
191 }
192 }
193}
194
195// A copy carries no body: its Content-Length must be what arrives and its
196// signed sha256 (when a digest) the hash of nothing. A copy that fails the
197// check stores nothing.
198func TestS3ServerValidatesCopyBody(t *testing.T) {
199 empty := sha256.Sum256(nil)
200 m := NewVersionedBucket()
201 m.AddBucket("src")
202 m.AddBucket("dst")
203 m.Put("src", "a", []byte("body"))
204 h, _ := NewS3Server(m)
205 copyWith := func(dst, body string, declared int64, sha string) *httptest.ResponseRecorder {
206 req := httptest.NewRequest(http.MethodPut, "/dst/"+dst, strings.NewReader(body))
207 req.ContentLength = declared
208 req.Header.Set("x-amz-copy-source", "/src/a")
209 if sha != "" {
210 req.Header.Set("x-amz-content-sha256", sha)
211 }
212 rec := httptest.NewRecorder()
213 h.ServeHTTP(rec, req)
214 return rec
215 }
216 if rec := copyWith("ok", "", 0, hex.EncodeToString(empty[:])); rec.Code != 200 {
217 t.Fatalf("a well-formed copy: %d %q", rec.Code, rec.Body.String())
218 }
219 if rec := copyWith("ok2", "", 0, "UNSIGNED-PAYLOAD"); rec.Code != 200 {
220 t.Fatalf("an unsigned-payload copy: %d %q", rec.Code, rec.Body.String())
221 }
222 if rec := copyWith("long", "xyz", 0, ""); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "<Code>IncompleteBody</Code>") {
223 t.Fatalf("a copy with a body longer than declared: %d %q", rec.Code, rec.Body.String())
224 }
225 if rec := copyWith("sha", "", 0, hex.EncodeToString(make([]byte, 32))); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "<Code>XAmzContentSHA256Mismatch</Code>") {
226 t.Fatalf("a copy with the wrong sha256: %d %q", rec.Code, rec.Body.String())
227 }
228 for _, k := range []string{"long", "sha"} {
229 if _, ok := m.Current("dst", k); ok {
230 t.Fatalf("a rejected copy stored %q", k)
231 }
232 }
233}
234
235func TestS3ServerConditionalReplaceUsesReadETag(t *testing.T) {
236 m := NewVersionedBucket()
237 m.AddBucket("data")
238 h, _ := NewS3Server(m)
239 first := do(t, h, http.MethodPut, "/data/lock.json", "expired", map[string]string{"If-None-Match": "*"})
240 read := do(t, h, http.MethodGet, "/data/lock.json", "", nil)
241 etag := read.Header().Get("ETag")
242 if etag == "" || etag != first.Header().Get("ETag") {
243 t.Fatal("GET did not return current ETag", etag)
244 }
245 if got := do(t, h, http.MethodPut, "/data/lock.json", "new-owner", map[string]string{"If-Match": etag}); got.Code != 200 {
246 t.Fatal("takeover", got.Code)
247 }
248 before := m.Snapshot()
249 if got := do(t, h, http.MethodPut, "/data/lock.json", "stale-owner", map[string]string{"If-Match": etag}); got.Code != http.StatusPreconditionFailed {
250 t.Fatal("stale CAS accepted", got.Code)
251 }
252 if m.Snapshot() != before {
253 t.Fatal("stale CAS mutated versions")
254 }
255 if got := do(t, h, http.MethodPut, "/data/missing", "new", map[string]string{"If-Match": etag}); got.Code != http.StatusPreconditionFailed {
256 t.Fatal("CAS on missing accepted", got.Code)
257 }
258 do(t, h, http.MethodDelete, "/data/lock.json", "", nil)
259 if got := do(t, h, http.MethodPut, "/data/lock.json", "new", map[string]string{"If-Match": etag}); got.Code != http.StatusPreconditionFailed {
260 t.Fatal("CAS over delete marker accepted", got.Code)
261 }
262}
263
264// DeleteObject honours If-Match as S3 does: a stale ETag is a 412 and removes
265// nothing, a missing key is a 404, the current ETag deletes, and a plain
266// delete (no header) is unchanged.
267func TestS3ServerConditionalDeleteUsesReadETag(t *testing.T) {
268 m := NewVersionedBucket()
269 m.AddBucket("data")
270 h, _ := NewS3Server(m)
271 do(t, h, http.MethodPut, "/data/lock.json", "one", map[string]string{"If-None-Match": "*"})
272 stale := do(t, h, http.MethodGet, "/data/lock.json", "", nil).Header().Get("ETag")
273 do(t, h, http.MethodPut, "/data/lock.json", "two", map[string]string{"If-Match": stale})
274 current := do(t, h, http.MethodGet, "/data/lock.json", "", nil).Header().Get("ETag")
275 before := m.Snapshot()
276 if got := do(t, h, http.MethodDelete, "/data/lock.json", "", map[string]string{"If-Match": stale}); got.Code != http.StatusPreconditionFailed {
277 t.Fatal("stale conditional delete accepted", got.Code)
278 }
279 if m.Snapshot() != before {
280 t.Fatal("stale conditional delete mutated versions")
281 }
282 if got := do(t, h, http.MethodDelete, "/data/lock.json", "", map[string]string{"If-Match": current}); got.Code != http.StatusNoContent {
283 t.Fatal("current conditional delete refused", got.Code)
284 }
285 if got := do(t, h, http.MethodDelete, "/data/lock.json", "", map[string]string{"If-Match": current}); got.Code != http.StatusNotFound {
286 t.Fatal("conditional delete of a deleted key", got.Code)
287 }
288 if got := do(t, h, http.MethodDelete, "/data/never", "", map[string]string{"If-Match": current}); got.Code != http.StatusNotFound {
289 t.Fatal("conditional delete of a missing key", got.Code)
290 }
291 if got := do(t, h, http.MethodDelete, "/data/never", "", nil); got.Code != http.StatusNoContent {
292 t.Fatal("plain delete changed", got.Code)
293 }
294}
295
296// listV2 decodes one ListObjectsV2 page from the raw wire.
297func listV2(t *testing.T, h http.Handler, query string) (listBucketResult, int) {
298 t.Helper()
299 rec := do(t, h, http.MethodGet, "/data?list-type=2"+query, "", nil)
300 var res listBucketResult
301 if rec.Code == http.StatusOK {
302 if err := xml.Unmarshal(rec.Body.Bytes(), &res); err != nil {
303 t.Fatalf("decode %q: %v", rec.Body.String(), err)
304 }
305 }
306 return res, rec.Code
307}
308
309// ListObjectsV2 on the wire: the current objects under a prefix in key
310// order, each with its size and quoted ETag; a key whose newest version is
311// a delete marker, an older version and a sibling prefix are not listed;
312// max-keys pages, and the continuation token resumes after the page;
313// start-after skips; a bad token and a negative max-keys are 400s; a
314// delimiter rolls keys into common prefixes, which page like keys.
315func TestS3ServerListObjectsV2(t *testing.T) {
316 m := NewVersionedBucket()
317 m.AddBucket("data")
318 h, _ := NewS3Server(m)
319 for _, k := range []string{"p/c", "p/a", "p/b/x", "p/d", "q/a", "p2/a"} {
320 m.Put("data", k, []byte("body of "+k))
321 }
322 m.Put("data", "p/a", []byte("newer"))
323 m.BareDelete("data", "p/d")
324 res, code := listV2(t, h, "&prefix=p/")
325 if code != http.StatusOK {
326 t.Fatal(code)
327 }
328 var keys []string
329 for _, o := range res.Contents {
330 keys = append(keys, o.Key)
331 }
332 if strings.Join(keys, ",") != "p/a,p/b/x,p/c" || res.IsTruncated || res.KeyCount != 3 || res.NextContinuationToken != "" {
333 t.Fatalf("listing %+v", res)
334 }
335 if o := res.Contents[0]; o.Size != int64(len("newer")) || o.ETag != `"`+md5hex([]byte("newer"))+`"` {
336 t.Fatalf("p/a listed as %+v", o)
337 }
338
339 first, _ := listV2(t, h, "&prefix=p/&max-keys=2")
340 if len(first.Contents) != 2 || !first.IsTruncated || first.NextContinuationToken == "" || first.Contents[1].Key != "p/b/x" {
341 t.Fatalf("first page %+v", first)
342 }
343 second, _ := listV2(t, h, "&prefix=p/&max-keys=2&continuation-token="+url.QueryEscape(first.NextContinuationToken))
344 if len(second.Contents) != 1 || second.Contents[0].Key != "p/c" || second.IsTruncated || second.ContinuationToken != first.NextContinuationToken {
345 t.Fatalf("second page %+v", second)
346 }
347 after, _ := listV2(t, h, "&prefix=p/&start-after=p/a")
348 if len(after.Contents) != 2 || after.Contents[0].Key != "p/b/x" {
349 t.Fatalf("start-after %+v", after)
350 }
351 if _, code := listV2(t, h, "&continuation-token=%21%21"); code != http.StatusBadRequest {
352 t.Fatal("bad token", code)
353 }
354 if _, code := listV2(t, h, "&max-keys=-1"); code != http.StatusBadRequest {
355 t.Fatal("negative max-keys", code)
356 }
357 // A delimiter rolls p/b/x into the common prefix p/b/, which counts
358 // against max-keys and can end a page.
359 m.Put("data", "p/b/y", []byte("y"))
360 d1, _ := listV2(t, h, "&prefix=p/&delimiter=/&max-keys=2")
361 if len(d1.Contents) != 1 || d1.Contents[0].Key != "p/a" || len(d1.CommonPrefixes) != 1 || d1.CommonPrefixes[0].Prefix != "p/b/" ||
362 d1.KeyCount != 2 || !d1.IsTruncated || d1.Delimiter != "/" {
363 t.Fatalf("delimited first page %+v", d1)
364 }
365 d2, _ := listV2(t, h, "&prefix=p/&delimiter=/&max-keys=2&continuation-token="+url.QueryEscape(d1.NextContinuationToken))
366 if len(d2.Contents) != 1 || d2.Contents[0].Key != "p/c" || len(d2.CommonPrefixes) != 0 || d2.IsTruncated {
367 t.Fatalf("delimited second page %+v", d2)
368 }
369 top, _ := listV2(t, h, "&delimiter=/")
370 var tops []string
371 for _, p := range top.CommonPrefixes {
372 tops = append(tops, p.Prefix)
373 }
374 if len(top.Contents) != 0 || strings.Join(tops, ",") != "p/,p2/,q/" {
375 t.Fatalf("top level %+v", top)
376 }
377}
378
379// The SDK's own paginator, through lease.NewS3Mirror, reads every page:
380// 2,500 keys at the default 1,000 a page, and again at a PageSize of 7.
381func TestS3ServerListObjectsV2PaginatesTheSDK(t *testing.T) {
382 m := NewVersionedBucket()
383 m.AddBucket("data")
384 h, _ := NewS3Server(m)
385 srv := httptest.NewServer(h)
386 defer srv.Close()
387 t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
388 t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
389 t.Setenv("AWS_SESSION_TOKEN", "")
390 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
391 t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent")
392 t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent")
393 t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
394 want := map[string]bool{}
395 for i := range 2500 {
396 k := fmt.Sprintf("big/%05d", i)
397 m.Put("data", k, []byte(k))
398 want[k] = true
399 }
400 m.Put("data", "bigger/x", []byte("x")) // a sibling prefix, never listed
401 ctx := context.Background()
402 mirror, err := lease.NewS3Mirror(ctx, "eu-west-1", "eu-west-1")
403 if err != nil {
404 t.Fatal(err)
405 }
406 for _, page := range []int{1000, 7} {
407 m.PageSize = page
408 got, err := mirror.List(ctx, "data", "big/")
409 if err != nil {
410 t.Fatalf("page size %d: %v", page, err)
411 }
412 if len(got) != len(want) {
413 t.Fatalf("page size %d: listed %d keys, want %d", page, len(got), len(want))
414 }
415 for k, o := range got {
416 if !want[k] || o.Size != int64(len(k)) || o.ETag != md5hex([]byte(k)) {
417 t.Fatalf("page size %d: %s listed as %+v", page, k, o)
418 }
419 }
420 }
421}
422