Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a719a77
docs(intent): add the intent for a shared recording B2 server for wir…
ffumero2003 Oct 28, 2026
d6970a9
docs(intent): add the spec and plan for the shared recording B2 server
ffumero2003 Oct 28, 2026
9984f36
test(internal): add recording B2 wire server
ffumero2003 Oct 28, 2026
7a6cfc9
test(b2): use shared recorder for CORS rule name
ffumero2003 Oct 28, 2026
99f64a0
test(internal): check recording server response closes
ffumero2003 Oct 28, 2026
ccc6d55
test(b2): retain CORS rule count assertion
ffumero2003 Oct 28, 2026
17d361b
test(internal): reproduce shared JSON map race
ffumero2003 Oct 28, 2026
3f6f077
fix(internal): isolate recorded JSON maps
ffumero2003 Oct 28, 2026
a733261
docs: direct wire tests to shared recorder
ffumero2003 Oct 28, 2026
a69a11a
docs(intent): record wire-test query parameters
ffumero2003 Oct 28, 2026
8fc8cf9
test(internal): record wire-test query parameters
ffumero2003 Oct 28, 2026
b61fcd8
fix(internal): keep request data out of the unscripted response
ffumero2003 Oct 28, 2026
57d42fa
test(internal): verify recording server cleanup
ffumero2003 Oct 29, 2026
e2d5726
fix(internal): set recording response security headers
ffumero2003 Oct 29, 2026
a9748f3
docs(intent): plan credential-free tests for live-only calls (#203)
ffumero2003 Oct 29, 2026
e287c78
docs(intent): correct R8 to the shared-upload-token refusal the live …
ffumero2003 Oct 29, 2026
2a7cd81
test: add credential-free tests for calls only covered by live tests
ffumero2003 Oct 29, 2026
0407c68
test(transport): pin Stall context cancellation
ffumero2003 Oct 29, 2026
0791767
test(internal): reproduce recording server review edge cases
ffumero2003 Oct 29, 2026
5fc1c1e
fix(internal): handle recorder defaults and late requests
ffumero2003 Oct 29, 2026
6c6e654
refactor(internal): share the recorded JSON body decode
ffumero2003 Oct 29, 2026
c98ec4d
Merge remote-tracking branch 'origin/test-issue-191-shared-recording-…
ffumero2003 Oct 29, 2026
3b58143
Merge remote-tracking branch 'origin/main' into test-issue-203-creden…
ffumero2003 Oct 29, 2026
5873ca7
Merge remote-tracking branch 'origin/main' into test-issue-203-creden…
ffumero2003 Oct 29, 2026
a3268c2
test: contrast newest and older versions in Reveal and pin AfterNByte…
ffumero2003 Oct 29, 2026
7231f3b
Merge remote-tracking branch 'origin/main' into test-issue-203-creden…
ffumero2003 Oct 30, 2026
fe2843e
test: tighten live-only wire and transport assertions
ffumero2003 Oct 30, 2026
11be84d
docs(intent): trim the #203 plan to its proof tables
ffumero2003 Oct 30, 2026
b24e27a
Merge remote-tracking branch 'origin/main' into test-issue-203-creden…
ffumero2003 Oct 30, 2026
690a8f5
test: tighten live-only call assertions
ffumero2003 Oct 30, 2026
05d3911
docs(intent): cite key expiration fix
ffumero2003 Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions b2/live_only_calls_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package b2

import (
"context"
"errors"
"io"
"net/http"
"testing"
"time"

"github.com/Backblaze/blazer/base"
"github.com/Backblaze/blazer/internal/b2wiretest"
)

// Wire fields below follow https://www.backblaze.com/apidocs/b2-hide-file
// and https://www.backblaze.com/apidocs/b2-delete-file-version.
func TestObjectHideWire(t *testing.T) {
t.Run("sends name and bucket", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Handle("gone", liveOnlyHead("file-id", "gone"))
srv.Reply("b2_hide_file", http.StatusOK, `{"fileId":"hide-id","action":"hide","uploadTimestamp":0}`)
if err := bucket.Object("gone").Hide(context.Background()); err != nil {
t.Fatal(err)
}
calls := srv.Calls("b2_hide_file")
if len(calls) != 1 {
t.Fatalf("b2_hide_file calls = %d, want 1", len(calls))
}
b2wiretest.AssertJSON(t, calls[0], "bucketId", "bid")
b2wiretest.AssertJSON(t, calls[0], "fileName", "gone")
})
t.Run("returns service errors", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Handle("gone", liveOnlyHead("file-id", "gone"))
srv.Reply("b2_hide_file", http.StatusBadRequest, `{"status":400,"code":"bad_request","message":"cannot hide"}`)
err := bucket.Object("gone").Hide(context.Background())
if status, code, message := base.MsgCode(err); status != 400 || code != "bad_request" || message != "cannot hide" {
t.Errorf("Hide error = %v (status=%d code=%q message=%q), want 400 bad_request: cannot hide", err, status, code, message)
}
if calls := srv.Calls("b2_hide_file"); len(calls) != 1 {
t.Errorf("b2_hide_file calls = %d, want 1", len(calls))
}
})
}

func TestBucketRevealWire(t *testing.T) {
t.Run("deletes newest hide marker", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
// b2_list_file_versions returns versions in newest-first order.
srv.Reply("b2_list_file_versions", http.StatusOK, `{"files":[{"fileId":"hide-id","fileName":"gone","action":"hide","uploadTimestamp":2},{"fileId":"upload-id","fileName":"gone","action":"upload","uploadTimestamp":1}],"nextFileName":"","nextFileId":""}`)
srv.Reply("b2_delete_file_version", http.StatusOK, `{}`)
if err := bucket.Reveal(context.Background(), "gone"); err != nil {
t.Fatal(err)
}
listCalls := srv.Calls("b2_list_file_versions")
if len(listCalls) != 1 {
t.Fatalf("b2_list_file_versions calls = %d, want 1", len(listCalls))
}
b2wiretest.AssertJSON(t, listCalls[0], "prefix", "gone")
deleteCalls := srv.Calls("b2_delete_file_version")
if len(deleteCalls) != 1 {
t.Fatalf("b2_delete_file_version calls = %d, want 1", len(deleteCalls))
}
b2wiretest.AssertJSON(t, deleteCalls[0], "fileId", "hide-id")
b2wiretest.AssertJSON(t, deleteCalls[0], "fileName", "gone")
})
t.Run("leaves a current upload alone", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
// b2_list_file_versions returns versions in newest-first order.
srv.Reply("b2_list_file_versions", http.StatusOK, `{"files":[{"fileId":"upload-id","fileName":"gone","action":"upload","uploadTimestamp":2},{"fileId":"hide-id","fileName":"gone","action":"hide","uploadTimestamp":1}],"nextFileName":"","nextFileId":""}`)
srv.Reply("b2_delete_file_version", http.StatusOK, `{}`)
if err := bucket.Reveal(context.Background(), "gone"); err != nil {
t.Fatal(err)
}
calls := srv.Calls("b2_list_file_versions")
if len(calls) != 1 {
t.Fatalf("b2_list_file_versions calls = %d, want 1", len(calls))
}
b2wiretest.AssertJSON(t, calls[0], "prefix", "gone")
if calls := srv.Calls("b2_delete_file_version"); len(calls) != 0 {
t.Fatalf("b2_delete_file_version calls = %d, want 0", len(calls))
}
})
}

// Request fields follow https://www.backblaze.com/apidocs/b2-get-download-authorization.
// URL arguments follow https://www.backblaze.com/docs/cloud-storage-download-authorization.
func TestBucketAuthTokenWire(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Reply("b2_get_download_authorization", http.StatusOK, `{"authorizationToken":"download-token"}`)
token, err := bucket.AuthToken(context.Background(), "p/", time.Hour)
if err != nil {
t.Fatal(err)
}
if token != "download-token" {
t.Errorf("token = %q, want %q", token, "download-token")
}
request := srv.Last(t, "b2_get_download_authorization")
b2wiretest.AssertJSON(t, request, "bucketId", "bid")
b2wiretest.AssertJSON(t, request, "fileNamePrefix", "p/")
b2wiretest.AssertJSON(t, request, "validDurationInSeconds", 3600)
b2wiretest.AssertNoJSON(t, request, "b2ContentDisposition")
}

func TestObjectAuthURLWire(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Reply("b2_get_download_authorization", http.StatusOK, `{"authorizationToken":"download-token"}`)
const (
objectName = "folder/a b.txt"
disposition = `attachment; filename="a b.txt"`
)
object := bucket.Object(objectName)
u, err := object.AuthURL(context.Background(), time.Hour, disposition)
if err != nil {
t.Fatal(err)
}
if got := u.Query().Get("Authorization"); got != "download-token" {
t.Errorf("Authorization = %q, want %q", got, "download-token")
}
if got := u.Query().Get("b2ContentDisposition"); got != disposition {
t.Errorf("b2ContentDisposition = %q, want %q", got, disposition)
}
request := srv.Last(t, "b2_get_download_authorization")
b2wiretest.AssertJSON(t, request, "fileNamePrefix", objectName)
b2wiretest.AssertJSON(t, request, "b2ContentDisposition", disposition)
u, err = object.AuthURL(context.Background(), time.Hour, "")
if err != nil {
t.Fatal(err)
}
if _, ok := u.Query()["b2ContentDisposition"]; ok {
t.Errorf("empty disposition URL query = %q, want b2ContentDisposition omitted", u.RawQuery)
}
if got := u.Query().Get("Authorization"); got != "download-token" {
t.Errorf("empty disposition Authorization = %q, want %q", got, "download-token")
}
request = srv.Last(t, "b2_get_download_authorization")
b2wiretest.AssertNoJSON(t, request, "b2ContentDisposition")
}

// Request and response fields follow https://www.backblaze.com/apidocs/b2-list-keys
// and https://www.backblaze.com/apidocs/b2-delete-key.
func TestClientListKeysAndDeleteWire(t *testing.T) {
srv := b2wiretest.New(t)
client := liveOnlyClient(t, srv)
// #244's null expiry mapping is fixed by #250; this test does not assert it.
srv.ReplySequence("b2_list_keys",
b2wiretest.Response{Status: http.StatusOK, Body: `{"keys":[{"applicationKeyId":"k-1","keyName":"first","capabilities":["listBuckets"],"expirationTimestamp":1700000000123},{"applicationKeyId":"k-2","keyName":"second","capabilities":["readFiles"],"expirationTimestamp":null}],"nextApplicationKeyId":"k-3"}`},
b2wiretest.Response{Status: http.StatusOK, Body: `{"keys":[]}`},
)
srv.Reply("b2_delete_key", http.StatusOK, `{}`)
keys, next, err := client.ListKeys(context.Background(), 10, "")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 || next != "k-3" {
t.Fatalf("ListKeys = %d keys, %q, want 2 keys, k-3", len(keys), next)
}
// Capabilities mapping is tracked separately in #220.
if keys[0].ID() != "k-1" || keys[0].Name() != "first" || keys[0].Expires().UnixMilli() != 1700000000123 {
t.Errorf("first key = id=%q name=%q expires=%d", keys[0].ID(), keys[0].Name(), keys[0].Expires().UnixMilli())
}
if keys[1].ID() != "k-2" || keys[1].Name() != "second" {
t.Errorf("second key = id=%q name=%q, want k-2, second", keys[1].ID(), keys[1].Name())
}
request := srv.Last(t, "b2_list_keys")
b2wiretest.AssertJSON(t, request, "accountId", "a")
b2wiretest.AssertJSON(t, request, "maxKeyCount", 10)
b2wiretest.AssertNoJSON(t, request, "startApplicationKeyId")
if _, _, err := client.ListKeys(context.Background(), 10, next); !errors.Is(err, io.EOF) {
t.Errorf("second ListKeys error = %v, want io.EOF", err)
}
request = srv.Last(t, "b2_list_keys")
b2wiretest.AssertJSON(t, request, "startApplicationKeyId", "k-3")
if err := keys[0].Delete(context.Background()); err != nil {
t.Fatal(err)
}
request = srv.Last(t, "b2_delete_key")
b2wiretest.AssertJSON(t, request, "applicationKeyId", "k-1")
}

// Request fields follow https://www.backblaze.com/apidocs/b2-cancel-large-file.
func TestObjectCancelWire(t *testing.T) {
t.Run("unfinished file", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Reply("b2_list_unfinished_large_files", http.StatusOK, `{"files":[{"fileId":"large-id","fileName":"pending","action":"start","uploadTimestamp":0}],"nextFileId":""}`)
srv.Reply("b2_cancel_large_file", http.StatusOK, `{}`)
iter := bucket.List(context.Background(), ListUnfinished())
if !iter.Next() {
t.Fatalf("unfinished list returned no object: %v", iter.Err())
}
if err := iter.Object().Cancel(context.Background()); err != nil {
t.Fatal(err)
}
request := srv.Last(t, "b2_cancel_large_file")
b2wiretest.AssertJSON(t, request, "fileId", "large-id")
})
t.Run("uploaded file", func(t *testing.T) {
srv, bucket := liveOnlyBucket(t)
srv.Reply("b2_list_file_names", http.StatusOK, `{"files":[{"fileId":"upload-id","fileName":"complete","action":"upload","uploadTimestamp":0}],"nextFileName":""}`)
srv.Reply("b2_cancel_large_file", http.StatusOK, `{}`)
iter := bucket.List(context.Background())
if !iter.Next() {
t.Fatalf("current list returned no object: %v", iter.Err())
}
const want = "complete is not an unfinished large file: upload"
if err := iter.Object().Cancel(context.Background()); err == nil || err.Error() != want {
t.Errorf("Cancel uploaded file error = %v, want %q", err, want)
}
if calls := srv.Calls("b2_cancel_large_file"); len(calls) != 0 {
t.Fatalf("b2_cancel_large_file calls = %d, want 0", len(calls))
}
})
}

func liveOnlyClient(t *testing.T, srv *b2wiretest.Server) *Client {
t.Helper()
client, err := NewClient(context.Background(), "a", "k", APIBase(srv.URL()))
if err != nil {
t.Fatal(err)
}
return client
}

func liveOnlyBucket(t *testing.T) (*b2wiretest.Server, *Bucket) {
t.Helper()
srv := b2wiretest.New(t)
srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`)
srv.Reply("b2_create_bucket", http.StatusOK, updateBucketJSON)
bucket, err := liveOnlyClient(t, srv).NewBucket(context.Background(), "n", nil)
if err != nil {
t.Fatal(err)
}
return srv, bucket
}

func liveOnlyHead(id, name string) func(b2wiretest.Request) b2wiretest.Response {
return func(b2wiretest.Request) b2wiretest.Response {
return b2wiretest.Response{Status: http.StatusOK, Header: http.Header{
"Content-Length": {"0"},
"X-Bz-File-Id": {id},
"X-Bz-File-Name": {name},
}}
}
}
Loading
Loading