diff --git a/b2/bucket_creation_wire_test.go b/b2/bucket_creation_wire_test.go new file mode 100644 index 0000000..7401f62 --- /dev/null +++ b/b2/bucket_creation_wire_test.go @@ -0,0 +1,181 @@ +package b2 + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +// Pin the create request independently of a live account's bucket defaults. +// https://www.backblaze.com/apidocs/b2-create-bucket +func TestNewBucketAttributesWire(t *testing.T) { + for _, test := range []struct { + name string + attrs *BucketAttrs + info any + rules any + response string + wantInfo map[string]string + wantRules []LifecycleRule + }{ + { + name: "no-attrs", + response: `{"bucketId":"bid","bucketName":"n","bucketType":"allPrivate","bucketInfo":{},"lifecycleRules":[],"revision":1}`, + }, + { + name: "only-info", attrs: &BucketAttrs{Info: map[string]string{"this": "that", "other": "thing"}}, + info: map[string]string{"this": "that", "other": "thing"}, + response: `{"bucketId":"bid","bucketName":"n","bucketType":"allPrivate","bucketInfo":{"this":"that","other":"thing"},"lifecycleRules":[],"revision":1}`, + wantInfo: map[string]string{"this": "that", "other": "thing"}, + }, + { + name: "only-rules", + attrs: &BucketAttrs{LifecycleRules: []LifecycleRule{ + {Prefix: "whee/", DaysHiddenUntilDeleted: 30}, + {Prefix: "whoa/", DaysNewUntilHidden: 1}, + }}, + rules: []map[string]any{ + {"fileNamePrefix": "whee/", "daysFromHidingToDeleting": 30}, + {"fileNamePrefix": "whoa/", "daysFromUploadingToHiding": 1}, + }, + response: `{"bucketId":"bid","bucketName":"n","bucketType":"allPrivate","bucketInfo":{},"lifecycleRules":[{"fileNamePrefix":"whee/","daysFromHidingToDeleting":30},{"fileNamePrefix":"whoa/","daysFromUploadingToHiding":1}],"revision":1}`, + wantRules: []LifecycleRule{{Prefix: "whee/", DaysHiddenUntilDeleted: 30}, {Prefix: "whoa/", DaysNewUntilHidden: 1}}, + }, + { + name: "info-and-rules", + attrs: &BucketAttrs{ + Info: map[string]string{"this": "that", "other": "thing"}, + LifecycleRules: []LifecycleRule{{Prefix: "whee/", DaysHiddenUntilDeleted: 30}, {Prefix: "whoa/", DaysNewUntilHidden: 1}}, + }, + info: map[string]string{"this": "that", "other": "thing"}, + rules: []map[string]any{ + {"fileNamePrefix": "whee/", "daysFromHidingToDeleting": 30}, + {"fileNamePrefix": "whoa/", "daysFromUploadingToHiding": 1}, + }, + response: `{"bucketId":"bid","bucketName":"n","bucketType":"allPrivate","bucketInfo":{"this":"that","other":"thing"},"lifecycleRules":[{"fileNamePrefix":"whee/","daysFromHidingToDeleting":30},{"fileNamePrefix":"whoa/","daysFromUploadingToHiding":1}],"revision":1}`, + wantInfo: map[string]string{"this": "that", "other": "thing"}, + wantRules: []LifecycleRule{{Prefix: "whee/", DaysHiddenUntilDeleted: 30}, {Prefix: "whoa/", DaysNewUntilHidden: 1}}, + }, + } { + t.Run(test.name, func(t *testing.T) { + srv := b2wiretest.New(t) + srv.ReplySequence("b2_list_buckets", + b2wiretest.Response{Status: http.StatusOK, Body: `{"buckets":[]}`}, + b2wiretest.Response{Status: http.StatusOK, Body: `{"buckets":[` + test.response + `]}`}, + ) + srv.Reply("b2_create_bucket", http.StatusOK, test.response) + ctx := context.Background() + client, err := NewClient(ctx, "account", "key", APIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + bucket, err := client.NewBucket(ctx, "n", test.attrs) + if err != nil { + t.Fatal(err) + } + if bucket.Name() != "n" || bucket.ID() != "bid" { + t.Fatalf("created bucket name/id = %q/%q, want n/bid", bucket.Name(), bucket.ID()) + } + creates := srv.Calls("b2_create_bucket") + if len(creates) != 1 { + t.Fatalf("create calls = %d, want 1", len(creates)) + } + request := creates[0] + b2wiretest.AssertJSON(t, request, "accountId", "a") + b2wiretest.AssertJSON(t, request, "bucketName", "n") + b2wiretest.AssertJSON(t, request, "bucketType", "allPrivate") + b2wiretest.AssertJSON(t, request, "bucketInfo", test.info) + b2wiretest.AssertJSON(t, request, "lifecycleRules", test.rules) + b2wiretest.AssertNoJSON(t, request, "defaultServerSideEncryption") + attrs, err := bucket.Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if len(attrs.Info) != len(test.wantInfo) { + t.Errorf("Attrs Info has %d entries, want %d", len(attrs.Info), len(test.wantInfo)) + } + for key, want := range test.wantInfo { + if got := attrs.Info[key]; got != want { + t.Errorf("Attrs Info[%q] = %q, want %q", key, got, want) + } + } + if len(attrs.LifecycleRules) != len(test.wantRules) { + t.Fatalf("Attrs LifecycleRules = %d rules, want %d", len(attrs.LifecycleRules), len(test.wantRules)) + } + for i, want := range test.wantRules { + got := attrs.LifecycleRules[i] + if got.Prefix != want.Prefix { + t.Errorf("Attrs LifecycleRules[%d].Prefix = %q, want %q", i, got.Prefix, want.Prefix) + } + if got.DaysNewUntilHidden != want.DaysNewUntilHidden { + t.Errorf("Attrs LifecycleRules[%d].DaysNewUntilHidden = %d, want %d", i, got.DaysNewUntilHidden, want.DaysNewUntilHidden) + } + if got.DaysHiddenUntilDeleted != want.DaysHiddenUntilDeleted { + t.Errorf("Attrs LifecycleRules[%d].DaysHiddenUntilDeleted = %d, want %d", i, got.DaysHiddenUntilDeleted, want.DaysHiddenUntilDeleted) + } + } + }) + } +} + +// Omitted SSE must stay omitted; decode the service's answer rather than +// assuming an account default. The null setting also covers older buckets. +// https://www.backblaze.com/apidocs/b2-create-bucket +func TestNewBucketDefaultEncryptionWire(t *testing.T) { + for _, test := range []struct { + name string + attrs *BucketAttrs + service string + want *ServerSideEncryption + }{ + { + name: "sse-omitted-encrypted", service: `{"mode":"SSE-B2","algorithm":"AES256"}`, + want: &ServerSideEncryption{Mode: "SSE-B2", Algorithm: "AES256"}, + }, + {name: "sse-omitted-legacy-unencrypted", service: `{"mode":null,"algorithm":null}`}, + { + name: "sse-explicit", attrs: &BucketAttrs{DefaultServerSideEncryption: SSEB2WithAES256()}, + service: `{"mode":"SSE-B2","algorithm":"AES256"}`, + want: &ServerSideEncryption{Mode: "SSE-B2", Algorithm: "AES256"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + srv := b2wiretest.New(t) + bucketJSON := fmt.Sprintf(`{"bucketId":"bid","bucketName":"n","bucketType":"allPrivate","bucketInfo":{},"lifecycleRules":[],"revision":1,"defaultServerSideEncryption":{"isClientAuthorizedToRead":true,"value":%s}}`, test.service) + srv.ReplySequence("b2_list_buckets", + b2wiretest.Response{Body: `{"buckets":[]}`}, + b2wiretest.Response{Body: `{"buckets":[` + bucketJSON + `]}`}, + ) + srv.Reply("b2_create_bucket", http.StatusOK, bucketJSON) + ctx := context.Background() + client, err := NewClient(ctx, "account", "key", APIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + bucket, err := client.NewBucket(ctx, "n", test.attrs) + if err != nil { + t.Fatal(err) + } + creates := srv.Calls("b2_create_bucket") + if len(creates) != 1 { + t.Fatalf("create calls = %d, want 1", len(creates)) + } + if test.attrs == nil { + b2wiretest.AssertNoJSON(t, creates[0], "defaultServerSideEncryption") + } else { + b2wiretest.AssertJSON(t, creates[0], "defaultServerSideEncryption", map[string]string{"mode": "SSE-B2", "algorithm": "AES256"}) + } + attrs, err := bucket.Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(attrs.DefaultServerSideEncryption, test.want) { + t.Errorf("default encryption = %+v, want %+v", attrs.DefaultServerSideEncryption, test.want) + } + }) + } +} diff --git a/b2/empty_delete_wire_test.go b/b2/empty_delete_wire_test.go new file mode 100644 index 0000000..0f3bb2d --- /dev/null +++ b/b2/empty_delete_wire_test.go @@ -0,0 +1,130 @@ +package b2 + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +// Empty uploads must stay empty in the request, metadata and downloaded bytes. +func TestZeroByteObjectWire(t *testing.T) { + for _, chunkSize := range []int{0, 100000000} { + name := "default-chunk-size" + if chunkSize != 0 { + name = "explicit-chunk-size" + } + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv, bucket := newSHA1WireBucket(t) + const file = `{"fileId":"empty-id","fileName":"empty","action":"upload","contentLength":0,"contentSha1":"da39a3ee5e6b4b0d3255bfef95601890afd80709","contentType":"application/octet-stream","fileInfo":{},"uploadTimestamp":1}` + srv.Reply("upload_file", http.StatusOK, file) + srv.Reply("b2_get_file_info", http.StatusOK, file) + srv.Handle("empty", func(r b2wiretest.Request) b2wiretest.Response { + if r.Method != http.MethodHead { + return downloadResponse(nil, "da39a3ee5e6b4b0d3255bfef95601890afd80709")(r) + } + return b2wiretest.Response{Header: http.Header{ + "Content-Length": {"0"}, + "X-Bz-File-Id": {"empty-id"}, + "X-Bz-Content-Sha1": {"da39a3ee5e6b4b0d3255bfef95601890afd80709"}, + }} + }) + writer := bucket.Object("empty").NewWriter(ctx) + writer.ChunkSize = chunkSize + if err := writer.Close(); err != nil { + t.Fatal(err) + } + uploads := srv.Calls("upload_file") + if len(uploads) != 1 { + t.Fatalf("upload calls = %d, want 1", len(uploads)) + } + if got := uploads[0]; len(got.Body) != 0 || got.Header.Get("Content-Length") != "0" || got.Header.Get("X-Bz-File-Name") != "empty" { + t.Fatalf("empty upload: body=%q headers=%v", got.Body, got.Header) + } + attrs, err := bucket.Object("empty").Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if attrs.Size != 0 { + t.Fatalf("empty object size = %d, want 0", attrs.Size) + } + b2wiretest.AssertJSON(t, srv.Last(t, "b2_get_file_info"), "fileId", "empty-id") + reader := bucket.Object("empty").NewReader(ctx) + defer func() { + if err := reader.Close(); err != nil { + t.Error(err) + } + }() + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if len(data) != 0 { + t.Fatalf("empty object content = %q, want empty", data) + } + }) + } +} + +// Delete resolves a version by HEAD, sends its ID and name, then a +// fresh handle must observe that the file is gone. +func TestDeleteWithoutNameWire(t *testing.T) { + ctx := context.Background() + srv := b2wiretest.New(t) + bucket := newListingWireBucket(t, ctx, srv) + deleted := make(chan struct{}) + srv.Handle("victim", func(r b2wiretest.Request) b2wiretest.Response { + select { + case <-deleted: + return b2wiretest.Response{Status: http.StatusNotFound, + Body: `{"status":404,"code":"not_found","message":"file not present"}`} + default: + return b2wiretest.Response{Header: http.Header{ + "Content-Length": {"7"}, "X-Bz-File-Id": {"victim-id"}, + }, Body: "payload"} + } + }) + srv.Reply("b2_get_file_info", http.StatusOK, `{"fileId":"victim-id","fileName":"victim","action":"upload","contentLength":7,"contentSha1":"none","contentType":"text/plain","fileInfo":{},"uploadTimestamp":1}`) + srv.Handle("b2_delete_file_version", func(r b2wiretest.Request) b2wiretest.Response { + if r.JSON["fileId"] != "victim-id" || r.JSON["fileName"] != "victim" { + return b2wiretest.Response{Status: http.StatusBadRequest, + Body: `{"status":400,"code":"bad_request","message":"wrong version"}`} + } + select { + case <-deleted: + t.Error("duplicate delete") + default: + close(deleted) + } + return b2wiretest.Response{Body: `{"fileId":"victim-id","fileName":"victim"}`} + }) + if _, err := bucket.Object("victim").Attrs(ctx); err != nil { + t.Fatalf("Attrs before Delete: %v", err) + } + if err := bucket.Object("victim").Delete(ctx); err != nil { + t.Fatal(err) + } + if _, err := bucket.Object("victim").Attrs(ctx); !IsNotExist(err) { + t.Errorf("Attrs after Delete = %v, want not-exist", err) + } + deletes := srv.Calls("b2_delete_file_version") + if len(deletes) != 1 { + t.Fatalf("delete calls = %d, want 1", len(deletes)) + } + b2wiretest.AssertJSON(t, deletes[0], "fileId", "victim-id") + b2wiretest.AssertJSON(t, deletes[0], "fileName", "victim") + lookups := srv.Calls("victim") + if len(lookups) != 3 { + t.Fatalf("HEAD calls = %d, want 3 (before, resolve for delete, after)", len(lookups)) + } + for _, request := range lookups { + if request.Method != http.MethodHead { + t.Errorf("lookup method = %s, want HEAD", request.Method) + } + } +} diff --git a/b2/integration_test.go b/b2/integration_test.go index 533a822..c195618 100644 --- a/b2/integration_test.go +++ b/b2/integration_test.go @@ -222,7 +222,7 @@ func TestCopyLive(t *testing.T) { bucket, done := startLiveTest(ctx, t) defer done() - source, _, err := writeFile(ctx, bucket, "copy-source", 1024, 1e8) + source, sourceSHA1, err := writeFile(ctx, bucket, "copy-source", 1024, 1e8) if err != nil { t.Fatal(err) } @@ -240,7 +240,10 @@ func TestCopyLive(t *testing.T) { if attrs.Size != 1024 { t.Errorf("copied object size: got %d, want %d", attrs.Size, 1024) } - if err := readFile(ctx, destination, attrs.SHA1, 1024, 1); err != nil { + if attrs.SHA1 != sourceSHA1 { + t.Errorf("copied object SHA1: got %q, want source SHA1 %q", attrs.SHA1, sourceSHA1) + } + if err := readFile(ctx, destination, sourceSHA1, 1024, 1); err != nil { t.Error(err) } } @@ -312,17 +315,25 @@ func TestResumeWriter(t *testing.T) { } func TestResumeWriterWithoutExtantFile(t *testing.T) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() bucket, done := startLiveTest(ctx, t) defer done() r := io.LimitReader(zReader{}, 15e6) + h := sha1.New() w := bucket.Object("foo").NewWriter(ctx) w.ChunkSize = 5e6 w.Resume = true - if _, err := io.Copy(w, r); err != nil { + if _, err := io.Copy(w, io.TeeReader(r, h)); err != nil { t.Fatalf("io.Copy: %v", err) } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := readFile(ctx, bucket.Object("foo"), fmt.Sprintf("%x", h.Sum(nil)), 5e6, 1); err != nil { + t.Fatal(err) + } } func TestAttrs(t *testing.T) { @@ -410,7 +421,8 @@ func TestFileBufferLive(t *testing.T) { bucket, done := startLiveTest(ctx, t) defer done() - r := io.LimitReader(zReader{}, 1e6) + h := sha1.New() + r := io.TeeReader(io.LimitReader(zReader{}, 1e6), h) w := bucket.Object("small").NewWriter(ctx) w.UseFileBuffer = true @@ -425,11 +437,14 @@ func TestFileBufferLive(t *testing.T) { smallTmpName := wb.f.Name() if _, err := io.Copy(w, r); err != nil { - t.Errorf("creating small file: %v", err) + t.Fatalf("creating small file: %v", err) } if err := w.Close(); err != nil { - t.Errorf("w.Close(): %v", err) + t.Fatalf("w.Close(): %v", err) + } + if err := readFile(ctx, bucket.Object("small"), fmt.Sprintf("%x", h.Sum(nil)), 1e6, 1); err != nil { + t.Error(err) } if _, err := os.Stat(smallTmpName); !os.IsNotExist(err) { @@ -757,6 +772,9 @@ func TestNewBucket(t *testing.T) { if bucket.ID() == "" { t.Errorf("%s: Bucket.ID() is empty after NewBucket", ent.name) } + if got, want := bucket.Name(), id+"-"+ent.name; got != want { + t.Errorf("%s: Bucket.Name() = %q, want %q", ent.name, got, want) + } if err := bucket.Update(ctx, nil); err != nil { t.Errorf("%s: Update(ctx, nil): %v", ent.name, err) continue @@ -791,7 +809,8 @@ func TestBucketDefaultEncryption(t *testing.T) { name string attrs *BucketAttrs }{ - {name: "sse-omitted", attrs: nil}, + // Omission is covered by TestNewBucketDefaultEncryptionWire without + // depending on this live account's default encryption policy. {name: "sse-explicit", attrs: &BucketAttrs{DefaultServerSideEncryption: SSEB2WithAES256()}}, } for _, ent := range table { @@ -875,10 +894,13 @@ func TestWriteEmpty(t *testing.T) { bucket, done := startLiveTest(ctx, t) defer done() - _, _, err := writeFile(ctx, bucket, smallFileName, 0, 1e8) + obj, sha, err := writeFile(ctx, bucket, smallFileName, 0, 1e8) if err != nil { t.Fatal(err) } + if err := readFile(ctx, obj, sha, 1e6, 1); err != nil { + t.Fatal(err) + } } func TestAttrsNoRoundtrip(t *testing.T) { @@ -972,6 +994,10 @@ func TestDeleteWithoutName(t *testing.T) { if err := bucket.Object(smallFileName).Delete(ctx); err != nil { t.Fatal(err) } + // Use a fresh handle so cached metadata cannot hide a failed deletion. + if _, err := bucket.Object(smallFileName).Attrs(ctx); !IsNotExist(err) { + t.Fatalf("Attrs after Delete: got %v, want not-exist", err) + } } func TestZeroByteObject(t *testing.T) { @@ -987,41 +1013,65 @@ func TestZeroByteObject(t *testing.T) { t.Fatal(err) } + attrs, err := bucket.Object(smallFileName).Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if attrs.Size != 0 { + t.Fatalf("zero-byte object size: got %d, want 0", attrs.Size) + } + if err := bucket.Object(smallFileName).Delete(ctx); err != nil { t.Fatal(err) } } func TestListUnfinishedLargeFiles(t *testing.T) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() bucket, done := startLiveTest(ctx, t) defer done() w := bucket.Object(largeFileName).NewWriter(ctx) - w.ChunkSize = 1e5 - if _, err := io.Copy(w, io.LimitReader(zReader{}, 1e6)); err != nil { + w.ChunkSize = 5e6 + defer func() { + if err := w.Close(); err != nil { + t.Error(err) + } + }() + if _, err := io.Copy(w, io.LimitReader(zReader{}, 6e6)); err != nil { t.Fatal(err) } iter := bucket.List(ctx, ListUnfinished()) - if !iter.Next() { - t.Errorf("ListUnfinishedLargeFiles: got none, want 1 (error %v)", iter.Err()) + var names []string + for iter.Next() { + names = append(names, iter.Object().Name()) + } + if err := iter.Err(); err != nil { + t.Fatal(err) + } + if want := []string{largeFileName}; !reflect.DeepEqual(names, want) { + t.Errorf("ListUnfinishedLargeFiles: got %q, want %q", names, want) } } func TestReauthPreservesOptions(t *testing.T) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() bucket, done := startLiveTest(ctx, t) defer done() - first := bucket.r.(*beRoot).options if err := bucket.r.reauthorizeAccount(ctx); err != nil { t.Fatalf("reauthorizeAccount: %v", err) } - second := bucket.r.(*beRoot).options - if !reflect.DeepEqual(first, second) { - // Test that they are literally the same set of options, which is an - // implementation detail but is fine for now. - t.Errorf("options mismatch: got %v, want %v", second, first) + // The wire twin checks transport, endpoint, user agent and test-mode + // options; here verify the refreshed client can still access live B2. + refreshed, err := bucket.c.Bucket(ctx, bucket.Name()) + if err != nil { + t.Fatal(err) + } + if refreshed.ID() != bucket.ID() { + t.Errorf("bucket after reauth: got ID %q, want %q", refreshed.ID(), bucket.ID()) } } diff --git a/b2/reauth_options_wire_test.go b/b2/reauth_options_wire_test.go new file mode 100644 index 0000000..f15cbe4 --- /dev/null +++ b/b2/reauth_options_wire_test.go @@ -0,0 +1,92 @@ +package b2 + +import ( + "context" + "fmt" + "net/http" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +// Like TestConcurrent401ReauthorizationWire, force reauth through a public +// call; check the options' HTTP effects instead of comparing internal structs. +func TestReauthPreservesOptionsWire(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv := b2wiretest.New(t) + auth := fmt.Sprintf(updateAuthJSON, srv.URL()) + srv.ReplySequence("b2_authorize_account", + b2wiretest.Response{Body: auth}, + b2wiretest.Response{Body: strings.Replace(auth, `"authorizationToken":"t"`, `"authorizationToken":"fresh"`, 1)}, + ) + srv.ReplySequence("b2_list_buckets", + b2wiretest.Response{Status: http.StatusUnauthorized, Body: `{"status":401,"code":"expired_auth_token","message":"expired"}`}, + b2wiretest.Response{Body: `{"buckets":[]}`}, + ) + recorder := &recordingTransport{next: http.DefaultTransport} + client, err := NewClient(ctx, "account", "key", APIBase(srv.URL()), Transport(recorder), + UserAgent("first-agent"), UserAgent("second-agent"), FailSomeUploads(), ExpireSomeAuthTokens(), ForceCapExceeded()) + if err != nil { + t.Fatal(err) + } + buckets, err := client.ListBuckets(ctx) + if err != nil { + t.Fatal(err) + } + if len(buckets) != 0 { + t.Fatalf("ListBuckets = %v, want empty", buckets) + } + requests := srv.Requests() + wantCalls := []string{"b2_authorize_account", "b2_list_buckets", "b2_authorize_account", "b2_list_buckets"} + if len(requests) != len(wantCalls) { + t.Fatalf("HTTP calls = %d, want %d", len(requests), len(wantCalls)) + } + recorder.mu.Lock() + transportCalls := len(recorder.requests) + recorder.mu.Unlock() + if transportCalls != 4 { + t.Errorf("configured transport calls = %d, want 4", transportCalls) + } + for i, request := range requests { + method := http.MethodPost + if i%2 == 0 { + method = http.MethodGet + } + if request.Method != method || request.Path != "/b2api/v4/"+wantCalls[i] { + t.Errorf("request %d = %s %s, want %s %s", i, request.Method, request.Path, method, wantCalls[i]) + } + if got := request.Header.Get("User-Agent"); !strings.HasPrefix(got, "second-agent first-agent blazer/") { + t.Errorf("request %d User-Agent = %q, want both custom agents before blazer/", i, got) + } + wantModes := []string{"expire_some_account_authorization_tokens", "fail_some_uploads", "force_cap_exceeded"} + if got := normalizedTestModes(request.Header.Values("X-Bz-Test-Mode")); !reflect.DeepEqual(got, wantModes) { + t.Errorf("request %d test modes = %q, want %q", i, got, wantModes) + } + } + lists := srv.Calls("b2_list_buckets") + if len(lists) != 2 { + t.Fatalf("list calls = %d, want 2", len(lists)) + } + for i, token := range []string{"t", "fresh"} { + if got := lists[i].Header.Get("Authorization"); got != token { + t.Errorf("list %d token = %q, want %q", i, got, token) + } + b2wiretest.AssertJSON(t, lists[i], "accountId", "a") + } +} + +func normalizedTestModes(values []string) []string { + var modes []string + for _, value := range values { + for _, mode := range strings.Split(value, ",") { + modes = append(modes, strings.TrimSpace(mode)) + } + } + sort.Strings(modes) + return modes +}