From bcfeb62ddb8a3e1eff10f75e9727441736139f23 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 11:48:51 -0600 Subject: [PATCH 1/6] test(b2): strengthen live object assertions --- b2/integration_test.go | 92 ++++++++++++++++++++++++------- intent/b2-live-assertions/plan.md | 30 ++++++++++ 2 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 intent/b2-live-assertions/plan.md diff --git a/b2/integration_test.go b/b2/integration_test.go index f3e98efb..bf4764e6 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/intent/b2-live-assertions/plan.md b/intent/b2-live-assertions/plan.md new file mode 100644 index 00000000..bae65ef2 --- /dev/null +++ b/intent/b2-live-assertions/plan.md @@ -0,0 +1,30 @@ +| Change | Value | +| --- | --- | +| Issue / slice | #204 / b2 core C2 | +| Scope | Test-only; `b2/integration_test.go`, new wire twins and this proof table | +| Baseline | `9b89b88`; clean worktree; `go test ./b2/...` passed with credentials unset | +| Test levels | Wire twins run locally; live tests require B2 credentials in the main-branch live CI job | +| Plan / self-review | Assert observable results and recorded requests; avoid cached deletion checks, destination-derived copy expectations and account-dependent SSE expectations | +| Constraints | No changes to production, `b2/b2_test.go`, `b2/writer.go` or `b2/reader.go`; no GitHub writes | + +| Item | Tests | Status / assertion catches | +| --- | --- | --- | +| R1 | `TestResumeWriterWithoutExtantFile` | Done: live-only, compile-checked; catches missing finalization or altered/truncated content after resume without an existing upload | +| R2 | `TestZeroByteObject` | Done: live-only, compile-checked; catches nonzero size metadata for an empty upload; wire twin pending | +| R3 | `TestDeleteWithoutName`; `TestWriteEmpty` | Done: live-only, compile-checked; catches deletion that reports success while leaving the object readable, and nonempty content after an empty write; wire twins pending | +| R4 | `TestListUnfinishedLargeFiles` | Done: live-only, compile-checked; catches wrong names, missing/extra entries and iterator errors; valid part sizes and writer finalization after listing | +| R5 | `TestFileBufferLive` | Done: live-only, compile-checked; catches altered/truncated bytes uploaded through the file buffer | +| R6 | `TestCopyLive` | Done: live-only, compile-checked; catches a corrupt copy even when its own reported SHA1 matches its corrupt content | +| R7 | `TestReauthPreservesOptions` | Live-only, compile-checked: catches a refreshed client that cannot access the original bucket; behavioral wire twin pending | +| R8 | `TestNewBucket`; `TestBucketDefaultEncryption` | Live-only, compile-checked: catches a wrong bucket name or explicit SSE-B2 setting; omitted SSE moves to a deterministic wire twin (pending) | + +| Check | Result | +| --- | --- | +| `go vet ./...` | PASS (exit 0); all live assertions compile; live B2 execution NOT RUN | +| `go test ./b2/...` (baseline) | PASS: b2 9.242s; b2/sync 2.759s | + +```sh +unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY +go vet ./... +go test ./b2/... +``` From bd8216f66a62dbf7b3beded6a68c81f1a51cfdc2 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 11:52:40 -0600 Subject: [PATCH 2/6] test(b2): add empty object and delete wire twins --- b2/empty_delete_wire_test.go | 129 ++++++++++++++++++++++++++++++ intent/b2-live-assertions/plan.md | 11 ++- 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 b2/empty_delete_wire_test.go diff --git a/b2/empty_delete_wire_test.go b/b2/empty_delete_wire_test.go new file mode 100644 index 00000000..24d8a25d --- /dev/null +++ b/b2/empty_delete_wire_test.go @@ -0,0 +1,129 @@ +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 reader.Close() + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); 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/intent/b2-live-assertions/plan.md b/intent/b2-live-assertions/plan.md index bae65ef2..bbc0a232 100644 --- a/intent/b2-live-assertions/plan.md +++ b/intent/b2-live-assertions/plan.md @@ -10,14 +10,19 @@ | Item | Tests | Status / assertion catches | | --- | --- | --- | | R1 | `TestResumeWriterWithoutExtantFile` | Done: live-only, compile-checked; catches missing finalization or altered/truncated content after resume without an existing upload | -| R2 | `TestZeroByteObject` | Done: live-only, compile-checked; catches nonzero size metadata for an empty upload; wire twin pending | -| R3 | `TestDeleteWithoutName`; `TestWriteEmpty` | Done: live-only, compile-checked; catches deletion that reports success while leaving the object readable, and nonempty content after an empty write; wire twins pending | +| R2 | `TestZeroByteObject`; `TestZeroByteObjectWire` | Done: live-only, compile-checked; catches nonzero size metadata for an empty upload; cheap offline twin M1 | +| R3 | `TestDeleteWithoutName`; `TestWriteEmpty`; `TestDeleteWithoutNameWire`; `TestZeroByteObjectWire` | Done: live-only, compile-checked; catches deletion that reports success while leaving the object readable, and nonempty content after an empty write; cheap offline twins M2/M1 | | R4 | `TestListUnfinishedLargeFiles` | Done: live-only, compile-checked; catches wrong names, missing/extra entries and iterator errors; valid part sizes and writer finalization after listing | | R5 | `TestFileBufferLive` | Done: live-only, compile-checked; catches altered/truncated bytes uploaded through the file buffer | | R6 | `TestCopyLive` | Done: live-only, compile-checked; catches a corrupt copy even when its own reported SHA1 matches its corrupt content | | R7 | `TestReauthPreservesOptions` | Live-only, compile-checked: catches a refreshed client that cannot access the original bucket; behavioral wire twin pending | | R8 | `TestNewBucket`; `TestBucketDefaultEncryption` | Live-only, compile-checked: catches a wrong bucket name or explicit SSE-B2 setting; omitted SSE moves to a deterministic wire twin (pending) | +| Break | Twin / production behavior | Temporary mutation | Failing output (exit 1) | Restored output (exit 0) | +| --- | --- | --- | --- | --- | +| M1 (R2/R3) | `TestZeroByteObjectWire`; `b2/b2.go:attrsFromFileInfo` | Map `Size` to `size + 1` | `empty object size = 1, want 0` (both chunk-size cases); `FAIL .../b2 0.724s` | `ok .../b2 0.586s` | +| M2 (R3) | `TestDeleteWithoutNameWire`; `b2/b2.go:Object.Delete` | Return nil instead of calling `deleteFileVersion` | `Attrs after Delete = , want not-exist`; `delete calls = 0, want 1`; `FAIL .../b2 0.707s` | `ok .../b2 0.438s` | + | Check | Result | | --- | --- | | `go vet ./...` | PASS (exit 0); all live assertions compile; live B2 execution NOT RUN | @@ -27,4 +32,6 @@ unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY go vet ./... go test ./b2/... +go test ./b2 -run '^TestZeroByteObjectWire$' -count=1 -timeout=30s +go test ./b2 -run '^TestDeleteWithoutNameWire$' -count=1 -timeout=30s ``` From 86fc4320e9c3c9c1aee880751205896fc23436b1 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:02:00 -0600 Subject: [PATCH 3/6] test(b2): pin reauth options and bucket defaults --- b2/bucket_creation_wire_test.go | 126 ++++++++++++++++++++++++++++++ b2/empty_delete_wire_test.go | 9 ++- b2/reauth_options_wire_test.go | 80 +++++++++++++++++++ intent/b2-live-assertions/plan.md | 23 +++++- 4 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 b2/bucket_creation_wire_test.go create mode 100644 b2/reauth_options_wire_test.go diff --git a/b2/bucket_creation_wire_test.go b/b2/bucket_creation_wire_test.go new file mode 100644 index 00000000..27e5b8bb --- /dev/null +++ b/b2/bucket_creation_wire_test.go @@ -0,0 +1,126 @@ +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 + }{ + {name: "no-attrs"}, + { + name: "only-info", attrs: &BucketAttrs{Info: map[string]string{"this": "that", "other": "thing"}}, + info: 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}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + srv := b2wiretest.New(t) + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) + srv.Reply("b2_create_bucket", http.StatusOK, updateBucketJSON) + 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") + }) + } +} + +// 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 index 24d8a25d..0f3bb2d4 100644 --- a/b2/empty_delete_wire_test.go +++ b/b2/empty_delete_wire_test.go @@ -55,14 +55,15 @@ func TestZeroByteObjectWire(t *testing.T) { } b2wiretest.AssertJSON(t, srv.Last(t, "b2_get_file_info"), "fileId", "empty-id") reader := bucket.Object("empty").NewReader(ctx) - defer reader.Close() + defer func() { + if err := reader.Close(); err != nil { + t.Error(err) + } + }() data, err := io.ReadAll(reader) if err != nil { t.Fatal(err) } - if err := reader.Close(); err != nil { - t.Fatal(err) - } if len(data) != 0 { t.Fatalf("empty object content = %q, want empty", data) } diff --git a/b2/reauth_options_wire_test.go b/b2/reauth_options_wire_test.go new file mode 100644 index 00000000..24e52734 --- /dev/null +++ b/b2/reauth_options_wire_test.go @@ -0,0 +1,80 @@ +package b2 + +import ( + "context" + "fmt" + "net/http" + "reflect" + "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()) + 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{"fail_some_uploads", "expire_some_account_authorization_tokens"} + if got := 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") + } +} diff --git a/intent/b2-live-assertions/plan.md b/intent/b2-live-assertions/plan.md index bbc0a232..70f3464a 100644 --- a/intent/b2-live-assertions/plan.md +++ b/intent/b2-live-assertions/plan.md @@ -15,18 +15,30 @@ | R4 | `TestListUnfinishedLargeFiles` | Done: live-only, compile-checked; catches wrong names, missing/extra entries and iterator errors; valid part sizes and writer finalization after listing | | R5 | `TestFileBufferLive` | Done: live-only, compile-checked; catches altered/truncated bytes uploaded through the file buffer | | R6 | `TestCopyLive` | Done: live-only, compile-checked; catches a corrupt copy even when its own reported SHA1 matches its corrupt content | -| R7 | `TestReauthPreservesOptions` | Live-only, compile-checked: catches a refreshed client that cannot access the original bucket; behavioral wire twin pending | -| R8 | `TestNewBucket`; `TestBucketDefaultEncryption` | Live-only, compile-checked: catches a wrong bucket name or explicit SSE-B2 setting; omitted SSE moves to a deterministic wire twin (pending) | +| R7 | `TestReauthPreservesOptions`; `TestReauthPreservesOptionsWire` | Done: live-only, compile-checked; catches a refreshed client that cannot access the original bucket; M3/M4 pin user agents, test modes, transport, endpoint, refreshed token and account on real HTTP requests | +| R8 | `TestNewBucket`; `TestBucketDefaultEncryption`; `TestNewBucketAttributesWire`; `TestNewBucketDefaultEncryptionWire` | Done: live-only, compile-checked; catches a wrong bucket name or explicit SSE-B2 setting; M5/M6/M7 pin create fields, omitted/explicit SSE and decoded service settings without an account default assumption | | Break | Twin / production behavior | Temporary mutation | Failing output (exit 1) | Restored output (exit 0) | | --- | --- | --- | --- | --- | | M1 (R2/R3) | `TestZeroByteObjectWire`; `b2/b2.go:attrsFromFileInfo` | Map `Size` to `size + 1` | `empty object size = 1, want 0` (both chunk-size cases); `FAIL .../b2 0.724s` | `ok .../b2 0.586s` | | M2 (R3) | `TestDeleteWithoutNameWire`; `b2/b2.go:Object.Delete` | Return nil instead of calling `deleteFileVersion` | `Attrs after Delete = , want not-exist`; `delete calls = 0, want 1`; `FAIL .../b2 0.707s` | `ok .../b2 0.438s` | +| M3 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `userAgents` in the options passed to reauthorization | Requests 2/3: `User-Agent = "blazer/0.9.0", want both custom agents before blazer/`; `FAIL .../b2 0.467s` | `ok .../b2 0.688s` | +| M4 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `transport` in the options passed to reauthorization | `configured transport calls = 2, want 4`; `FAIL .../b2 0.676s` | `ok .../b2 0.285s` | +| M5 (R8) | `TestNewBucketAttributesWire`; `b2/b2.go:Client.NewBucket` | Pass nil lifecycle rules to `createBucket` | `b2_create_bucket body lifecycleRules: got null` instead of the two literal rules; `FAIL .../b2 0.312s` | `ok .../b2 0.276s` | +| M6 (R8) | `TestNewBucketDefaultEncryptionWire`; `base/base.go:B2.CreateBucket` | Always send SSE-B2/AES256, even when omitted | `defaultServerSideEncryption: got {"algorithm":"AES256","mode":"SSE-B2"}, want missing` (both omitted cases); `FAIL .../b2 0.670s` | `ok .../b2 0.272s` | +| M7 (R8) | `TestNewBucketDefaultEncryptionWire`; `b2/baseline.go:b2Bucket.attrs` | Decode the returned SSE mode as `broken` | `default encryption = &{Mode:broken Algorithm:AES256}, want &{Mode:SSE-B2 Algorithm:AES256}`; `FAIL .../b2 0.715s` | `ok .../b2 0.273s` | | Check | Result | | --- | --- | -| `go vet ./...` | PASS (exit 0); all live assertions compile; live B2 execution NOT RUN | +| `go vet ./...` (final) | PASS (exit 0, no output); all live assertions compile; live B2 execution NOT RUN | | `go test ./b2/...` (baseline) | PASS: b2 9.242s; b2/sync 2.759s | +| Object twins with `-race -count=1` | PASS: b2 1.781s | +| `go test -race -count=5 -shuffle=on ./b2/...` (final, after cleanup lint fix) | PASS (exit 0): `ok github.com/Backblaze/blazer/b2 63.896s`; `ok github.com/Backblaze/blazer/b2/sync 17.194s` | +| Scoped live-test invocation with credentials unset | All ten scoped tests SKIP; live-only, compile-checked; `ok .../b2 0.266s` is not evidence of live B2 execution | +| `make ci` (first attempt) | FAIL at lint: unchecked deferred `reader.Close()` in the empty-object twin; fixed by checking the deferred close error | +| `make ci` (final) | PASS (exit 0): lint `0 issues.`; b2 race tests 13.665s; b2/sync 4.337s; base 6.811s; module/build/format checks, remaining package tests, b2keys, all five cross-compiles, changelog-check and all five script checks passed | +| Scope audit | All temporary production mutations restored; protected sibling files unchanged; no B2 credentials used | +| SSE contract | [b2_create_bucket](https://www.backblaze.com/apidocs/b2-create-bucket): omit the request field to leave encryption to the service; twin fixtures include the documented SSE-B2/AES256 response and legacy null metadata | ```sh unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY @@ -34,4 +46,9 @@ go vet ./... go test ./b2/... go test ./b2 -run '^TestZeroByteObjectWire$' -count=1 -timeout=30s go test ./b2 -run '^TestDeleteWithoutNameWire$' -count=1 -timeout=30s +go test ./b2 -run '^TestReauthPreservesOptionsWire$' -count=1 -timeout=30s +go test ./b2 -run '^TestNewBucketAttributesWire$' -count=1 -timeout=30s +go test ./b2 -run '^TestNewBucketDefaultEncryptionWire$' -count=1 -timeout=30s +go test -race -count=5 -shuffle=on ./b2/... +make ci ``` From eb9f02a84271f43fc9e577814b967fd8ce9333eb Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:39:07 -0600 Subject: [PATCH 4/6] test(b2): cover cap-exceeded reauthorization --- b2/reauth_options_wire_test.go | 18 +++++++++++++++--- intent/b2-live-assertions/plan.md | 11 +++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/b2/reauth_options_wire_test.go b/b2/reauth_options_wire_test.go index 24e52734..f15cbe40 100644 --- a/b2/reauth_options_wire_test.go +++ b/b2/reauth_options_wire_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "reflect" + "sort" "strings" "testing" "time" @@ -29,7 +30,7 @@ func TestReauthPreservesOptionsWire(t *testing.T) { ) recorder := &recordingTransport{next: http.DefaultTransport} client, err := NewClient(ctx, "account", "key", APIBase(srv.URL()), Transport(recorder), - UserAgent("first-agent"), UserAgent("second-agent"), FailSomeUploads(), ExpireSomeAuthTokens()) + UserAgent("first-agent"), UserAgent("second-agent"), FailSomeUploads(), ExpireSomeAuthTokens(), ForceCapExceeded()) if err != nil { t.Fatal(err) } @@ -62,8 +63,8 @@ func TestReauthPreservesOptionsWire(t *testing.T) { 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{"fail_some_uploads", "expire_some_account_authorization_tokens"} - if got := request.Header.Values("X-Bz-Test-Mode"); !reflect.DeepEqual(got, wantModes) { + 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) } } @@ -78,3 +79,14 @@ func TestReauthPreservesOptionsWire(t *testing.T) { 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 +} diff --git a/intent/b2-live-assertions/plan.md b/intent/b2-live-assertions/plan.md index 70f3464a..699eb05a 100644 --- a/intent/b2-live-assertions/plan.md +++ b/intent/b2-live-assertions/plan.md @@ -1,6 +1,7 @@ | Change | Value | | --- | --- | -| Issue / slice | #204 / b2 core C2 | +| Issue / slice | #204, b2 core slice C2 (integration_test.go) | +| Requirements | R1–R8 are the eight items in #204's `integration_test.go` section, in order | | Scope | Test-only; `b2/integration_test.go`, new wire twins and this proof table | | Baseline | `9b89b88`; clean worktree; `go test ./b2/...` passed with credentials unset | | Test levels | Wire twins run locally; live tests require B2 credentials in the main-branch live CI job | @@ -15,15 +16,16 @@ | R4 | `TestListUnfinishedLargeFiles` | Done: live-only, compile-checked; catches wrong names, missing/extra entries and iterator errors; valid part sizes and writer finalization after listing | | R5 | `TestFileBufferLive` | Done: live-only, compile-checked; catches altered/truncated bytes uploaded through the file buffer | | R6 | `TestCopyLive` | Done: live-only, compile-checked; catches a corrupt copy even when its own reported SHA1 matches its corrupt content | -| R7 | `TestReauthPreservesOptions`; `TestReauthPreservesOptionsWire` | Done: live-only, compile-checked; catches a refreshed client that cannot access the original bucket; M3/M4 pin user agents, test modes, transport, endpoint, refreshed token and account on real HTTP requests | +| R7 | `TestReauthPreservesOptions`; `TestReauthPreservesOptionsWire` | Done: live-only, compile-checked; catches a refreshed client that cannot access the original bucket; M3/M4/M8 pin user agents, all three test modes, transport, endpoint, refreshed token and account on real HTTP requests | | R8 | `TestNewBucket`; `TestBucketDefaultEncryption`; `TestNewBucketAttributesWire`; `TestNewBucketDefaultEncryptionWire` | Done: live-only, compile-checked; catches a wrong bucket name or explicit SSE-B2 setting; M5/M6/M7 pin create fields, omitted/explicit SSE and decoded service settings without an account default assumption | | Break | Twin / production behavior | Temporary mutation | Failing output (exit 1) | Restored output (exit 0) | | --- | --- | --- | --- | --- | | M1 (R2/R3) | `TestZeroByteObjectWire`; `b2/b2.go:attrsFromFileInfo` | Map `Size` to `size + 1` | `empty object size = 1, want 0` (both chunk-size cases); `FAIL .../b2 0.724s` | `ok .../b2 0.586s` | | M2 (R3) | `TestDeleteWithoutNameWire`; `b2/b2.go:Object.Delete` | Return nil instead of calling `deleteFileVersion` | `Attrs after Delete = , want not-exist`; `delete calls = 0, want 1`; `FAIL .../b2 0.707s` | `ok .../b2 0.438s` | -| M3 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `userAgents` in the options passed to reauthorization | Requests 2/3: `User-Agent = "blazer/0.9.0", want both custom agents before blazer/`; `FAIL .../b2 0.467s` | `ok .../b2 0.688s` | -| M4 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `transport` in the options passed to reauthorization | `configured transport calls = 2, want 4`; `FAIL .../b2 0.676s` | `ok .../b2 0.285s` | +| M3 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `userAgents` in the options passed to reauthorization | Requests 2/3: `User-Agent = "blazer/0.9.0", want both custom agents before blazer/`; `FAIL .../b2 0.879s` | Restored before M4; final twin pass recorded below | +| M4 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `transport` in the options passed to reauthorization | `configured transport calls = 2, want 4`; `FAIL .../b2 0.520s` | Restored before final gates | +| M8 (R7) | `TestReauthPreservesOptionsWire`; `b2/baseline.go:authOptions` | Omit the `capExceeded` block that adds `base.ForceCapExceeded()` | Requests 0–3 each lacked `force_cap_exceeded`; `FAIL .../b2 0.673s` | Restored before M3/M4 and final gates | | M5 (R8) | `TestNewBucketAttributesWire`; `b2/b2.go:Client.NewBucket` | Pass nil lifecycle rules to `createBucket` | `b2_create_bucket body lifecycleRules: got null` instead of the two literal rules; `FAIL .../b2 0.312s` | `ok .../b2 0.276s` | | M6 (R8) | `TestNewBucketDefaultEncryptionWire`; `base/base.go:B2.CreateBucket` | Always send SSE-B2/AES256, even when omitted | `defaultServerSideEncryption: got {"algorithm":"AES256","mode":"SSE-B2"}, want missing` (both omitted cases); `FAIL .../b2 0.670s` | `ok .../b2 0.272s` | | M7 (R8) | `TestNewBucketDefaultEncryptionWire`; `b2/baseline.go:b2Bucket.attrs` | Decode the returned SSE mode as `broken` | `default encryption = &{Mode:broken Algorithm:AES256}, want &{Mode:SSE-B2 Algorithm:AES256}`; `FAIL .../b2 0.715s` | `ok .../b2 0.273s` | @@ -37,6 +39,7 @@ | Scoped live-test invocation with credentials unset | All ten scoped tests SKIP; live-only, compile-checked; `ok .../b2 0.266s` is not evidence of live B2 execution | | `make ci` (first attempt) | FAIL at lint: unchecked deferred `reader.Close()` in the empty-object twin; fixed by checking the deferred close error | | `make ci` (final) | PASS (exit 0): lint `0 issues.`; b2 race tests 13.665s; b2/sync 4.337s; base 6.811s; module/build/format checks, remaining package tests, b2keys, all five cross-compiles, changelog-check and all five script checks passed | +| Panel follow-up gates | `TestReauthPreservesOptionsWire` PASS (0.277s); `go vet ./...` PASS (exit 0, no output); `go test -race -count=5 -shuffle=on ./b2/...` PASS (b2 62.583s; b2/sync 15.378s); `make ci` PASS (lint `0 issues.`, b2 14.191s, b2/sync 4.122s, base 6.849s, b2keys, five cross-compiles and five script checks) | | Scope audit | All temporary production mutations restored; protected sibling files unchanged; no B2 credentials used | | SSE contract | [b2_create_bucket](https://www.backblaze.com/apidocs/b2-create-bucket): omit the request field to leave encryption to the service; twin fixtures include the documented SSE-B2/AES256 response and legacy null metadata | From f3e02feb1cedb0b6d22ba65364f0211024d293e8 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:10:06 -0600 Subject: [PATCH 5/6] test(b2): verify created bucket attributes round trip --- b2/bucket_creation_wire_test.go | 71 +++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/b2/bucket_creation_wire_test.go b/b2/bucket_creation_wire_test.go index 27e5b8bb..7401f62d 100644 --- a/b2/bucket_creation_wire_test.go +++ b/b2/bucket_creation_wire_test.go @@ -14,15 +14,23 @@ import ( // 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 + name string + attrs *BucketAttrs + info any + rules any + response string + wantInfo map[string]string + wantRules []LifecycleRule }{ - {name: "no-attrs"}, + { + 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"}, + 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", @@ -34,12 +42,32 @@ func TestNewBucketAttributesWire(t *testing.T) { {"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.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) - srv.Reply("b2_create_bucket", http.StatusOK, updateBucketJSON) + 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 { @@ -63,6 +91,33 @@ func TestNewBucketAttributesWire(t *testing.T) { 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) + } + } }) } } From 804b628a5b2651993af05c3ac99de9d4f7c415d0 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:10:35 -0600 Subject: [PATCH 6/6] chore: move the C2 proof table to the PR description --- intent/b2-live-assertions/plan.md | 57 ------------------------------- 1 file changed, 57 deletions(-) delete mode 100644 intent/b2-live-assertions/plan.md diff --git a/intent/b2-live-assertions/plan.md b/intent/b2-live-assertions/plan.md deleted file mode 100644 index 699eb05a..00000000 --- a/intent/b2-live-assertions/plan.md +++ /dev/null @@ -1,57 +0,0 @@ -| Change | Value | -| --- | --- | -| Issue / slice | #204, b2 core slice C2 (integration_test.go) | -| Requirements | R1–R8 are the eight items in #204's `integration_test.go` section, in order | -| Scope | Test-only; `b2/integration_test.go`, new wire twins and this proof table | -| Baseline | `9b89b88`; clean worktree; `go test ./b2/...` passed with credentials unset | -| Test levels | Wire twins run locally; live tests require B2 credentials in the main-branch live CI job | -| Plan / self-review | Assert observable results and recorded requests; avoid cached deletion checks, destination-derived copy expectations and account-dependent SSE expectations | -| Constraints | No changes to production, `b2/b2_test.go`, `b2/writer.go` or `b2/reader.go`; no GitHub writes | - -| Item | Tests | Status / assertion catches | -| --- | --- | --- | -| R1 | `TestResumeWriterWithoutExtantFile` | Done: live-only, compile-checked; catches missing finalization or altered/truncated content after resume without an existing upload | -| R2 | `TestZeroByteObject`; `TestZeroByteObjectWire` | Done: live-only, compile-checked; catches nonzero size metadata for an empty upload; cheap offline twin M1 | -| R3 | `TestDeleteWithoutName`; `TestWriteEmpty`; `TestDeleteWithoutNameWire`; `TestZeroByteObjectWire` | Done: live-only, compile-checked; catches deletion that reports success while leaving the object readable, and nonempty content after an empty write; cheap offline twins M2/M1 | -| R4 | `TestListUnfinishedLargeFiles` | Done: live-only, compile-checked; catches wrong names, missing/extra entries and iterator errors; valid part sizes and writer finalization after listing | -| R5 | `TestFileBufferLive` | Done: live-only, compile-checked; catches altered/truncated bytes uploaded through the file buffer | -| R6 | `TestCopyLive` | Done: live-only, compile-checked; catches a corrupt copy even when its own reported SHA1 matches its corrupt content | -| R7 | `TestReauthPreservesOptions`; `TestReauthPreservesOptionsWire` | Done: live-only, compile-checked; catches a refreshed client that cannot access the original bucket; M3/M4/M8 pin user agents, all three test modes, transport, endpoint, refreshed token and account on real HTTP requests | -| R8 | `TestNewBucket`; `TestBucketDefaultEncryption`; `TestNewBucketAttributesWire`; `TestNewBucketDefaultEncryptionWire` | Done: live-only, compile-checked; catches a wrong bucket name or explicit SSE-B2 setting; M5/M6/M7 pin create fields, omitted/explicit SSE and decoded service settings without an account default assumption | - -| Break | Twin / production behavior | Temporary mutation | Failing output (exit 1) | Restored output (exit 0) | -| --- | --- | --- | --- | --- | -| M1 (R2/R3) | `TestZeroByteObjectWire`; `b2/b2.go:attrsFromFileInfo` | Map `Size` to `size + 1` | `empty object size = 1, want 0` (both chunk-size cases); `FAIL .../b2 0.724s` | `ok .../b2 0.586s` | -| M2 (R3) | `TestDeleteWithoutNameWire`; `b2/b2.go:Object.Delete` | Return nil instead of calling `deleteFileVersion` | `Attrs after Delete = , want not-exist`; `delete calls = 0, want 1`; `FAIL .../b2 0.707s` | `ok .../b2 0.438s` | -| M3 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `userAgents` in the options passed to reauthorization | Requests 2/3: `User-Agent = "blazer/0.9.0", want both custom agents before blazer/`; `FAIL .../b2 0.879s` | Restored before M4; final twin pass recorded below | -| M4 (R7) | `TestReauthPreservesOptionsWire`; `b2/backend.go:beRoot.reauthorizeAccount` | Clear `transport` in the options passed to reauthorization | `configured transport calls = 2, want 4`; `FAIL .../b2 0.520s` | Restored before final gates | -| M8 (R7) | `TestReauthPreservesOptionsWire`; `b2/baseline.go:authOptions` | Omit the `capExceeded` block that adds `base.ForceCapExceeded()` | Requests 0–3 each lacked `force_cap_exceeded`; `FAIL .../b2 0.673s` | Restored before M3/M4 and final gates | -| M5 (R8) | `TestNewBucketAttributesWire`; `b2/b2.go:Client.NewBucket` | Pass nil lifecycle rules to `createBucket` | `b2_create_bucket body lifecycleRules: got null` instead of the two literal rules; `FAIL .../b2 0.312s` | `ok .../b2 0.276s` | -| M6 (R8) | `TestNewBucketDefaultEncryptionWire`; `base/base.go:B2.CreateBucket` | Always send SSE-B2/AES256, even when omitted | `defaultServerSideEncryption: got {"algorithm":"AES256","mode":"SSE-B2"}, want missing` (both omitted cases); `FAIL .../b2 0.670s` | `ok .../b2 0.272s` | -| M7 (R8) | `TestNewBucketDefaultEncryptionWire`; `b2/baseline.go:b2Bucket.attrs` | Decode the returned SSE mode as `broken` | `default encryption = &{Mode:broken Algorithm:AES256}, want &{Mode:SSE-B2 Algorithm:AES256}`; `FAIL .../b2 0.715s` | `ok .../b2 0.273s` | - -| Check | Result | -| --- | --- | -| `go vet ./...` (final) | PASS (exit 0, no output); all live assertions compile; live B2 execution NOT RUN | -| `go test ./b2/...` (baseline) | PASS: b2 9.242s; b2/sync 2.759s | -| Object twins with `-race -count=1` | PASS: b2 1.781s | -| `go test -race -count=5 -shuffle=on ./b2/...` (final, after cleanup lint fix) | PASS (exit 0): `ok github.com/Backblaze/blazer/b2 63.896s`; `ok github.com/Backblaze/blazer/b2/sync 17.194s` | -| Scoped live-test invocation with credentials unset | All ten scoped tests SKIP; live-only, compile-checked; `ok .../b2 0.266s` is not evidence of live B2 execution | -| `make ci` (first attempt) | FAIL at lint: unchecked deferred `reader.Close()` in the empty-object twin; fixed by checking the deferred close error | -| `make ci` (final) | PASS (exit 0): lint `0 issues.`; b2 race tests 13.665s; b2/sync 4.337s; base 6.811s; module/build/format checks, remaining package tests, b2keys, all five cross-compiles, changelog-check and all five script checks passed | -| Panel follow-up gates | `TestReauthPreservesOptionsWire` PASS (0.277s); `go vet ./...` PASS (exit 0, no output); `go test -race -count=5 -shuffle=on ./b2/...` PASS (b2 62.583s; b2/sync 15.378s); `make ci` PASS (lint `0 issues.`, b2 14.191s, b2/sync 4.122s, base 6.849s, b2keys, five cross-compiles and five script checks) | -| Scope audit | All temporary production mutations restored; protected sibling files unchanged; no B2 credentials used | -| SSE contract | [b2_create_bucket](https://www.backblaze.com/apidocs/b2-create-bucket): omit the request field to leave encryption to the service; twin fixtures include the documented SSE-B2/AES256 response and legacy null metadata | - -```sh -unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY -go vet ./... -go test ./b2/... -go test ./b2 -run '^TestZeroByteObjectWire$' -count=1 -timeout=30s -go test ./b2 -run '^TestDeleteWithoutNameWire$' -count=1 -timeout=30s -go test ./b2 -run '^TestReauthPreservesOptionsWire$' -count=1 -timeout=30s -go test ./b2 -run '^TestNewBucketAttributesWire$' -count=1 -timeout=30s -go test ./b2 -run '^TestNewBucketDefaultEncryptionWire$' -count=1 -timeout=30s -go test -race -count=5 -shuffle=on ./b2/... -make ci -```