From d679c70bf727ab4d4d09806611a87bc579e709f5 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 11:53:22 -0600 Subject: [PATCH 1/6] test(b2): pin upload retry caps, backoff delays and reader close --- b2/b2_test.go | 118 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/b2/b2_test.go b/b2/b2_test.go index 793fd226..c6b24bb0 100644 --- a/b2/b2_test.go +++ b/b2/b2_test.go @@ -164,8 +164,9 @@ func (t *testRoot) retryAfter(d time.Duration) <-chan time.Time { } type testAfter struct { - calls uint32 - ready chan time.Time + ready chan time.Time + mu sync.Mutex + delays []time.Duration } func newTestAfter() *testAfter { @@ -174,13 +175,23 @@ func newTestAfter() *testAfter { return &testAfter{ready: ready} } -func (a *testAfter) wait(time.Duration) <-chan time.Time { - atomic.AddUint32(&a.calls, 1) +func (a *testAfter) wait(d time.Duration) <-chan time.Time { + a.mu.Lock() + a.delays = append(a.delays, d) + a.mu.Unlock() return a.ready } func (a *testAfter) count() int { - return int(atomic.LoadUint32(&a.calls)) + a.mu.Lock() + defer a.mu.Unlock() + return len(a.delays) +} + +func (a *testAfter) durations() []time.Duration { + a.mu.Lock() + defer a.mu.Unlock() + return append([]time.Duration(nil), a.delays...) } type testBucketSettings struct { @@ -821,8 +832,9 @@ func TestBackoff(t *testing.T) { defer cancel() table := []struct { - root *testRoot - want int + root *testRoot + want int + delays [][2]time.Duration }{ { root: &testRoot{ @@ -836,7 +848,8 @@ func TestBackoff(t *testing.T) { }, }, }, - want: 2, + want: 2, + delays: [][2]time.Duration{{time.Second, time.Second}, {2 * time.Second, 2 * time.Second}}, }, { root: &testRoot{ @@ -849,7 +862,8 @@ func TestBackoff(t *testing.T) { }, }, }, - want: 1, + want: 1, + delays: [][2]time.Duration{{1010 * time.Millisecond, 1030 * time.Millisecond}}, }, } @@ -874,7 +888,12 @@ func TestBackoff(t *testing.T) { t.Fatal(err) } if got := timer.count(); got != ent.want { - t.Errorf("got %d calls, wanted %d", got, ent.want) + t.Fatalf("got %d calls, wanted %d", got, ent.want) + } + for i, got := range timer.durations() { + if want := ent.delays[i]; got < want[0] || got > want[1] { + t.Errorf("wait %d = %v, want [%v, %v]", i+1, got, want[0], want[1]) + } } } } @@ -892,8 +911,14 @@ func TestBackoffWithoutRetryAfter(t *testing.T) { errs: &errCont{ errMap: map[string]map[int]error{ "createBucket": { - 0: testError{retry: true, maxRetries: 5}, - 1: testError{retry: true, maxRetries: 5}, + 0: testError{retry: true, maxRetries: 8}, + 1: testError{retry: true, maxRetries: 8}, + 2: testError{retry: true, maxRetries: 8}, + 3: testError{retry: true, maxRetries: 8}, + 4: testError{retry: true, maxRetries: 8}, + 5: testError{retry: true, maxRetries: 8}, + 6: testError{retry: true, maxRetries: 8}, + 7: testError{retry: true, maxRetries: 8}, }, }, }, @@ -906,8 +931,25 @@ func TestBackoffWithoutRetryAfter(t *testing.T) { if _, err := client.NewBucket(ctx, "fun", &BucketAttrs{Type: Private}); err != nil { t.Errorf("bucket should not err, got %v", err) } - if got := timer.count(); got != 2 { - t.Errorf("wrong number of calls; got %d, want 2", got) + if got := timer.count(); got != 8 { + t.Fatalf("wrong number of calls; got %d, want 8", got) + } + // Literal bounds allow jitter without measuring elapsed wall-clock time. + // The last two waits must return to the 30-second backoff cap, plus jitter. + want := [][2]time.Duration{ + {1010 * time.Millisecond, 1030 * time.Millisecond}, + {2040 * time.Millisecond, 2122 * time.Millisecond}, + {4 * time.Second, 4600 * time.Millisecond}, + {8 * time.Second, 9300 * time.Millisecond}, + {16 * time.Second, 20 * time.Second}, + {32 * time.Second, 40 * time.Second}, + {30 * time.Second, 32 * time.Second}, + {30 * time.Second, 32 * time.Second}, + } + for i, got := range timer.durations() { + if got < want[i][0] || got > want[i][1] { + t.Errorf("wait %d = %v, want [%v, %v]", i+1, got, want[i][0], want[i][1]) + } } } @@ -964,6 +1006,12 @@ func TestBackoffWithMaxRetriesReached(t *testing.T) { if got := timer.count(); got != ent.want { t.Fatalf("got %d calls, wanted %d", got, ent.want) } + want := [][2]time.Duration{{1010 * time.Millisecond, 1030 * time.Millisecond}, {2040 * time.Millisecond, 2122 * time.Millisecond}} + for i, got := range timer.durations() { + if got < want[i][0] || got > want[i][1] { + t.Errorf("wait %d = %v, want [%v, %v]", i+1, got, want[i][0], want[i][1]) + } + } } } @@ -1448,12 +1496,15 @@ func TestReaderDoubleClose(t *testing.T) { t.Fatal(err) } r := o.NewReader(ctx) - // Read to EOF, and then read some more. - if _, err := io.Copy(ioutil.Discard, r); err != nil { - t.Fatal(err) + t.Cleanup(func() { _ = r.Close() }) + if err := r.Close(); err != nil { + t.Fatalf("first Close: %v", err) } - if _, err := io.Copy(ioutil.Discard, r); err != nil { - t.Fatal(err) + if err := r.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if n, err := r.Read(make([]byte, 1)); n != 0 || err == nil { + t.Fatalf("Read after Close = %d, %v; want 0 and an error or EOF", n, err) } } @@ -1717,24 +1768,19 @@ func TestWriterLimitNewUploadAttempts(t *testing.T) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - concurrentUploads := 4 - retryCount := 5 - errorCount := concurrentUploads * (retryCount + 1) - - errorMap := make(map[int]error, errorCount) - for i := 0; i < errorCount; i++ { - errorMap[i] = testError{reupload: true} + // One worker makes the per-part attempt count independent of scheduling. + errorMap := make(map[int]error) + for i := 0; i < 7; i++ { + errorMap[i] = testError{reupload: true, maxReuploads: 5} } + errs := &errCont{errMap: map[string]map[int]error{"uploadPart": errorMap}} client := &Client{ backend: &beRoot{ b2i: &testRoot{ bucketMap: make(map[string]map[string]string), - errs: &errCont{ - errMap: map[string]map[int]error{ - "uploadPart": errorMap, - }, - }, + errs: errs, + afterFunc: newTestAfter().wait, }, }, } @@ -1746,10 +1792,18 @@ func TestWriterLimitNewUploadAttempts(t *testing.T) { w := bucket.Object("test").NewWriter(ctx) r := io.LimitReader(zReader{}, 1e7) w.ChunkSize = 1e4 - w.ConcurrentUploads = concurrentUploads + w.ConcurrentUploads = 1 if _, err := io.Copy(w, r); err == nil { t.Fatalf("io.Copy: should have returned an error") } + w.wg.Wait() + count, ok := errs.opMap.Load("uploadPart") + if !ok { + t.Fatal("uploadPart attempts = 0, want 6 (initial attempt plus five reuploads)") + } + if got := atomic.LoadUint32(count.(*uint32)); got != 6 { + t.Fatalf("uploadPart attempts = %d, want 6 (initial attempt plus five reuploads)", got) + } } func TestFileBuffer(t *testing.T) { From dcb1d63b8c4ae24902cc6481a606efb15bf9ef26 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 11:53:57 -0600 Subject: [PATCH 2/6] test(b2): replace fake-only bucket assertions with wire coverage Reuse the recording server for CORS, file lock, bucket IDs and default-encryption validation. Remove fake tests already covered by the replication and delete-bypass wire tests. Record isolated mutation proofs for the C1 checklist; stop the Writer error-identity item because current main returns context cancellation instead of the injected error. --- b2/b2_test.go | 192 ++++++++++++------------------ intent/b2-unit-assertions/plan.md | 68 +++++++++++ 2 files changed, 145 insertions(+), 115 deletions(-) create mode 100644 intent/b2-unit-assertions/plan.md diff --git a/b2/b2_test.go b/b2/b2_test.go index c6b24bb0..a4449718 100644 --- a/b2/b2_test.go +++ b/b2/b2_test.go @@ -29,6 +29,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/Backblaze/blazer/internal/b2wiretest" ) const ( @@ -49,11 +51,19 @@ func TestNewBucketCORSAndFileLock(t *testing.T) { ExposeHeaders: []string{"x-bz-file-name"}, MaxAgeSeconds: 60, }} - client := &Client{backend: &beRoot{b2i: &testRoot{ - bucketMap: make(map[string]map[string]string), - errs: &errCont{}, - }}} - bucket, err := client.NewBucket(ctx, bucketName, &BucketAttrs{ + const lockedJSON = `{"bucketId":"locked-id","bucketName":"locked","bucketType":"allPrivate","corsRules":[{"corsRuleName":"browser","allowedOrigins":["https://example.com"],"allowedHeaders":["authorization"],"allowedOperations":["b2_download_file_by_name"],"exposeHeaders":["x-bz-file-name"],"maxAgeSeconds":60}],"fileLockConfiguration":{"isClientAuthorizedToRead":true,"value":{"isFileLockEnabled":true}}}` + const plainJSON = `{"bucketId":"plain-id","bucketName":"plain","bucketType":"allPrivate","corsRules":[],"fileLockConfiguration":{"isClientAuthorizedToRead":true,"value":{"isFileLockEnabled":false}}}` + srv := b2wiretest.New(t) + srv.ReplySequence("b2_list_buckets", + b2wiretest.Response{Body: `{"buckets":[]}`}, + b2wiretest.Response{Body: `{"buckets":[]}`}, + b2wiretest.Response{Body: `{"buckets":[` + lockedJSON + `,` + plainJSON + `]}`}) + srv.ReplySequence("b2_create_bucket", b2wiretest.Response{Body: lockedJSON}, b2wiretest.Response{Body: plainJSON}) + client, err := NewClient(ctx, "account", "key", APIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + bucket, err := client.NewBucket(ctx, "locked", &BucketAttrs{ Type: Private, CORSRules: wantCORS, FileLockEnabled: true, @@ -61,6 +71,20 @@ func TestNewBucketCORSAndFileLock(t *testing.T) { if err != nil { t.Fatal(err) } + plain, err := client.NewBucket(ctx, "plain", &BucketAttrs{Type: Private}) + if err != nil { + t.Fatal(err) + } + creates := srv.Calls("b2_create_bucket") + if len(creates) != 2 { + t.Fatalf("create requests = %d, want 2", len(creates)) + } + b2wiretest.AssertJSON(t, creates[0], "corsRules", []map[string]any{{ + "corsRuleName": "browser", "allowedOrigins": []string{"https://example.com"}, + "allowedHeaders": []string{"authorization"}, "allowedOperations": []string{"b2_download_file_by_name"}, + "exposeHeaders": []string{"x-bz-file-name"}, "maxAgeSeconds": 60, + }}) + b2wiretest.AssertJSON(t, creates[0], "fileLockEnabled", true) attrs, err := bucket.Attrs(ctx) if err != nil { t.Fatal(err) @@ -71,47 +95,31 @@ func TestNewBucketCORSAndFileLock(t *testing.T) { if !attrs.FileLockEnabled { t.Error("Bucket.Attrs().FileLockEnabled = false, want true") } -} - -func TestNewBucketReplicationConfig(t *testing.T) { - ctx := context.Background() - want := &ReplicationConfiguration{ - AsReplicationSource: AsReplicationSource{ - SourceApplicationKeyID: "source-key", - ReplicationRules: []ReplicationRules{{ - ReplicationRuleName: "rule", - DestinationBucketID: "destination-bucket-id", - FileNamePrefix: "prefix/", - IncludeExistingFiles: true, - IsEnabled: true, - Priority: 1, - }}, - }, - AsReplicationDestination: &AsReplicationDestination{ - SourceToDestinationKeyMapping: map[string]string{"source-key": "destination-key"}, - }, - } - client := &Client{backend: &beRoot{b2i: &testRoot{ - bucketMap: make(map[string]map[string]string), - errs: &errCont{}, - }}} - bucket, err := client.NewBucket(ctx, bucketName, &BucketAttrs{Type: Private, ReplicationConfig: want}) - if err != nil { - t.Fatal(err) - } - got, err := bucket.Attrs(ctx) + attrs, err = plain.Attrs(ctx) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(got.ReplicationConfig, want) { - t.Errorf("Bucket.Attrs().ReplicationConfig = %#v, want %#v", got.ReplicationConfig, want) + if len(attrs.CORSRules) != 0 || attrs.FileLockEnabled { + t.Errorf("plain bucket attrs = %+v, want no CORS rules and no file lock", attrs) } } func TestBucketID(t *testing.T) { - b := &Bucket{b: &beBucket{b2bucket: &testBucket{idv: "bucket-id"}}} - if got := b.ID(); got != "bucket-id" { - t.Fatalf("Bucket.ID() = %q, want %q", got, "bucket-id") + bucket, srv := newMappingBucket(t, updateBucketJSON) + before := len(srv.Requests()) + if got := bucket.ID(); got != "bid" { + t.Fatalf("created Bucket.ID() = %q, want bid", got) + } + if got := len(srv.Requests()); got != before { + t.Fatalf("Bucket.ID() sent %d requests, want 0", got-before) + } + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[{"bucketId":"listed-id","bucketName":"n","bucketType":"allPrivate"}]}`) + listed, err := bucket.c.Bucket(context.Background(), "n") + if err != nil { + t.Fatal(err) + } + if got := listed.ID(); got != "listed-id" { + t.Fatalf("listed Bucket.ID() = %q, want listed-id", got) } } @@ -568,30 +576,6 @@ func (t *testFile) deleteFileVersion(_ context.Context, bypassGovernance bool) e return nil } -func TestObjectDeleteBypassGovernanceOption(t *testing.T) { - ctx := context.Background() - standard := &testFile{n: "standard", a: "upload", files: make(map[string]string)} - if err := objectForDeleteOptionTest(standard).Delete(ctx); err != nil { - t.Fatal(err) - } - if standard.bypassGovernance { - t.Fatal("Delete without BypassGovernance requested a bypass") - } - - bypass := &testFile{n: "bypass", a: "upload", files: make(map[string]string)} - if err := objectForDeleteOptionTest(bypass).Delete(ctx, BypassGovernance()); err != nil { - t.Fatal(err) - } - if !bypass.bypassGovernance { - t.Fatal("Delete with BypassGovernance did not request a bypass") - } -} - -func objectForDeleteOptionTest(file *testFile) *Object { - root := &beRoot{b2i: &testRoot{errs: &errCont{}, bucketMap: make(map[string]map[string]string)}} - return &Object{f: &beFile{b2file: file, ri: root}} -} - func TestObjectDeleteAllowsCopyStatus(t *testing.T) { file := &testFile{n: "copy", a: "copy", files: map[string]string{"copy": "present"}} root := &beRoot{b2i: &testRoot{errs: &errCont{}, bucketMap: make(map[string]map[string]string)}} @@ -1922,46 +1906,56 @@ func readFile(ctx context.Context, obj *Object, sha string, chunk, concur int) e func TestBucketDefaultEncryptionRejected(t *testing.T) { ctx := context.Background() - client := &Client{ - backend: &beRoot{ - b2i: &testRoot{ - bucketMap: make(map[string]map[string]string), - errs: &errCont{}, - }, - }, - } - bucket, err := client.NewBucket(ctx, "bkt", nil) - if err != nil { - t.Fatal(err) - } + bucket, srv := newMappingBucket(t, updateBucketJSON) + client := bucket.c table := []struct { - name string - sse *ServerSideEncryption - ok bool + name string + sse *ServerSideEncryption + ok bool + wantErr string }{ {name: "unset", sse: nil, ok: true}, {name: "sse-b2", sse: SSEB2WithAES256(), ok: true}, - {name: "none", sse: &ServerSideEncryption{Mode: "none"}}, - {name: "sse-c", sse: &ServerSideEncryption{Mode: "SSE-C", Algorithm: "AES256"}}, - {name: "empty", sse: &ServerSideEncryption{}}, - {name: "sse-b2 without algorithm", sse: &ServerSideEncryption{Mode: "SSE-B2"}}, + {name: "none", sse: &ServerSideEncryption{Mode: "none"}, wantErr: "none/ cannot be used as default for a bucket"}, + {name: "sse-c", sse: &ServerSideEncryption{Mode: "SSE-C", Algorithm: "AES256"}, wantErr: "SSE-C/AES256 cannot be used as default for a bucket"}, + {name: "empty", sse: &ServerSideEncryption{}, wantErr: "/ cannot be used as default for a bucket"}, + {name: "sse-b2 without algorithm", sse: &ServerSideEncryption{Mode: "SSE-B2"}, wantErr: "SSE-B2/ cannot be used as default for a bucket"}, } for _, ent := range table { t.Run(ent.name, func(t *testing.T) { + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) + creates := len(srv.Calls("b2_create_bucket")) + updates := len(srv.Calls("b2_update_bucket")) attrs := &BucketAttrs{DefaultServerSideEncryption: ent.sse} _, err := client.NewBucket(ctx, "new-"+ent.name, attrs) if (err == nil) != ent.ok { t.Errorf("NewBucket(%+v): err = %v, want ok=%v", ent.sse, err, ent.ok) } + if !ent.ok && (err == nil || err.Error() != ent.wantErr) { + t.Errorf("NewBucket error = %v, want %q", err, ent.wantErr) + } // Attrs are ignored when the bucket already exists. - if _, err := client.NewBucket(ctx, "bkt", attrs); err != nil { + srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[`+updateBucketJSON+`]}`) + if _, err := client.NewBucket(ctx, "n", attrs); err != nil { t.Errorf("NewBucket(existing, %+v): err = %v, want nil", ent.sse, err) } err = bucket.Update(ctx, attrs) if (err == nil) != ent.ok { t.Errorf("Update(%+v): err = %v, want ok=%v", ent.sse, err, ent.ok) } + if !ent.ok { + if err == nil || err.Error() != ent.wantErr { + t.Errorf("Update error = %v, want %q", err, ent.wantErr) + } + // NewBucket may list first, but invalid settings must never be sent. + if got := len(srv.Calls("b2_create_bucket")) - creates; got != 0 { + t.Errorf("invalid default sent %d create requests, want 0", got) + } + if got := len(srv.Calls("b2_update_bucket")) - updates; got != 0 { + t.Errorf("invalid default sent %d update requests, want 0", got) + } + } }) } } @@ -2046,35 +2040,3 @@ func TestCreateKeyDispatch(t *testing.T) { } }) } - -func TestBucketAttrsCORSAndFileLockPerBucket(t *testing.T) { - ctx := context.Background() - wantCORS := []CORSRule{{Name: "browser", AllowedOrigins: []string{"https://example.com"}}} - client := &Client{backend: &beRoot{b2i: &testRoot{ - bucketMap: make(map[string]map[string]string), - errs: &errCont{}, - }}} - locked, err := client.NewBucket(ctx, "locked-bucket", &BucketAttrs{Type: Private, CORSRules: wantCORS, FileLockEnabled: true}) - if err != nil { - t.Fatal(err) - } - plain, err := client.NewBucket(ctx, "plain-bucket", &BucketAttrs{Type: Private}) - if err != nil { - t.Fatal(err) - } - - got, err := locked.Attrs(ctx) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got.CORSRules, wantCORS) || !got.FileLockEnabled { - t.Errorf("locked bucket attrs = %+v, want its own CORS rules and file lock", got) - } - got, err = plain.Attrs(ctx) - if err != nil { - t.Fatal(err) - } - if len(got.CORSRules) != 0 || got.FileLockEnabled { - t.Errorf("plain bucket attrs = %+v, want no CORS rules and no file lock", got) - } -} diff --git a/intent/b2-unit-assertions/plan.md b/intent/b2-unit-assertions/plan.md new file mode 100644 index 00000000..235a6110 --- /dev/null +++ b/intent/b2-unit-assertions/plan.md @@ -0,0 +1,68 @@ +| Item | Status | Test / proof rows | +| --- | --- | --- | +| 1. Writer error identity | Stopped: current main returns context cancellation instead of the injected error; supplied assertion preserved, uncommitted; production fix out of scope | `TestWriterReturnsError`; 1 | +| 2. Upload attempt cap | Done: one worker, five reuploads, six attempts | `TestWriterLimitNewUploadAttempts`; 2a–2c | +| 3. Read after double Close | Done: close before EOF, then explicitly require zero bytes and an error | `TestReaderDoubleClose`; 3 | +| 4. Fake-echo bucket tests | Done: CORS/file-lock create and per-bucket reads use the recording server; redundant replication and per-bucket fake tests removed | `TestNewBucketCORSAndFileLock`; 4a–4d; existing `TestNewBucketReplicationConfigurationWireRoundTrip`; 4e | +| 5. Delete bypass fake | Done: redundant fake test and helper removed; wire twin unchanged (also avoids overlap with #240) | Already covered by `TestObjectDeleteBypassGovernanceReachesTheWire`; 5a–5b | +| 6. Bucket ID | Done: literal IDs from create and list responses; ID lookup makes no request | `TestBucketID`; 6 | +| 7. Invalid bucket encryption | Done: exact validation errors and no create/update request for rejected settings; existing-bucket and valid cases retained | `TestBucketDefaultEncryptionRejected`; 7a–7d | +| 8. Backoff durations | Done: existing wait hook captures durations; literal Retry-After, jitter bounds, and cap bounds | `TestBackoff`, `TestBackoffWithoutRetryAfter`, `TestBackoffWithMaxRetriesReached`; 8a–8i | + +| Proof environment | Value | +| --- | --- | +| Baseline | `9b89b88` (`origin/main`); no branch changes | +| Mutation isolation | Archive of HEAD plus the changed test file; each break applied alone, target test run, original file restored, target rerun; production files in the worktree never edited | +| Expectations | Literals only; no call to the implementation's backoff function to derive bounds | +| Live / simulator | NOT RUN: test-only assertions and fixtures; no SDK or harness production behavior changed | + +| Item | Break | Failing output | Passing after revert | +| --- | --- | --- | --- | +| 1 | No mutation: supplied stronger assertion on current main | `io.Copy error = context canceled, want injected permanent upload failure` | Stopped; no green available without a production fix | +| 2a | Reupload limit `+1` → `+2` | `uploadPart attempts = 7, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.764s` | +| 2b | Initial-attempt guard flipped | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.284s` | +| 2c | Initial-attempt guard deleted | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.287s` | +| 3 | Delete `Reader.Close` cancellation | `panic: test timed out after 15s`, `TestReaderDoubleClose`, blocked in `Reader.Close` / `WaitGroup.Wait` | `ok github.com/Backblaze/blazer/b2 0.270s` | +| 4a | Omit CORS in create mapping | `b2_create_bucket body corsRules: missing, want [{"allowedHeaders":["authorization"],"allowedOperations":["b2_download_file_by_name"],"allowedOrigins":["https://example.com"],"corsRuleName":"browser","exposeHeaders":["x-bz-file-name"],"maxAgeSeconds":60}]` | `ok github.com/Backblaze/blazer/b2 0.292s` | +| 4b | Omit file lock in create mapping | `b2_create_bucket body fileLockEnabled: missing, want true` | `ok github.com/Backblaze/blazer/b2 0.271s` | +| 4c | Bucket-name guard flipped | `Bucket.Attrs().FileLockEnabled = false, want true` | `ok github.com/Backblaze/blazer/b2 0.269s` | +| 4d | Bucket-name guard deleted (first bucket returned unconditionally) | `plain bucket attrs = … FileLockEnabled:true …, want no CORS rules and no file lock` | `ok github.com/Backblaze/blazer/b2 0.284s` | +| 4e | Omit replication in create mapping | `create omitted replicationConfiguration` | `ok github.com/Backblaze/blazer/b2 0.276s` | +| 5a | Bypass option assignment flipped to false | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.275s` | +| 5b | Bypass option assignment deleted | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.288s` | +| 6 | Read bucket name instead of response ID | `created Bucket.ID() = "n", want bid` | `ok github.com/Backblaze/blazer/b2 0.284s` | +| 7a | NewBucket encryption guard flipped | `invalid default sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.310s` | +| 7b | NewBucket encryption guard deleted | `invalid default sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.462s` | +| 7c | Update encryption guard flipped | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.307s` | +| 7d | Update encryption guard deleted | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.293s` | +| 8a | Force backend delay to 1ns; `TestBackoff` | `wait 1 = 1ns, want [1s, 1s]`; `wait 2 = 1ns, want [2s, 2s]`; `wait 1 = 1ns, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.323s` | +| 8b | Force backend delay to 1ns; `TestBackoffWithoutRetryAfter` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 7 = 1ns, want [30s, 32s]` | `ok github.com/Backblaze/blazer/b2 0.278s` | +| 8c | Force backend delay to 1ns; `TestBackoffWithMaxRetriesReached` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 2 = 1ns, want [2.04s, 2.122s]` | `ok github.com/Backblaze/blazer/b2 0.294s` | +| 8d | First-delay guard flipped | `wait 1 = 0s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.568s` | +| 8e | First-delay guard deleted | `wait 1 = 0s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.273s` | +| 8f | Retry-After guard flipped | `wait 1 = 1.011091425s, want [1s, 1s]` | `ok github.com/Backblaze/blazer/b2 0.450s` | +| 8g | Retry-After guard deleted | `wait 1 = 1.02398841s, want [1s, 1s]` | `ok github.com/Backblaze/blazer/b2 0.280s` | +| 8h | 30-second cap guard flipped | `wait 1 = 30.010863997s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.268s` | +| 8i | 30-second cap guard deleted | `wait 7 = 1m11.547314088s, want [30s, 32s]`; `wait 8 = 2m26.982487425s, want [30s, 32s]` | `ok github.com/Backblaze/blazer/b2 0.269s` | + +```sh +unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY +go test ./b2 -run '^TEST_NAME$' -count=1 -timeout=15s +go test ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff.*|TestBucketDefaultEncryptionRejected|TestNewBucketCORSAndFileLock|TestNewBucketReplicationConfigurationWireRoundTrip|TestObjectDeleteBypassGovernanceReachesTheWire|TestBucketID)$' -count=1 +go test -race -count=5 -shuffle=on ./b2/... +go test -race -count=30 ./b2 -run '^(TestWriterReturnsError|TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff|TestBackoffWithoutRetryAfter|TestBackoffWithMaxRetriesReached)$' +go test -race -count=30 ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff|TestBackoffWithoutRetryAfter|TestBackoffWithMaxRetriesReached)$' +go vet ./... +make ci +make b2keys crosscompile changelog-check scripts-test +``` + +| Check | Actual output / result | +| --- | --- | +| C1 targeted run, excluding stopped item 1 | `ok github.com/Backblaze/blazer/b2 0.678s` | +| Changed concurrency/timing tests, 30 race-enabled repetitions (item 1 excluded) | `ok github.com/Backblaze/blazer/b2 1.377s` | +| Full b2 race suite, count 5, shuffled (seed `1793382557891028000`) | FAIL: item 1 failed 5/5 with `io.Copy error = context canceled, want injected permanent upload failure`; `FAIL github.com/Backblaze/blazer/b2 65.633s`; `ok github.com/Backblaze/blazer/b2/sync 17.810s` | +| Concurrency/timing tests plus stopped item 1, count 30 | FAIL: item 1 failed 30/30 with the same error; `FAIL github.com/Backblaze/blazer/b2 0.440s` | +| `go vet ./...` | Exit 0, no output | +| `make ci` | Build, vet, format, tidy, verify pass (`all modules verified`); lint `0 issues.`; tests fail only item 1: `FAIL github.com/Backblaze/blazer/b2 14.391s`, `make: *** [test] Error 1`; other packages pass or have no tests | +| Remaining CI targets run separately | Exit 0: b2keys (`[no test files]`), Linux amd64/386/arm, Darwin arm64 and Windows amd64 cross-builds; changelog-check passes; five shell script cases report `ok` | From a8a1675b8b150da5b72a2612c53c42fe4aa46f8c Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:37:02 -0600 Subject: [PATCH 3/6] test(b2): remove dead bypass fake state --- b2/b2_test.go | 14 ++++++-------- intent/b2-unit-assertions/plan.md | 16 +++++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/b2/b2_test.go b/b2/b2_test.go index a4449718..0770e291 100644 --- a/b2/b2_test.go +++ b/b2/b2_test.go @@ -541,12 +541,11 @@ func (t *testFileChunk) uploadPart(_ context.Context, r io.Reader, _ string, _, } type testFile struct { - n string - s int64 - t time.Time - a string - files map[string]string - bypassGovernance bool + n string + s int64 + t time.Time + a string + files map[string]string } func (t *testFile) id() string { return t.n } @@ -568,10 +567,9 @@ func (t *testFile) listParts(context.Context, int, int) ([]b2FilePartInterface, return nil, 0, nil } -func (t *testFile) deleteFileVersion(_ context.Context, bypassGovernance bool) error { +func (t *testFile) deleteFileVersion(_ context.Context, _ bool) error { gmux.Lock() defer gmux.Unlock() - t.bypassGovernance = bypassGovernance delete(t.files, t.n) return nil } diff --git a/intent/b2-unit-assertions/plan.md b/intent/b2-unit-assertions/plan.md index 235a6110..d256ded1 100644 --- a/intent/b2-unit-assertions/plan.md +++ b/intent/b2-unit-assertions/plan.md @@ -1,6 +1,10 @@ +This file is slice C1's proof record, standing in for a spec and plan. It follows #204's checklist rather than R-numbered requirements. + +Item 1 moved to #245: the stronger assertion fails on main because of a product bug. + | Item | Status | Test / proof rows | | --- | --- | --- | -| 1. Writer error identity | Stopped: current main returns context cancellation instead of the injected error; supplied assertion preserved, uncommitted; production fix out of scope | `TestWriterReturnsError`; 1 | +| 1. Writer error identity | Moved to #245 | — | | 2. Upload attempt cap | Done: one worker, five reuploads, six attempts | `TestWriterLimitNewUploadAttempts`; 2a–2c | | 3. Read after double Close | Done: close before EOF, then explicitly require zero bytes and an error | `TestReaderDoubleClose`; 3 | | 4. Fake-echo bucket tests | Done: CORS/file-lock create and per-bucket reads use the recording server; redundant replication and per-bucket fake tests removed | `TestNewBucketCORSAndFileLock`; 4a–4d; existing `TestNewBucketReplicationConfigurationWireRoundTrip`; 4e | @@ -18,7 +22,6 @@ | Item | Break | Failing output | Passing after revert | | --- | --- | --- | --- | -| 1 | No mutation: supplied stronger assertion on current main | `io.Copy error = context canceled, want injected permanent upload failure` | Stopped; no green available without a production fix | | 2a | Reupload limit `+1` → `+2` | `uploadPart attempts = 7, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.764s` | | 2b | Initial-attempt guard flipped | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.284s` | | 2c | Initial-attempt guard deleted | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.287s` | @@ -50,7 +53,6 @@ unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY go test ./b2 -run '^TEST_NAME$' -count=1 -timeout=15s go test ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff.*|TestBucketDefaultEncryptionRejected|TestNewBucketCORSAndFileLock|TestNewBucketReplicationConfigurationWireRoundTrip|TestObjectDeleteBypassGovernanceReachesTheWire|TestBucketID)$' -count=1 go test -race -count=5 -shuffle=on ./b2/... -go test -race -count=30 ./b2 -run '^(TestWriterReturnsError|TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff|TestBackoffWithoutRetryAfter|TestBackoffWithMaxRetriesReached)$' go test -race -count=30 ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff|TestBackoffWithoutRetryAfter|TestBackoffWithMaxRetriesReached)$' go vet ./... make ci @@ -59,10 +61,10 @@ make b2keys crosscompile changelog-check scripts-test | Check | Actual output / result | | --- | --- | -| C1 targeted run, excluding stopped item 1 | `ok github.com/Backblaze/blazer/b2 0.678s` | +| C1 targeted run before item 1 moved to #245 | `ok github.com/Backblaze/blazer/b2 0.678s` | | Changed concurrency/timing tests, 30 race-enabled repetitions (item 1 excluded) | `ok github.com/Backblaze/blazer/b2 1.377s` | -| Full b2 race suite, count 5, shuffled (seed `1793382557891028000`) | FAIL: item 1 failed 5/5 with `io.Copy error = context canceled, want injected permanent upload failure`; `FAIL github.com/Backblaze/blazer/b2 65.633s`; `ok github.com/Backblaze/blazer/b2/sync 17.810s` | -| Concurrency/timing tests plus stopped item 1, count 30 | FAIL: item 1 failed 30/30 with the same error; `FAIL github.com/Backblaze/blazer/b2 0.440s` | +| Full b2 race suite, count 5, shuffled | PASS: `ok github.com/Backblaze/blazer/b2 63.318s`; `ok github.com/Backblaze/blazer/b2/sync 15.414s` | +| Concurrency/timing tests, count 30 | `ok github.com/Backblaze/blazer/b2 1.377s` | | `go vet ./...` | Exit 0, no output | -| `make ci` | Build, vet, format, tidy, verify pass (`all modules verified`); lint `0 issues.`; tests fail only item 1: `FAIL github.com/Backblaze/blazer/b2 14.391s`, `make: *** [test] Error 1`; other packages pass or have no tests | +| `make ci` at committed head | PASS: build, vet, format, tidy, verify (`all modules verified`), lint (`0 issues.`), race tests, b2keys, five cross-builds, changelog-check and scripts-test all passed; `b2` completed in `14.099s` | | Remaining CI targets run separately | Exit 0: b2keys (`[no test files]`), Linux amd64/386/arm, Darwin arm64 and Windows amd64 cross-builds; changelog-check passes; five shell script cases report `ok` | From 5a572f075145e61ed88940105e44f20ff95a33e3 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:11:54 -0600 Subject: [PATCH 4/6] test(b2): strengthen bucket, reader, and upload cap assertions --- b2/b2_test.go | 166 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 126 insertions(+), 40 deletions(-) diff --git a/b2/b2_test.go b/b2/b2_test.go index 0770e291..ea64314e 100644 --- a/b2/b2_test.go +++ b/b2/b2_test.go @@ -85,6 +85,10 @@ func TestNewBucketCORSAndFileLock(t *testing.T) { "exposeHeaders": []string{"x-bz-file-name"}, "maxAgeSeconds": 60, }}) b2wiretest.AssertJSON(t, creates[0], "fileLockEnabled", true) + // b2_create_bucket documents corsRules as optional and fileLockEnabled as + // false by default, so a plain bucket omits both fields. + b2wiretest.AssertNoJSON(t, creates[1], "corsRules") + b2wiretest.AssertNoJSON(t, creates[1], "fileLockEnabled") attrs, err := bucket.Attrs(ctx) if err != nil { t.Fatal(err) @@ -102,6 +106,14 @@ func TestNewBucketCORSAndFileLock(t *testing.T) { if len(attrs.CORSRules) != 0 || attrs.FileLockEnabled { t.Errorf("plain bucket attrs = %+v, want no CORS rules and no file lock", attrs) } + lists := srv.Calls("b2_list_buckets") + if len(lists) != 4 { + t.Fatalf("list requests = %d, want 4", len(lists)) + } + for i, name := range []string{"locked", "plain"} { + b2wiretest.AssertJSON(t, lists[i+2], "bucketName", name) + b2wiretest.AssertNoJSON(t, lists[i+2], "bucketId") + } } func TestBucketID(t *testing.T) { @@ -139,6 +151,7 @@ func (t testError) Error() string { type errCont struct { errMap map[string]map[int]error opMap sync.Map + onCall func(name string, attempt uint32) } func (e *errCont) getError(name string) error { @@ -147,6 +160,9 @@ func (e *errCont) getError(name string) error { } val, _ := e.opMap.LoadOrStore(name, new(uint32)) newVal := atomic.AddUint32(val.(*uint32), 1) + if e.onCall != nil { + e.onCall(name, newVal) + } return e.errMap[name][int(newVal-1)] } @@ -1746,45 +1762,95 @@ func TestWriterReturnsError(t *testing.T) { } func TestWriterLimitNewUploadAttempts(t *testing.T) { - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // One worker makes the per-part attempt count independent of scheduling. - errorMap := make(map[int]error) - for i := 0; i < 7; i++ { - errorMap[i] = testError{reupload: true, maxReuploads: 5} - } - errs := &errCont{errMap: map[string]map[int]error{"uploadPart": errorMap}} - - client := &Client{ - backend: &beRoot{ - b2i: &testRoot{ + for _, uploads := range []int{1, 2} { + t.Run(fmt.Sprintf("%d workers", uploads), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + const attempts = 6 // one initial attempt plus five reuploads + errorMap := make(map[string]map[int]error) + for part := 1; part <= uploads; part++ { + retries := make(map[int]error) + for attempt := 0; attempt < attempts+1; attempt++ { + retries[attempt] = testError{reupload: true, maxReuploads: attempts - 1} + } + errorMap[fmt.Sprintf("uploadPart:%d", part)] = retries + } + errs := &errCont{errMap: errorMap} + started := make(chan string, uploads) + release := make(chan struct{}) + if uploads > 1 { + errs.onCall = func(name string, attempt uint32) { + if attempt == 1 && strings.HasPrefix(name, "uploadPart:") { + started <- name + <-release + } + } + } + client := &Client{backend: &beRoot{b2i: &testRoot{ bucketMap: make(map[string]map[string]string), errs: errs, afterFunc: newTestAfter().wait, - }, - }, - } - - bucket, err := client.NewBucket(ctx, bucketName, &BucketAttrs{Type: Private}) - if err != nil { - t.Fatal(err) - } - w := bucket.Object("test").NewWriter(ctx) - r := io.LimitReader(zReader{}, 1e7) - w.ChunkSize = 1e4 - w.ConcurrentUploads = 1 - if _, err := io.Copy(w, r); err == nil { - t.Fatalf("io.Copy: should have returned an error") - } - w.wg.Wait() - count, ok := errs.opMap.Load("uploadPart") - if !ok { - t.Fatal("uploadPart attempts = 0, want 6 (initial attempt plus five reuploads)") - } - if got := atomic.LoadUint32(count.(*uint32)); got != 6 { - t.Fatalf("uploadPart attempts = %d, want 6 (initial attempt plus five reuploads)", got) + }}} + bucket, err := client.NewBucket(ctx, bucketName, &BucketAttrs{Type: Private}) + if err != nil { + t.Fatal(err) + } + w := bucket.Object("test").NewWriter(ctx) + w.ChunkSize = 1e4 + w.ConcurrentUploads = uploads + copyDone := make(chan error, 1) + go func() { + _, err := io.Copy(w, io.LimitReader(zReader{}, 1e7)) + copyDone <- err + }() + if uploads > 1 { + parts := map[string]bool{} + for range uploads { + select { + case part := <-started: + parts[part] = true + case <-ctx.Done(): + t.Fatal("concurrent uploads did not both start:", ctx.Err()) + } + } + if got := len(parts); got != uploads { + t.Fatalf("concurrent upload parts = %d, want %d", got, uploads) + } + close(release) + } + select { + case err := <-copyDone: + if err == nil { + t.Fatal("io.Copy: should have returned an error") + } + case <-ctx.Done(): + t.Fatal("io.Copy did not return:", ctx.Err()) + } + w.wg.Wait() + seen := 0 + reachedCap := false + errs.opMap.Range(func(key, value any) bool { + name := key.(string) + if !strings.HasPrefix(name, "uploadPart:") { + return true + } + got := atomic.LoadUint32(value.(*uint32)) + if got > attempts { + t.Errorf("%s attempts = %d, want at most %d", name, got, attempts) + } + if got == attempts { + reachedCap = true + } + seen++ + return true + }) + if seen < uploads { + t.Fatalf("upload parts with attempts = %d, want at least %d", seen, uploads) + } + if !reachedCap { + t.Fatalf("no part reached %d attempts", attempts) + } + }) } } @@ -1923,21 +1989,45 @@ func TestBucketDefaultEncryptionRejected(t *testing.T) { for _, ent := range table { t.Run(ent.name, func(t *testing.T) { srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[]}`) + lists := len(srv.Calls("b2_list_buckets")) creates := len(srv.Calls("b2_create_bucket")) updates := len(srv.Calls("b2_update_bucket")) attrs := &BucketAttrs{DefaultServerSideEncryption: ent.sse} _, err := client.NewBucket(ctx, "new-"+ent.name, attrs) + if got := len(srv.Calls("b2_list_buckets")) - lists; got != 1 { + t.Errorf("NewBucket listed %d times, want 1", got) + } if (err == nil) != ent.ok { t.Errorf("NewBucket(%+v): err = %v, want ok=%v", ent.sse, err, ent.ok) } if !ent.ok && (err == nil || err.Error() != ent.wantErr) { t.Errorf("NewBucket error = %v, want %q", err, ent.wantErr) } + if !ent.ok { + if got := len(srv.Calls("b2_create_bucket")) - creates; got != 0 { + t.Errorf("invalid default sent %d create requests, want 0", got) + } + if got := len(srv.Calls("b2_update_bucket")) - updates; got != 0 { + t.Errorf("invalid default sent %d update requests, want 0", got) + } + } // Attrs are ignored when the bucket already exists. srv.Reply("b2_list_buckets", http.StatusOK, `{"buckets":[`+updateBucketJSON+`]}`) + lists = len(srv.Calls("b2_list_buckets")) + creates = len(srv.Calls("b2_create_bucket")) + updates = len(srv.Calls("b2_update_bucket")) if _, err := client.NewBucket(ctx, "n", attrs); err != nil { t.Errorf("NewBucket(existing, %+v): err = %v, want nil", ent.sse, err) } + if got := len(srv.Calls("b2_list_buckets")) - lists; got != 1 { + t.Errorf("NewBucket(existing) listed %d times, want 1", got) + } + if got := len(srv.Calls("b2_create_bucket")) - creates; got != 0 { + t.Errorf("NewBucket(existing) sent %d create requests, want 0", got) + } + if got := len(srv.Calls("b2_update_bucket")) - updates; got != 0 { + t.Errorf("NewBucket(existing) sent %d update requests, want 0", got) + } err = bucket.Update(ctx, attrs) if (err == nil) != ent.ok { t.Errorf("Update(%+v): err = %v, want ok=%v", ent.sse, err, ent.ok) @@ -1946,10 +2036,6 @@ func TestBucketDefaultEncryptionRejected(t *testing.T) { if err == nil || err.Error() != ent.wantErr { t.Errorf("Update error = %v, want %q", err, ent.wantErr) } - // NewBucket may list first, but invalid settings must never be sent. - if got := len(srv.Calls("b2_create_bucket")) - creates; got != 0 { - t.Errorf("invalid default sent %d create requests, want 0", got) - } if got := len(srv.Calls("b2_update_bucket")) - updates; got != 0 { t.Errorf("invalid default sent %d update requests, want 0", got) } From 2cc0c37f0796cca4bd07a653dc7d3e429964cab4 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:13:28 -0600 Subject: [PATCH 5/6] docs(intent): record C1 assertion proofs --- intent/b2-unit-assertions/plan.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/intent/b2-unit-assertions/plan.md b/intent/b2-unit-assertions/plan.md index d256ded1..39f7e8c8 100644 --- a/intent/b2-unit-assertions/plan.md +++ b/intent/b2-unit-assertions/plan.md @@ -5,18 +5,18 @@ Item 1 moved to #245: the stronger assertion fails on main because of a product | Item | Status | Test / proof rows | | --- | --- | --- | | 1. Writer error identity | Moved to #245 | — | -| 2. Upload attempt cap | Done: one worker, five reuploads, six attempts | `TestWriterLimitNewUploadAttempts`; 2a–2c | -| 3. Read after double Close | Done: close before EOF, then explicitly require zero bytes and an error | `TestReaderDoubleClose`; 3 | -| 4. Fake-echo bucket tests | Done: CORS/file-lock create and per-bucket reads use the recording server; redundant replication and per-bucket fake tests removed | `TestNewBucketCORSAndFileLock`; 4a–4d; existing `TestNewBucketReplicationConfigurationWireRoundTrip`; 4e | +| 2. Upload attempt cap | Done: single-worker exact cap plus a deterministic two-worker case; every observed part has at most six attempts and two parts start concurrently | `TestWriterLimitNewUploadAttempts`; 2a–2d | +| 3. Read after double Close | Done: close before EOF, then explicitly require zero bytes and an error; both `(0, nil)` and `(1, nil)` breaks fail | `TestReaderDoubleClose`; 3–3b | +| 4. Fake-echo bucket tests | Done: CORS/file-lock create and per-bucket reads use the recording server; the plain create omits CORS and file lock, and Attrs requests name-filtered listings | `TestNewBucketCORSAndFileLock`; 4a–4h; existing `TestNewBucketReplicationConfigurationWireRoundTrip`; 4e | | 5. Delete bypass fake | Done: redundant fake test and helper removed; wire twin unchanged (also avoids overlap with #240) | Already covered by `TestObjectDeleteBypassGovernanceReachesTheWire`; 5a–5b | | 6. Bucket ID | Done: literal IDs from create and list responses; ID lookup makes no request | `TestBucketID`; 6 | -| 7. Invalid bucket encryption | Done: exact validation errors and no create/update request for rejected settings; existing-bucket and valid cases retained | `TestBucketDefaultEncryptionRejected`; 7a–7d | +| 7. Invalid bucket encryption | Done: exact validation errors; rejected new buckets make one list and zero create/update requests; existing buckets make one list and zero create/update requests | `TestBucketDefaultEncryptionRejected`; 7a–7f | | 8. Backoff durations | Done: existing wait hook captures durations; literal Retry-After, jitter bounds, and cap bounds | `TestBackoff`, `TestBackoffWithoutRetryAfter`, `TestBackoffWithMaxRetriesReached`; 8a–8i | | Proof environment | Value | | --- | --- | -| Baseline | `9b89b88` (`origin/main`); no branch changes | -| Mutation isolation | Archive of HEAD plus the changed test file; each break applied alone, target test run, original file restored, target rerun; production files in the worktree never edited | +| Baseline | `c16317a` (`origin/main`); merged before these changes | +| Mutation isolation | Each break was applied alone, its target test run, and the original file restored before the next break; temporary production mutations were never committed | | Expectations | Literals only; no call to the implementation's backoff function to derive bounds | | Live / simulator | NOT RUN: test-only assertions and fixtures; no SDK or harness production behavior changed | @@ -25,12 +25,18 @@ Item 1 moved to #245: the stronger assertion fails on main because of a product | 2a | Reupload limit `+1` → `+2` | `uploadPart attempts = 7, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.764s` | | 2b | Initial-attempt guard flipped | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.284s` | | 2c | Initial-attempt guard deleted | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.287s` | +| 2d | `b2/writer.go:952` reupload cap `+1` → `+2`, two workers | `uploadPart:1 attempts = 7, want at most 6`; `uploadPart:2 attempts = 7, want at most 6` | `ok github.com/Backblaze/blazer/b2 1.795s` | | 3 | Delete `Reader.Close` cancellation | `panic: test timed out after 15s`, `TestReaderDoubleClose`, blocked in `Reader.Close` / `WaitGroup.Wait` | `ok github.com/Backblaze/blazer/b2 0.270s` | +| 3a | `Reader.Read` returns `(0, nil)` after Close | `Read after Close = 0, ; want 0 and an error or EOF` | `ok github.com/Backblaze/blazer/b2 1.376s` | +| 3b | `Reader.Read` returns `(1, nil)` after Close | `Read after Close = 1, ; want 0 and an error or EOF` | `ok github.com/Backblaze/blazer/b2 1.376s` | | 4a | Omit CORS in create mapping | `b2_create_bucket body corsRules: missing, want [{"allowedHeaders":["authorization"],"allowedOperations":["b2_download_file_by_name"],"allowedOrigins":["https://example.com"],"corsRuleName":"browser","exposeHeaders":["x-bz-file-name"],"maxAgeSeconds":60}]` | `ok github.com/Backblaze/blazer/b2 0.292s` | | 4b | Omit file lock in create mapping | `b2_create_bucket body fileLockEnabled: missing, want true` | `ok github.com/Backblaze/blazer/b2 0.271s` | -| 4c | Bucket-name guard flipped | `Bucket.Attrs().FileLockEnabled = false, want true` | `ok github.com/Backblaze/blazer/b2 0.269s` | -| 4d | Bucket-name guard deleted (first bucket returned unconditionally) | `plain bucket attrs = … FileLockEnabled:true …, want no CORS rules and no file lock` | `ok github.com/Backblaze/blazer/b2 0.284s` | +| 4c | `b2/b2.go:446`, `Client.Bucket` name-match condition `if bucket.name() == name` flipped | `Bucket.Attrs().FileLockEnabled = false, want true` | `ok github.com/Backblaze/blazer/b2 0.269s` | +| 4d | `b2/b2.go:446`, same `Client.Bucket` name-match condition removed so the first listed bucket returns unconditionally | `plain bucket attrs = … FileLockEnabled:true …, want no CORS rules and no file lock` | `ok github.com/Backblaze/blazer/b2 0.284s` | | 4e | Omit replication in create mapping | `create omitted replicationConfiguration` | `ok github.com/Backblaze/blazer/b2 0.276s` | +| 4f | `base/base.go:829` sends a CORS rule for the plain create | `b2_create_bucket body corsRules: got [{"corsRuleName":"unexpected"}], want missing` | `ok github.com/Backblaze/blazer/b2 1.376s` | +| 4g | `base/base.go:830` forces `fileLockEnabled: true` for the plain create | `b2_create_bucket body fileLockEnabled: got true, want missing` | `ok github.com/Backblaze/blazer/b2 1.376s` | +| 4h | `b2/baseline.go:278` drops the name passed to `ListBuckets` | `b2_list_buckets body bucketName: missing, want "locked"`; `… want "plain"` | `ok github.com/Backblaze/blazer/b2 1.376s` | | 5a | Bypass option assignment flipped to false | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.275s` | | 5b | Bypass option assignment deleted | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.288s` | | 6 | Read bucket name instead of response ID | `created Bucket.ID() = "n", want bid` | `ok github.com/Backblaze/blazer/b2 0.284s` | @@ -38,6 +44,8 @@ Item 1 moved to #245: the stronger assertion fails on main because of a product | 7b | NewBucket encryption guard deleted | `invalid default sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.462s` | | 7c | Update encryption guard flipped | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.307s` | | 7d | Update encryption guard deleted | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.293s` | +| 7e | Move NewBucket encryption validation before `b2/b2.go:465` list | `NewBucket listed 0 times, want 1` | `ok github.com/Backblaze/blazer/b2 1.376s` | +| 7f | Flip `b2/b2.go:470` existing-bucket name match | `NewBucket(existing) sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 1.376s` | | 8a | Force backend delay to 1ns; `TestBackoff` | `wait 1 = 1ns, want [1s, 1s]`; `wait 2 = 1ns, want [2s, 2s]`; `wait 1 = 1ns, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.323s` | | 8b | Force backend delay to 1ns; `TestBackoffWithoutRetryAfter` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 7 = 1ns, want [30s, 32s]` | `ok github.com/Backblaze/blazer/b2 0.278s` | | 8c | Force backend delay to 1ns; `TestBackoffWithMaxRetriesReached` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 2 = 1ns, want [2.04s, 2.122s]` | `ok github.com/Backblaze/blazer/b2 0.294s` | From 0c3e73945bf7334b83494486e038264e8ab1d780 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:18:26 -0600 Subject: [PATCH 6/6] chore: move the C1 proof table to the PR description Part of #204 --- intent/b2-unit-assertions/plan.md | 78 ------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 intent/b2-unit-assertions/plan.md diff --git a/intent/b2-unit-assertions/plan.md b/intent/b2-unit-assertions/plan.md deleted file mode 100644 index 39f7e8c8..00000000 --- a/intent/b2-unit-assertions/plan.md +++ /dev/null @@ -1,78 +0,0 @@ -This file is slice C1's proof record, standing in for a spec and plan. It follows #204's checklist rather than R-numbered requirements. - -Item 1 moved to #245: the stronger assertion fails on main because of a product bug. - -| Item | Status | Test / proof rows | -| --- | --- | --- | -| 1. Writer error identity | Moved to #245 | — | -| 2. Upload attempt cap | Done: single-worker exact cap plus a deterministic two-worker case; every observed part has at most six attempts and two parts start concurrently | `TestWriterLimitNewUploadAttempts`; 2a–2d | -| 3. Read after double Close | Done: close before EOF, then explicitly require zero bytes and an error; both `(0, nil)` and `(1, nil)` breaks fail | `TestReaderDoubleClose`; 3–3b | -| 4. Fake-echo bucket tests | Done: CORS/file-lock create and per-bucket reads use the recording server; the plain create omits CORS and file lock, and Attrs requests name-filtered listings | `TestNewBucketCORSAndFileLock`; 4a–4h; existing `TestNewBucketReplicationConfigurationWireRoundTrip`; 4e | -| 5. Delete bypass fake | Done: redundant fake test and helper removed; wire twin unchanged (also avoids overlap with #240) | Already covered by `TestObjectDeleteBypassGovernanceReachesTheWire`; 5a–5b | -| 6. Bucket ID | Done: literal IDs from create and list responses; ID lookup makes no request | `TestBucketID`; 6 | -| 7. Invalid bucket encryption | Done: exact validation errors; rejected new buckets make one list and zero create/update requests; existing buckets make one list and zero create/update requests | `TestBucketDefaultEncryptionRejected`; 7a–7f | -| 8. Backoff durations | Done: existing wait hook captures durations; literal Retry-After, jitter bounds, and cap bounds | `TestBackoff`, `TestBackoffWithoutRetryAfter`, `TestBackoffWithMaxRetriesReached`; 8a–8i | - -| Proof environment | Value | -| --- | --- | -| Baseline | `c16317a` (`origin/main`); merged before these changes | -| Mutation isolation | Each break was applied alone, its target test run, and the original file restored before the next break; temporary production mutations were never committed | -| Expectations | Literals only; no call to the implementation's backoff function to derive bounds | -| Live / simulator | NOT RUN: test-only assertions and fixtures; no SDK or harness production behavior changed | - -| Item | Break | Failing output | Passing after revert | -| --- | --- | --- | --- | -| 2a | Reupload limit `+1` → `+2` | `uploadPart attempts = 7, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.764s` | -| 2b | Initial-attempt guard flipped | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.284s` | -| 2c | Initial-attempt guard deleted | `uploadPart attempts = 1, want 6 (initial attempt plus five reuploads)` | `ok github.com/Backblaze/blazer/b2 0.287s` | -| 2d | `b2/writer.go:952` reupload cap `+1` → `+2`, two workers | `uploadPart:1 attempts = 7, want at most 6`; `uploadPart:2 attempts = 7, want at most 6` | `ok github.com/Backblaze/blazer/b2 1.795s` | -| 3 | Delete `Reader.Close` cancellation | `panic: test timed out after 15s`, `TestReaderDoubleClose`, blocked in `Reader.Close` / `WaitGroup.Wait` | `ok github.com/Backblaze/blazer/b2 0.270s` | -| 3a | `Reader.Read` returns `(0, nil)` after Close | `Read after Close = 0, ; want 0 and an error or EOF` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 3b | `Reader.Read` returns `(1, nil)` after Close | `Read after Close = 1, ; want 0 and an error or EOF` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 4a | Omit CORS in create mapping | `b2_create_bucket body corsRules: missing, want [{"allowedHeaders":["authorization"],"allowedOperations":["b2_download_file_by_name"],"allowedOrigins":["https://example.com"],"corsRuleName":"browser","exposeHeaders":["x-bz-file-name"],"maxAgeSeconds":60}]` | `ok github.com/Backblaze/blazer/b2 0.292s` | -| 4b | Omit file lock in create mapping | `b2_create_bucket body fileLockEnabled: missing, want true` | `ok github.com/Backblaze/blazer/b2 0.271s` | -| 4c | `b2/b2.go:446`, `Client.Bucket` name-match condition `if bucket.name() == name` flipped | `Bucket.Attrs().FileLockEnabled = false, want true` | `ok github.com/Backblaze/blazer/b2 0.269s` | -| 4d | `b2/b2.go:446`, same `Client.Bucket` name-match condition removed so the first listed bucket returns unconditionally | `plain bucket attrs = … FileLockEnabled:true …, want no CORS rules and no file lock` | `ok github.com/Backblaze/blazer/b2 0.284s` | -| 4e | Omit replication in create mapping | `create omitted replicationConfiguration` | `ok github.com/Backblaze/blazer/b2 0.276s` | -| 4f | `base/base.go:829` sends a CORS rule for the plain create | `b2_create_bucket body corsRules: got [{"corsRuleName":"unexpected"}], want missing` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 4g | `base/base.go:830` forces `fileLockEnabled: true` for the plain create | `b2_create_bucket body fileLockEnabled: got true, want missing` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 4h | `b2/baseline.go:278` drops the name passed to `ListBuckets` | `b2_list_buckets body bucketName: missing, want "locked"`; `… want "plain"` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 5a | Bypass option assignment flipped to false | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.275s` | -| 5b | Bypass option assignment deleted | `Delete(BypassGovernance) sent bypassGovernance = , want true` | `ok github.com/Backblaze/blazer/b2 0.288s` | -| 6 | Read bucket name instead of response ID | `created Bucket.ID() = "n", want bid` | `ok github.com/Backblaze/blazer/b2 0.284s` | -| 7a | NewBucket encryption guard flipped | `invalid default sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.310s` | -| 7b | NewBucket encryption guard deleted | `invalid default sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.462s` | -| 7c | Update encryption guard flipped | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.307s` | -| 7d | Update encryption guard deleted | `invalid default sent 1 update requests, want 0` | `ok github.com/Backblaze/blazer/b2 0.293s` | -| 7e | Move NewBucket encryption validation before `b2/b2.go:465` list | `NewBucket listed 0 times, want 1` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 7f | Flip `b2/b2.go:470` existing-bucket name match | `NewBucket(existing) sent 1 create requests, want 0` | `ok github.com/Backblaze/blazer/b2 1.376s` | -| 8a | Force backend delay to 1ns; `TestBackoff` | `wait 1 = 1ns, want [1s, 1s]`; `wait 2 = 1ns, want [2s, 2s]`; `wait 1 = 1ns, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.323s` | -| 8b | Force backend delay to 1ns; `TestBackoffWithoutRetryAfter` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 7 = 1ns, want [30s, 32s]` | `ok github.com/Backblaze/blazer/b2 0.278s` | -| 8c | Force backend delay to 1ns; `TestBackoffWithMaxRetriesReached` | `wait 1 = 1ns, want [1.01s, 1.03s]`; `wait 2 = 1ns, want [2.04s, 2.122s]` | `ok github.com/Backblaze/blazer/b2 0.294s` | -| 8d | First-delay guard flipped | `wait 1 = 0s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.568s` | -| 8e | First-delay guard deleted | `wait 1 = 0s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.273s` | -| 8f | Retry-After guard flipped | `wait 1 = 1.011091425s, want [1s, 1s]` | `ok github.com/Backblaze/blazer/b2 0.450s` | -| 8g | Retry-After guard deleted | `wait 1 = 1.02398841s, want [1s, 1s]` | `ok github.com/Backblaze/blazer/b2 0.280s` | -| 8h | 30-second cap guard flipped | `wait 1 = 30.010863997s, want [1.01s, 1.03s]` | `ok github.com/Backblaze/blazer/b2 0.268s` | -| 8i | 30-second cap guard deleted | `wait 7 = 1m11.547314088s, want [30s, 32s]`; `wait 8 = 2m26.982487425s, want [30s, 32s]` | `ok github.com/Backblaze/blazer/b2 0.269s` | - -```sh -unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY -go test ./b2 -run '^TEST_NAME$' -count=1 -timeout=15s -go test ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff.*|TestBucketDefaultEncryptionRejected|TestNewBucketCORSAndFileLock|TestNewBucketReplicationConfigurationWireRoundTrip|TestObjectDeleteBypassGovernanceReachesTheWire|TestBucketID)$' -count=1 -go test -race -count=5 -shuffle=on ./b2/... -go test -race -count=30 ./b2 -run '^(TestWriterLimitNewUploadAttempts|TestReaderDoubleClose|TestBackoff|TestBackoffWithoutRetryAfter|TestBackoffWithMaxRetriesReached)$' -go vet ./... -make ci -make b2keys crosscompile changelog-check scripts-test -``` - -| Check | Actual output / result | -| --- | --- | -| C1 targeted run before item 1 moved to #245 | `ok github.com/Backblaze/blazer/b2 0.678s` | -| Changed concurrency/timing tests, 30 race-enabled repetitions (item 1 excluded) | `ok github.com/Backblaze/blazer/b2 1.377s` | -| Full b2 race suite, count 5, shuffled | PASS: `ok github.com/Backblaze/blazer/b2 63.318s`; `ok github.com/Backblaze/blazer/b2/sync 15.414s` | -| Concurrency/timing tests, count 30 | `ok github.com/Backblaze/blazer/b2 1.377s` | -| `go vet ./...` | Exit 0, no output | -| `make ci` at committed head | PASS: build, vet, format, tidy, verify (`all modules verified`), lint (`0 issues.`), race tests, b2keys, five cross-builds, changelog-check and scripts-test all passed; `b2` completed in `14.099s` | -| Remaining CI targets run separately | Exit 0: b2keys (`[no test files]`), Linux amd64/386/arm, Darwin arm64 and Windows amd64 cross-builds; changelog-check passes; five shell script cases report `ok` |