| 1 | package lease_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/hmac" |
| 6 | "crypto/md5" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "net/http" |
| 13 | "net/http/httptest" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/aws/aws-sdk-go-v2/aws" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/lease" |
| 22 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 23 | ) |
| 24 | |
| 25 | const ( |
| 26 | stagingAK = "AKIATEST" |
| 27 | stagingSecret = "test-secret" |
| 28 | stagingRegion = "eu-west-2" |
| 29 | ) |
| 30 | |
| 31 | var stagingCreds = aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { |
| 32 | return aws.Credentials{AccessKeyID: stagingAK, SecretAccessKey: stagingSecret}, nil |
| 33 | }) |
| 34 | |
| 35 | func hmacSHA(key []byte, s string) []byte { |
| 36 | h := hmac.New(sha256.New, key) |
| 37 | h.Write([]byte(s)) |
| 38 | return h.Sum(nil) |
| 39 | } |
| 40 | |
| 41 | // verifySigV4 recomputes the signature of a received request from first |
| 42 | // principles (the AWS spec's canonical request and key derivation, not the |
| 43 | // SDK's code), so the hand-built request is checked against an independent |
| 44 | // reading of what S3 would check. It returns the X-Amz-Date it saw. |
| 45 | func verifySigV4(r *http.Request) error { |
| 46 | auth := r.Header.Get("Authorization") |
| 47 | rest, ok := strings.CutPrefix(auth, "AWS4-HMAC-SHA256 ") |
| 48 | if !ok { |
| 49 | return fmt.Errorf("authorization %q", auth) |
| 50 | } |
| 51 | f := map[string]string{} |
| 52 | for _, part := range strings.Split(rest, ", ") { |
| 53 | k, v, _ := strings.Cut(part, "=") |
| 54 | f[k] = v |
| 55 | } |
| 56 | date := r.Header.Get("X-Amz-Date") |
| 57 | scope := date[:8] + "/" + stagingRegion + "/s3/aws4_request" |
| 58 | if f["Credential"] != stagingAK+"/"+scope { |
| 59 | return fmt.Errorf("credential scope %q, want %q", f["Credential"], scope) |
| 60 | } |
| 61 | var canon strings.Builder |
| 62 | canon.WriteString(r.Method + "\n") |
| 63 | canon.WriteString(strings.SplitN(r.RequestURI, "?", 2)[0] + "\n") // the path as sent: S3 canonicalizes it once-encoded |
| 64 | canon.WriteString(r.URL.RawQuery + "\n") |
| 65 | for _, h := range strings.Split(f["SignedHeaders"], ";") { |
| 66 | v := r.Header.Get(h) |
| 67 | if h == "host" { |
| 68 | v = r.Host |
| 69 | } |
| 70 | if h == "content-length" { |
| 71 | v = fmt.Sprint(r.ContentLength) |
| 72 | } |
| 73 | canon.WriteString(h + ":" + strings.TrimSpace(v) + "\n") |
| 74 | } |
| 75 | canon.WriteString("\n" + f["SignedHeaders"] + "\n" + r.Header.Get("X-Amz-Content-Sha256")) |
| 76 | sum := sha256.Sum256([]byte(canon.String())) |
| 77 | sts := "AWS4-HMAC-SHA256\n" + date + "\n" + scope + "\n" + hex.EncodeToString(sum[:]) |
| 78 | k := hmacSHA([]byte("AWS4"+stagingSecret), date[:8]) |
| 79 | k = hmacSHA(k, stagingRegion) |
| 80 | k = hmacSHA(k, "s3") |
| 81 | k = hmacSHA(k, "aws4_request") |
| 82 | if got := hex.EncodeToString(hmacSHA(k, sts)); got != f["Signature"] { |
| 83 | return fmt.Errorf("signature mismatch\ncanonical request:\n%s", canon.String()) |
| 84 | } |
| 85 | return nil |
| 86 | } |
| 87 | |
| 88 | type recorded struct { |
| 89 | method, uri, date, payload, contentType, copySource, ifMatch, ifNoneMatch, body string |
| 90 | sigErr error |
| 91 | } |
| 92 | |
| 93 | // stagingFixture is a staging mirror over leasetest's local S3, with every |
| 94 | // request recorded and its signature verified on arrival. Optionally an |
| 95 | // override answers instead of the model (a failing S3). |
| 96 | func stagingFixture(t *testing.T, override func(w http.ResponseWriter, r *http.Request) bool) (*lease.StagingMirror, *leasetest.VersionedBucket, func() []recorded) { |
| 97 | t.Helper() |
| 98 | bucket := leasetest.NewVersionedBucket() |
| 99 | bucket.AddBucket("data-uk") |
| 100 | bucket.AddBucket("data-ie") |
| 101 | handler, _ := leasetest.NewS3Server(bucket) |
| 102 | var mu sync.Mutex |
| 103 | var reqs []recorded |
| 104 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | body, _ := io.ReadAll(r.Body) |
| 106 | mu.Lock() |
| 107 | reqs = append(reqs, recorded{r.Method, r.RequestURI, r.Header.Get("X-Amz-Date"), r.Header.Get("X-Amz-Content-Sha256"), |
| 108 | r.Header.Get("Content-Type"), r.Header.Get("X-Amz-Copy-Source"), r.Header.Get("X-Amz-Copy-Source-If-Match"), |
| 109 | r.Header.Get("If-None-Match"), string(body), verifySigV4(r)}) |
| 110 | mu.Unlock() |
| 111 | r.Body = io.NopCloser(strings.NewReader(string(body))) |
| 112 | if override != nil && override(w, r) { |
| 113 | return |
| 114 | } |
| 115 | handler.ServeHTTP(w, r) |
| 116 | })) |
| 117 | t.Cleanup(srv.Close) |
| 118 | return lease.NewStagingMirrorAt(srv.URL, stagingRegion, stagingCreds), bucket, func() []recorded { |
| 119 | mu.Lock() |
| 120 | defer mu.Unlock() |
| 121 | return append([]recorded(nil), reqs...) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | var signAt = time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 126 | |
| 127 | func TestStagingMirrorCopyVersioned(t *testing.T) { |
| 128 | m, bucket, reqs := stagingFixture(t, nil) |
| 129 | bucket.Put("data-ie", "instances/h/generations/0 1+a/lvl.ltx", []byte("body")) |
| 130 | etag := fmt.Sprintf("%x", md5sum("body")) |
| 131 | ctx := context.Background() |
| 132 | gotETag, ver, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "instances/h/generations/0 1+a/lvl.ltx", etag, "data-uk", "moves/h/op/replica/x y.ltx") |
| 133 | if err != nil || gotETag != etag || ver == "" { |
| 134 | t.Fatalf("copy: etag %q ver %q err %v", gotETag, ver, err) |
| 135 | } |
| 136 | if got, _ := bucket.Current("data-uk", "moves/h/op/replica/x y.ltx"); string(got) != "body" { |
| 137 | t.Fatalf("destination %q", got) |
| 138 | } |
| 139 | r := reqs()[0] |
| 140 | if r.sigErr != nil { |
| 141 | t.Fatal(r.sigErr) |
| 142 | } |
| 143 | if r.date != "20261006T090000Z" || r.payload != "UNSIGNED-PAYLOAD" || r.method != "PUT" { |
| 144 | t.Fatalf("request %+v", r) |
| 145 | } |
| 146 | if r.copySource != "/data-ie/instances/h/generations/0%201%2Ba/lvl.ltx" || r.ifMatch != `"`+etag+`"` { |
| 147 | t.Fatalf("copy headers: source %q if-match %q", r.copySource, r.ifMatch) |
| 148 | } |
| 149 | if r.uri != "/data-uk/moves/h/op/replica/x%20y.ltx" { |
| 150 | t.Fatalf("destination path %q", r.uri) |
| 151 | } |
| 152 | // no source ETag: no precondition header |
| 153 | if _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "instances/h/generations/0 1+a/lvl.ltx", "", "data-uk", "moves/h/op/replica/z"); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | if rs := reqs(); rs[1].ifMatch != "" || rs[1].sigErr != nil { |
| 157 | t.Fatalf("unconditional copy: %+v", rs[1]) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func md5sum(s string) [16]byte { return md5.Sum([]byte(s)) } |
| 162 | |
| 163 | func TestStagingMirrorCopyFailures(t *testing.T) { |
| 164 | m, bucket, reqs := stagingFixture(t, func(w http.ResponseWriter, r *http.Request) bool { |
| 165 | switch { |
| 166 | case strings.Contains(r.URL.Path, "late"): |
| 167 | w.WriteHeader(200) |
| 168 | io.WriteString(w, "<Error><Code>InternalError</Code><Message>x</Message></Error>") |
| 169 | return true |
| 170 | case strings.Contains(r.URL.Path, "boom"): |
| 171 | w.WriteHeader(503) |
| 172 | io.WriteString(w, "<Error><Code>SlowDown</Code><Message>x</Message></Error>") |
| 173 | return true |
| 174 | case strings.Contains(r.URL.Path, "empty"): |
| 175 | w.WriteHeader(200) |
| 176 | return true |
| 177 | } |
| 178 | return false |
| 179 | }) |
| 180 | bucket.Put("data-ie", "k", []byte("body")) |
| 181 | ctx := context.Background() |
| 182 | // the source changed since it was listed |
| 183 | if _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "0000", "data-uk", "d"); !errors.Is(err, lease.ErrCopySourceChanged) { |
| 184 | t.Fatalf("stale source: %v", err) |
| 185 | } |
| 186 | if _, ok := bucket.Current("data-uk", "d"); ok { |
| 187 | t.Fatal("a stale-source copy landed") |
| 188 | } |
| 189 | // a 200 carrying an <Error> body is a failure |
| 190 | if _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "late"); err == nil || !strings.Contains(err.Error(), "InternalError") { |
| 191 | t.Fatalf("late failure: %v", err) |
| 192 | } |
| 193 | if _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "empty"); err == nil { |
| 194 | t.Fatal("a 200 with no result is not a success") |
| 195 | } |
| 196 | // any other status names the code, and is sent once (no retry) |
| 197 | before := len(reqs()) |
| 198 | _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "boom") |
| 199 | if err == nil || !strings.Contains(err.Error(), "SlowDown") || errors.Is(err, lease.ErrCopySourceChanged) { |
| 200 | t.Fatalf("503: %v", err) |
| 201 | } |
| 202 | if n := len(reqs()) - before; n != 1 { |
| 203 | t.Fatalf("a failing request was sent %d times", n) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestStagingMirrorPutAndDelete(t *testing.T) { |
| 208 | m, bucket, reqs := stagingFixture(t, nil) |
| 209 | ctx := context.Background() |
| 210 | v1, err := m.PutVersionedAt(ctx, signAt, "data-uk", "moves/h/op/timers/t.json", []byte(`{"a":1}`)) |
| 211 | if err != nil || v1 == "" { |
| 212 | t.Fatalf("put: %q %v", v1, err) |
| 213 | } |
| 214 | v2, err := m.PutVersionedAt(ctx, signAt, "data-uk", "moves/h/op/timers/t.json", []byte(`{"a":2}`)) |
| 215 | if err != nil || v2 == "" || v2 == v1 { |
| 216 | t.Fatalf("overwrite: %q %v", v2, err) |
| 217 | } |
| 218 | sum := sha256.Sum256([]byte(`{"a":1}`)) |
| 219 | r := reqs()[0] |
| 220 | if r.sigErr != nil || r.payload != hex.EncodeToString(sum[:]) || r.contentType != "application/json" || r.ifNoneMatch != "" || r.body != `{"a":1}` { |
| 221 | t.Fatalf("put request %+v", r) |
| 222 | } |
| 223 | if _, err := m.PutIfAbsentVersionedAt(ctx, signAt, "data-uk", "moves/h/op/move.json", []byte("{}")); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | if rs := reqs(); rs[2].ifNoneMatch != "*" || rs[2].sigErr != nil { |
| 227 | t.Fatalf("create-only request %+v", rs[2]) |
| 228 | } |
| 229 | if _, err := m.PutIfAbsentVersionedAt(ctx, signAt, "data-uk", "moves/h/op/move.json", []byte("{}")); !errors.Is(err, lease.ErrExists) { |
| 230 | t.Fatalf("second create-only: %v", err) |
| 231 | } |
| 232 | // an empty body still signs and lands |
| 233 | if _, err := m.PutVersionedAt(ctx, signAt, "data-uk", "empty", nil); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if err := m.DeleteAt(ctx, signAt, "data-uk", "moves/h/op/timers/t.json"); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if _, ok := bucket.Current("data-uk", "moves/h/op/timers/t.json"); ok { |
| 240 | t.Fatal("still current after DeleteAt") |
| 241 | } |
| 242 | for i, r := range reqs() { |
| 243 | if r.sigErr != nil { |
| 244 | t.Fatalf("request %d: %v", i, r.sigErr) |
| 245 | } |
| 246 | } |
| 247 | // a missing bucket is an error naming the code |
| 248 | if _, err := m.PutVersionedAt(ctx, signAt, "nope", "k", []byte("x")); err == nil || !strings.Contains(err.Error(), "NoSuchBucket") { |
| 249 | t.Fatalf("missing bucket: %v", err) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // The signature carries the timestamp it was given and nothing else: the |
| 254 | // same inputs sign identically however much later they are sent, and a |
| 255 | // signAt in the far past is used as is (the mirror reads no clock). |
| 256 | func TestStagingMirrorSignsOnlyAtSignAt(t *testing.T) { |
| 257 | var auths []string |
| 258 | m, _, reqs := stagingFixture(t, func(w http.ResponseWriter, r *http.Request) bool { |
| 259 | auths = append(auths, r.Header.Get("Authorization")) |
| 260 | return false |
| 261 | }) |
| 262 | ctx := context.Background() |
| 263 | for i := 0; i < 2; i++ { |
| 264 | if _, err := m.PutVersionedAt(ctx, signAt, "data-uk", "k", []byte("x")); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | time.Sleep(1100 * time.Millisecond) // a second boundary passes between sends |
| 268 | } |
| 269 | if len(auths) != 2 || auths[0] != auths[1] { |
| 270 | t.Fatalf("same signAt, different signatures: %q", auths) |
| 271 | } |
| 272 | old := time.Date(2001, 1, 2, 3, 4, 5, 0, time.UTC) |
| 273 | if _, err := m.PutVersionedAt(ctx, old, "data-uk", "k", []byte("x")); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | if rs := reqs(); rs[2].date != "20010102T030405Z" { |
| 277 | t.Fatalf("date %q", rs[2].date) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // A mutation without a signing time (a caller that bypassed the guard) |
| 282 | // sends nothing. |
| 283 | func TestStagingMirrorRefusesAZeroSignTime(t *testing.T) { |
| 284 | m, _, reqs := stagingFixture(t, nil) |
| 285 | ctx := context.Background() |
| 286 | if _, err := m.PutVersionedAt(ctx, time.Time{}, "data-uk", "k", []byte("x")); err == nil { |
| 287 | t.Fatal("put signed at the zero time") |
| 288 | } |
| 289 | if _, _, err := m.CopyVersionedAt(ctx, time.Time{}, "data-ie", "k", "", "data-uk", "d"); err == nil { |
| 290 | t.Fatal("copy signed at the zero time") |
| 291 | } |
| 292 | if err := m.DeleteAt(ctx, time.Time{}, "data-uk", "k"); err == nil { |
| 293 | t.Fatal("delete signed at the zero time") |
| 294 | } |
| 295 | if n := len(reqs()); n != 0 { |
| 296 | t.Fatalf("%d requests sent", n) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | // Get is the ordinary read, delegated. |
| 301 | func TestStagingMirrorGetDelegates(t *testing.T) { |
| 302 | m, bucket, _ := stagingFixture(t, nil) |
| 303 | bucket.Put("data-uk", "r", []byte("receipts")) |
| 304 | got, err := m.Get(context.Background(), "data-uk", "r") |
| 305 | if err != nil || string(got) != "receipts" { |
| 306 | t.Fatalf("get: %q %v", got, err) |
| 307 | } |
| 308 | if _, err := m.Get(context.Background(), "data-uk", "absent"); !errors.Is(err, lease.ErrMirrorNotFound) { |
| 309 | t.Fatalf("get of an absent key: %v", err) |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // A staging bucket must be versioned: a 2xx with no x-amz-version-id is a |
| 314 | // failure for every mutation that is sealed by version, never an empty id. |
| 315 | func TestStagingMirrorRefusesAMissingVersionID(t *testing.T) { |
| 316 | m, bucket, _ := stagingFixture(t, func(w http.ResponseWriter, r *http.Request) bool { |
| 317 | if !strings.Contains(r.URL.Path, "unversioned") { |
| 318 | return false |
| 319 | } |
| 320 | if r.Header.Get("X-Amz-Copy-Source") != "" { |
| 321 | io.WriteString(w, `<CopyObjectResult><ETag>"abc"</ETag></CopyObjectResult>`) |
| 322 | } |
| 323 | w.Header().Set("ETag", `"abc"`) |
| 324 | return true // a 200 with no x-amz-version-id |
| 325 | }) |
| 326 | bucket.Put("data-ie", "k", []byte("body")) |
| 327 | ctx := context.Background() |
| 328 | for name, call := range map[string]func() error{ |
| 329 | "copy": func() error { |
| 330 | _, _, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "unversioned/c") |
| 331 | return err |
| 332 | }, |
| 333 | "put": func() error { |
| 334 | _, err := m.PutVersionedAt(ctx, signAt, "data-uk", "unversioned/p", []byte("x")) |
| 335 | return err |
| 336 | }, |
| 337 | "put-if-absent": func() error { |
| 338 | _, err := m.PutIfAbsentVersionedAt(ctx, signAt, "data-uk", "unversioned/pia", []byte("x")) |
| 339 | return err |
| 340 | }, |
| 341 | } { |
| 342 | if err := call(); err == nil || !strings.Contains(err.Error(), "version id") { |
| 343 | t.Errorf("%s with no version id: %v", name, err) |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // Final review finding 4: a versioning-suspended bucket answers every write |
| 349 | // with the version id "null", and the next write to the key overwrites that |
| 350 | // same version. It names no immutable object, so it is refused like a |
| 351 | // missing id, for every mutation that returns one. |
| 352 | func TestStagingMirrorRefusesTheNullVersionID(t *testing.T) { |
| 353 | m, bucket, _ := stagingFixture(t, func(w http.ResponseWriter, r *http.Request) bool { |
| 354 | if !strings.Contains(r.URL.Path, "suspended") { |
| 355 | return false |
| 356 | } |
| 357 | // Headers first: a body written before them sends them. |
| 358 | w.Header().Set("ETag", `"abc"`) |
| 359 | w.Header().Set("X-Amz-Version-Id", "null") |
| 360 | if r.Header.Get("X-Amz-Copy-Source") != "" { |
| 361 | io.WriteString(w, `<CopyObjectResult><ETag>"abc"</ETag></CopyObjectResult>`) |
| 362 | } |
| 363 | return true |
| 364 | }) |
| 365 | bucket.Put("data-ie", "k", []byte("body")) |
| 366 | ctx := context.Background() |
| 367 | body := "sealed" |
| 368 | sum := sha256.Sum256([]byte(body)) |
| 369 | for name, call := range map[string]func() (string, error){ |
| 370 | "copy": func() (string, error) { |
| 371 | _, v, err := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "suspended/c") |
| 372 | return v, err |
| 373 | }, |
| 374 | "put": func() (string, error) { |
| 375 | return m.PutVersionedAt(ctx, signAt, "data-uk", "suspended/p", []byte("x")) |
| 376 | }, |
| 377 | "put-reader": func() (string, error) { |
| 378 | return m.PutReaderVersionedAt(ctx, signAt, "data-uk", "suspended/r", strings.NewReader(body), int64(len(body)), hex.EncodeToString(sum[:])) |
| 379 | }, |
| 380 | "put-if-absent": func() (string, error) { |
| 381 | return m.PutIfAbsentVersionedAt(ctx, signAt, "data-uk", "suspended/pia", []byte("x")) |
| 382 | }, |
| 383 | } { |
| 384 | v, err := call() |
| 385 | if err == nil || v != "" || !strings.Contains(err.Error(), "version id") { |
| 386 | t.Errorf("%s answered with the null version: %q, %v", name, v, err) |
| 387 | } |
| 388 | } |
| 389 | for _, v := range []string{"", "null"} { |
| 390 | if lease.UsableVersionID(v) { |
| 391 | t.Errorf("UsableVersionID(%q) = true", v) |
| 392 | } |
| 393 | } |
| 394 | if !lease.UsableVersionID("3HL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY") { |
| 395 | t.Error("a real version id is not usable") |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | // A redirect is an error, and it is never followed: no second request, |
| 400 | // whatever the status (a 307/308 would resend the mutation, a 301/302/303 |
| 401 | // would turn it into a GET). |
| 402 | func TestStagingMirrorNeverFollowsARedirect(t *testing.T) { |
| 403 | for _, code := range []int{301, 302, 303, 307, 308} { |
| 404 | m, bucket, reqs := stagingFixture(t, func(w http.ResponseWriter, r *http.Request) bool { |
| 405 | if r.URL.Path == "/elsewhere" { |
| 406 | t.Errorf("a redirect was followed: %s %s", r.Method, r.URL.Path) |
| 407 | } |
| 408 | w.Header().Set("Location", "http://"+r.Host+"/elsewhere") |
| 409 | w.WriteHeader(code) |
| 410 | return true |
| 411 | }) |
| 412 | bucket.Put("data-ie", "k", []byte("body")) |
| 413 | ctx := context.Background() |
| 414 | _, cerr := m.PutVersionedAt(ctx, signAt, "data-uk", "k", []byte("x")) |
| 415 | _, _, kerr := m.CopyVersionedAt(ctx, signAt, "data-ie", "k", "", "data-uk", "d") |
| 416 | _, perr := m.PutIfAbsentVersionedAt(ctx, signAt, "data-uk", "m", []byte("x")) |
| 417 | derr := m.DeleteAt(ctx, signAt, "data-uk", "k") |
| 418 | for name, err := range map[string]error{"put": cerr, "copy": kerr, "put-if-absent": perr, "delete": derr} { |
| 419 | if err == nil || !strings.Contains(err.Error(), fmt.Sprint(code)) { |
| 420 | t.Errorf("%d on %s: %v", code, name, err) |
| 421 | } |
| 422 | } |
| 423 | if n := len(reqs()); n != 4 { |
| 424 | t.Errorf("%d: %d requests for 4 calls", code, n) |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // PutReaderVersionedAt streams a body of a known size and sha256 (the |
| 430 | // sealed archive, kept on disk) under the same single signature: the |
| 431 | // payload hash it signs is the one given, the body lands whole, and a |
| 432 | // reader that ends early is never sent as a complete object. |
| 433 | func TestStagingMirrorPutReader(t *testing.T) { |
| 434 | m, bucket, reqs := stagingFixture(t, nil) |
| 435 | ctx := context.Background() |
| 436 | body := strings.Repeat("sealed ", 1000) |
| 437 | sum := sha256.Sum256([]byte(body)) |
| 438 | ver, err := m.PutReaderVersionedAt(ctx, signAt, "data-uk", "moves/h/op/files/files-x.sealed", strings.NewReader(body), int64(len(body)), hex.EncodeToString(sum[:])) |
| 439 | if err != nil || ver == "" { |
| 440 | t.Fatalf("put: %q %v", ver, err) |
| 441 | } |
| 442 | r := reqs()[0] |
| 443 | if r.sigErr != nil || r.payload != hex.EncodeToString(sum[:]) || r.body != body || r.ifNoneMatch != "" { |
| 444 | t.Fatalf("request %+v", r.sigErr) |
| 445 | } |
| 446 | if got, ok := bucket.Current("data-uk", "moves/h/op/files/files-x.sealed"); !ok || string(got) != body { |
| 447 | t.Fatalf("not stored: %q", got) |
| 448 | } |
| 449 | // A hash that is not 64 hex digits, a negative size, or a zero signing |
| 450 | // time sends nothing. |
| 451 | n := len(reqs()) |
| 452 | for _, c := range []struct { |
| 453 | size int64 |
| 454 | sha string |
| 455 | at time.Time |
| 456 | }{{int64(len(body)), "nothex", signAt}, {-1, hex.EncodeToString(sum[:]), signAt}, {int64(len(body)), hex.EncodeToString(sum[:]), time.Time{}}} { |
| 457 | if _, err := m.PutReaderVersionedAt(ctx, c.at, "data-uk", "k", strings.NewReader(body), c.size, c.sha); err == nil { |
| 458 | t.Fatalf("accepted size %d sha %q at %v", c.size, c.sha, c.at) |
| 459 | } |
| 460 | } |
| 461 | if len(reqs()) != n { |
| 462 | t.Fatal("a refused put sent a request") |
| 463 | } |
| 464 | // A reader shorter than its declared size fails rather than landing a |
| 465 | // truncated object. |
| 466 | if _, err := m.PutReaderVersionedAt(ctx, signAt, "data-uk", "short", strings.NewReader("abc"), 10, hex.EncodeToString(sum[:])); err == nil { |
| 467 | t.Fatal("a short body was accepted") |
| 468 | } |
| 469 | if _, ok := bucket.Current("data-uk", "short"); ok { |
| 470 | t.Fatal("a short body landed") |
| 471 | } |
| 472 | } |
| 473 | |