From f90171aec0c48b5063ce77900db05f686672e404 Mon Sep 17 00:00:00 2001 From: sophiecarreras <49928680+sophiecarreras@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:20:24 -0300 Subject: [PATCH] test: add credential-free wire tests for client options, key lifetime, list locker and status Cover paths that no offline test reached: - base: the X-Bz-Test-Mode headers set by FailSomeUploads, ExpireSomeAuthTokens and ForceCapExceeded appear on authorize, JSON and download requests; UserAgent options are prepended to the default in order; Bucket.BaseURL and Bucket.S3URL come from the authorize response. - b2: the same client options reach the wire through NewClient; Lifetime and Deadline set validDurationInSeconds on b2_create_key and no option omits it; ListLocker is held during every list request and released after; Status reports RPC counts by method. Test-only change, so no changelog entry. Co-Authored-By: Claude Sonnet 5.5 --- b2/client_options_wire_test.go | 180 ++++++++++++++++++++++++++++++ base/session_options_wire_test.go | 127 +++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 b2/client_options_wire_test.go create mode 100644 base/session_options_wire_test.go diff --git a/b2/client_options_wire_test.go b/b2/client_options_wire_test.go new file mode 100644 index 0000000..f979dcc --- /dev/null +++ b/b2/client_options_wire_test.go @@ -0,0 +1,180 @@ +// Copyright 2026, the Blazer authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package b2 + +import ( + "context" + "net/http" + "reflect" + "sync" + "testing" + "time" + + "github.com/Backblaze/blazer/base" + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +func clientOptionsWire(t *testing.T, opts ...ClientOption) (*b2wiretest.Server, *Client) { + t.Helper() + srv := b2wiretest.New(t) + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) + srv.Reply("b2_create_key", http.StatusOK, `{"applicationKeyId":"id","applicationKey":"secret","keyName":"k","capabilities":["readFiles"],"expirationTimestamp":0}`) + client, err := NewClient(context.Background(), "account", "key", append([]ClientOption{APIBase(srv.URL())}, opts...)...) + if err != nil { + t.Fatal(err) + } + if _, err := client.ListBuckets(context.Background()); err != nil { + t.Fatal(err) + } + return srv, client +} + +// The client options must reach the base layer, so they show up on the +// authorize call as well as on later calls. +func TestClientTestModeOptionsReachTheWire(t *testing.T) { + for _, tc := range []struct { + name string + opts []ClientOption + want []string + }{ + {"none", nil, nil}, + {"fail some uploads", []ClientOption{FailSomeUploads()}, []string{"fail_some_uploads"}}, + {"expire some tokens", []ClientOption{ExpireSomeAuthTokens()}, []string{"expire_some_account_authorization_tokens"}}, + {"force cap exceeded", []ClientOption{ForceCapExceeded()}, []string{"force_cap_exceeded"}}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, _ := clientOptionsWire(t, tc.opts...) + for _, call := range []string{"b2_authorize_account", "b2_list_buckets"} { + if got := srv.Last(t, call).Header.Values("X-Bz-Test-Mode"); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s X-Bz-Test-Mode = %q, want %q", call, got, tc.want) + } + } + }) + } +} + +func TestClientUserAgentOptionsReachTheWire(t *testing.T) { + srv, _ := clientOptionsWire(t, UserAgent("app/1"), UserAgent("lib/2")) + want := "lib/2 app/1 " + base.DefaultUserAgent + for _, call := range []string{"b2_authorize_account", "b2_list_buckets"} { + b2wiretest.AssertHeader(t, srv.Last(t, call), "User-Agent", want) + } +} + +func TestKeyLifetimeAndDeadlineSendValidDuration(t *testing.T) { + create := func(t *testing.T, opts ...KeyOption) b2wiretest.Request { + t.Helper() + srv, client := clientOptionsWire(t) + if _, err := client.CreateKey(context.Background(), "k", append([]KeyOption{Capabilities("readFiles")}, opts...)...); err != nil { + t.Fatal(err) + } + return srv.Last(t, "b2_create_key") + } + t.Run("no option omits the field", func(t *testing.T) { + b2wiretest.AssertNoJSON(t, create(t), "validDurationInSeconds") + }) + t.Run("lifetime", func(t *testing.T) { + b2wiretest.AssertJSON(t, create(t, Lifetime(90*time.Second)), "validDurationInSeconds", 90) + }) + t.Run("deadline is converted to the time left", func(t *testing.T) { + r := create(t, Deadline(time.Now().Add(2*time.Hour))) + got, ok := r.JSON["validDurationInSeconds"].(float64) + if !ok { + t.Fatalf("validDurationInSeconds = %v, want a number", r.JSON["validDurationInSeconds"]) + } + // Deadline measures from now, so allow the seconds the test itself takes. + if got > 7200 || got < 7200-30 { + t.Errorf("validDurationInSeconds = %v, want about 7200", got) + } + }) +} + +// lockWatcher records whether it is held, so a test can see if a listing +// request ran under the lock. +type lockWatcher struct { + mu sync.Mutex + held bool + locks int + unlocks int +} + +func (l *lockWatcher) Lock() { + l.mu.Lock() + l.held = true + l.locks++ + l.mu.Unlock() +} + +func (l *lockWatcher) Unlock() { + l.mu.Lock() + l.held = false + l.unlocks++ + l.mu.Unlock() +} + +func (l *lockWatcher) isHeld() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.held +} + +func TestListLockerIsHeldDuringEachListRequest(t *testing.T) { + srv := b2wiretest.New(t) + locker := &lockWatcher{} + var heldDuringRequest []bool + var mu sync.Mutex + srv.Handle("b2_list_file_names", func(r b2wiretest.Request) b2wiretest.Response { + mu.Lock() + heldDuringRequest = append(heldDuringRequest, locker.isHeld()) + mu.Unlock() + if r.JSON["startFileName"] == nil { + return b2wiretest.Response{Body: `{"files":[{"fileId":"1","fileName":"a","action":"upload","uploadTimestamp":1}],"nextFileName":"b","nextFileId":null}`} + } + return b2wiretest.Response{Body: `{"files":[{"fileId":"2","fileName":"b","action":"upload","uploadTimestamp":1}],"nextFileName":null,"nextFileId":null}`} + }) + bucket := newListingWireBucket(t, context.Background(), srv) + + it := bucket.List(context.Background(), ListLocker(locker), ListPageSize(1)) + var names []string + for it.Next() { + names = append(names, it.Object().Name()) + } + if err := it.Err(); err != nil { + t.Fatal(err) + } + if want := []string{"a", "b"}; !reflect.DeepEqual(names, want) { + t.Fatalf("listed %q, want %q", names, want) + } + if want := []bool{true, true}; !reflect.DeepEqual(heldDuringRequest, want) { + t.Errorf("lock held during list requests = %v, want %v", heldDuringRequest, want) + } + if locker.isHeld() || locker.locks != locker.unlocks { + t.Errorf("after listing: held=%v locks=%d unlocks=%d, want released and balanced", locker.isHeld(), locker.locks, locker.unlocks) + } +} + +func TestStatusCountsRPCsByMethod(t *testing.T) { + _, client := clientOptionsWire(t) + status := client.Status() + if len(status.RPCs) == 0 { + t.Fatal("Status().RPCs has no time windows") + } + for window, methods := range status.RPCs { + counts := methods.CountByMethod() + if counts["b2_list_buckets"] != 1 { + t.Errorf("window %v: b2_list_buckets count = %d, want 1 (all counts %v)", window, counts["b2_list_buckets"], counts) + } + } +} diff --git a/base/session_options_wire_test.go b/base/session_options_wire_test.go new file mode 100644 index 0000000..9805e98 --- /dev/null +++ b/base/session_options_wire_test.go @@ -0,0 +1,127 @@ +// Copyright 2026, the Blazer authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package base + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +const downloadHeadResponseID = "file-id" + +// newOptionsWire starts a recording server that answers the calls the option +// tests make: authorize, list buckets and a download by ID. +func newOptionsWire(t *testing.T) *b2wiretest.Server { + t.Helper() + srv := b2wiretest.New(t) + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) + srv.Handle("b2_download_file_by_id", func(b2wiretest.Request) b2wiretest.Response { + return b2wiretest.Response{Status: http.StatusOK, Header: http.Header{ + "Content-Length": {"0"}, + "X-Bz-File-Id": {downloadHeadResponseID}, + "X-Bz-File-Name": {"name"}, + }} + }) + return srv +} + +// authorizeAndCall runs b2_authorize_account, a JSON call and a download with +// the options, and returns the request for each. +func authorizeAndCall(t *testing.T, opts ...AuthOption) (auth, list, download b2wiretest.Request) { + t.Helper() + srv := newOptionsWire(t) + ctx := context.Background() + b, err := AuthorizeAccount(ctx, "account", "key", append([]AuthOption{SetAPIBase(srv.URL())}, opts...)...) + if err != nil { + t.Fatal(err) + } + if _, err := b.ListBuckets(ctx, ""); err != nil { + t.Fatal(err) + } + reader, err := (&Bucket{b2: b}).DownloadFileByID(ctx, downloadHeadResponseID, 0, 0, true) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + return srv.Last(t, "b2_authorize_account"), srv.Last(t, "b2_list_buckets"), srv.Last(t, "b2_download_file_by_id") +} + +// The B2 service reads these headers to inject failures for client testing: +// https://www.backblaze.com/docs/cloud-storage-integration-checklist. +func TestTestModeOptionsSendHeaderOnEveryRequestKind(t *testing.T) { + for _, tc := range []struct { + name string + opts []AuthOption + want []string + }{ + {"none", nil, nil}, + {"fail some uploads", []AuthOption{FailSomeUploads()}, []string{"fail_some_uploads"}}, + {"expire some tokens", []AuthOption{ExpireSomeAuthTokens()}, []string{"expire_some_account_authorization_tokens"}}, + {"force cap exceeded", []AuthOption{ForceCapExceeded()}, []string{"force_cap_exceeded"}}, + {"all three", []AuthOption{ForceCapExceeded(), FailSomeUploads(), ExpireSomeAuthTokens()}, + []string{"fail_some_uploads", "expire_some_account_authorization_tokens", "force_cap_exceeded"}}, + } { + t.Run(tc.name, func(t *testing.T) { + auth, list, download := authorizeAndCall(t, tc.opts...) + for _, r := range []b2wiretest.Request{auth, list, download} { + if got := r.Header.Values("X-Bz-Test-Mode"); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s X-Bz-Test-Mode = %q, want %q", r.Call, got, tc.want) + } + } + }) + } +} + +func TestUserAgentOptionsArePrependedToTheDefault(t *testing.T) { + for _, tc := range []struct { + name string + opts []AuthOption + want string + }{ + {"default", nil, DefaultUserAgent}, + {"one", []AuthOption{UserAgent("app/1")}, "app/1 " + DefaultUserAgent}, + {"later options come first", []AuthOption{UserAgent("app/1"), UserAgent("lib/2")}, "lib/2 app/1 " + DefaultUserAgent}, + } { + t.Run(tc.name, func(t *testing.T) { + auth, list, download := authorizeAndCall(t, tc.opts...) + for _, r := range []b2wiretest.Request{auth, list, download} { + b2wiretest.AssertHeader(t, r, "User-Agent", tc.want) + } + }) + } +} + +func TestBucketURLsComeFromTheAuthorizeResponse(t *testing.T) { + srv := b2wiretest.New(t) + srv.Reply("b2_authorize_account", http.StatusOK, fmt.Sprintf(`{"accountId":"a","authorizationToken":"t","applicationKeyExpirationTimestamp":0,"apiInfo":{"storageApi":{"absoluteMinimumPartSize":5000000,"apiUrl":%q,"capabilities":[],"downloadUrl":"https://download.example.test","storageApi":"storage","recommendedPartSize":100000000,"s3ApiUrl":"https://s3.example.test","allowed":{"buckets":null,"capabilities":[],"namePrefix":null}}}}`, srv.URL())) + b, err := AuthorizeAccount(context.Background(), "account", "key", SetAPIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + bucket := &Bucket{b2: b} + if got, want := bucket.BaseURL(), "https://download.example.test"; got != want { + t.Errorf("BaseURL() = %q, want %q", got, want) + } + if got, want := bucket.S3URL(), "https://s3.example.test"; got != want { + t.Errorf("S3URL() = %q, want %q", got, want) + } +}