From 92574f278d29e78b7bff378bc36136c662d5917c Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 13:03:16 -0600 Subject: [PATCH 1/5] test(base): cover key expiration timestamps --- b2/key_expiration_wire_test.go | 70 ++++++++++++++++++++++++++++++++++ base/key_expiration_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 b2/key_expiration_wire_test.go create mode 100644 base/key_expiration_test.go diff --git a/b2/key_expiration_wire_test.go b/b2/key_expiration_wire_test.go new file mode 100644 index 0000000..9b966cb --- /dev/null +++ b/b2/key_expiration_wire_test.go @@ -0,0 +1,70 @@ +package b2 + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +var wireKeyExpirationTime = time.Date(2023, time.November, 14, 22, 13, 20, 123000000, time.UTC) + +func newKeyExpirationWire(t *testing.T) (*b2wiretest.Server, *Client) { + t.Helper() + srv := b2wiretest.New(t) + client, err := NewClient(context.Background(), "account", "key", APIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + return srv, client +} + +func TestKeyExpirationReachesPublicAPI(t *testing.T) { + const noExpiration = `{"accountId":"account","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const expiration = `{"accountId":"account","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` + + t.Run("create without expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, noExpiration) + key, err := client.CreateKey(context.Background(), "no-expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().IsZero() { + t.Errorf("CreateKey Expires = %v, want zero time", key.Expires()) + } + }) + + t.Run("create with expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, expiration) + key, err := client.CreateKey(context.Background(), "expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().Equal(wireKeyExpirationTime) { + t.Errorf("CreateKey Expires = %v, want %v", key.Expires(), wireKeyExpirationTime) + } + }) + + t.Run("list keys", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_list_keys", http.StatusOK, `{"keys":[`+noExpiration+`,`+expiration+`],"nextApplicationKeyId":null}`) + keys, _, err := client.ListKeys(context.Background(), 10, "") + if err != nil && err != io.EOF { + t.Fatalf("ListKeys: %v", err) + } + if len(keys) != 2 { + t.Fatalf("ListKeys returned %d keys, want 2", len(keys)) + } + if !keys[0].Expires().IsZero() { + t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires()) + } + if !keys[1].Expires().Equal(wireKeyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[1].Expires(), wireKeyExpirationTime) + } + }) +} diff --git a/base/key_expiration_test.go b/base/key_expiration_test.go new file mode 100644 index 0000000..7dbc842 --- /dev/null +++ b/base/key_expiration_test.go @@ -0,0 +1,54 @@ +package base + +import ( + "context" + "testing" + "time" +) + +var keyExpirationTime = time.Date(2023, time.November, 14, 22, 13, 20, 123000000, time.UTC) + +func TestKeyExpiration(t *testing.T) { + const noExpirationCreate = `{"accountId":"account-id","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const expirationCreate = `{"accountId":"account-id","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` + const listResponse = `{"keys":[` + noExpirationCreate + `,` + expirationCreate + `],"nextApplicationKeyId":null}` + + t.Run("create without expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, noExpirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "no-expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.IsZero() { + t.Errorf("CreateKey Expires = %v, want zero time", key.Expires) + } + }) + + t.Run("create with expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, expirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.Equal(keyExpirationTime) { + t.Errorf("CreateKey Expires = %v, want %v", key.Expires, keyExpirationTime) + } + }) + + t.Run("list keys", func(t *testing.T) { + b := newKeyRestrictionsB2(t, `{}`, listResponse) + keys, _, err := b.ListKeys(context.Background(), 10, "") + if err != nil { + t.Fatalf("ListKeys: %v", err) + } + if len(keys) != 2 { + t.Fatalf("ListKeys returned %d keys, want 2", len(keys)) + } + if !keys[0].Expires.IsZero() { + t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires) + } + if !keys[1].Expires.Equal(keyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[1].Expires, keyExpirationTime) + } + }) +} From af23efea7e75965ce8c291868314289206efd423 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 13:08:18 -0600 Subject: [PATCH 2/5] fix(base): preserve zero key expiration timestamps --- .changes/unreleased/Fixed-20261030-130622.yaml | 3 +++ b2/key.go | 3 ++- base/base.go | 11 +++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changes/unreleased/Fixed-20261030-130622.yaml diff --git a/.changes/unreleased/Fixed-20261030-130622.yaml b/.changes/unreleased/Fixed-20261030-130622.yaml new file mode 100644 index 0000000..39dba87 --- /dev/null +++ b/.changes/unreleased/Fixed-20261030-130622.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Application keys without an expiration timestamp now report a zero expiration time instead of 1970-01-01 (#244). +time: 2026-10-30T13:06:22.194375-06:00 diff --git a/b2/key.go b/b2/key.go index 68a4aa3..74c1413 100644 --- a/b2/key.go +++ b/b2/key.go @@ -45,7 +45,8 @@ func (k *Key) Prefix() string { return k.k.prefix() } // useless. func (k *Key) Name() string { return k.k.name() } -// Expires returns the expiration date of this application key. +// Expires returns the expiration date of this application key, or the zero time +// if the key does not expire. func (k *Key) Expires() time.Time { return k.k.expires() } // Delete removes the key from B2. diff --git a/base/base.go b/base/base.go index 67677cd..46af70b 100644 --- a/base/base.go +++ b/base/base.go @@ -368,6 +368,13 @@ func millitime(t int64) time.Time { return time.Unix(t/1000, t%1000*1e6) } +func keyExpiration(t int64) time.Time { + if t == 0 { + return time.Time{} + } + return millitime(t) +} + type b2Options struct { transport http.RoundTripper failSomeUploads bool @@ -2098,7 +2105,7 @@ func (b *B2) doCreateKeyWithState(ctx context.Context, state authState, apiVersi ID: b2resp.ID, Secret: b2resp.Secret, Capabilities: b2resp.Capabilities, - Expires: millitime(b2resp.Expires), + Expires: keyExpiration(b2resp.Expires), BucketIDs: keyBucketIDs(b2types.Key(*b2resp)), Prefix: b2resp.Prefix, b2: b, @@ -2138,7 +2145,7 @@ func (b *B2) ListKeys(ctx context.Context, max int, next string) ([]*Key, string Name: key.Name, ID: key.ID, Capabilities: key.Capabilities, - Expires: millitime(key.Expires), + Expires: keyExpiration(key.Expires), BucketIDs: keyBucketIDs(key), Prefix: key.Prefix, b2: b, From 5bb7db1a50d9cc5422213d080960020c5381106b Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 13:32:40 -0600 Subject: [PATCH 3/5] test(base): cover missing key expiration timestamps --- .../unreleased/Fixed-20261030-130622.yaml | 2 +- b2/key_expiration_wire_test.go | 40 ++++++++++++++++--- base/base.go | 11 +++-- base/key_expiration_test.go | 39 +++++++++++++++--- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/.changes/unreleased/Fixed-20261030-130622.yaml b/.changes/unreleased/Fixed-20261030-130622.yaml index 39dba87..a9ab37a 100644 --- a/.changes/unreleased/Fixed-20261030-130622.yaml +++ b/.changes/unreleased/Fixed-20261030-130622.yaml @@ -1,3 +1,3 @@ kind: Fixed -body: Application keys without an expiration timestamp now report a zero expiration time instead of 1970-01-01 (#244). +body: Application keys without an expiration timestamp now report a zero expiration time instead of 1970-01-01; callers comparing Expires() to 1970-01-01 or Unix() to 0 should use IsZero() (#244). time: 2026-10-30T13:06:22.194375-06:00 diff --git a/b2/key_expiration_wire_test.go b/b2/key_expiration_wire_test.go index 9b966cb..1c5117a 100644 --- a/b2/key_expiration_wire_test.go +++ b/b2/key_expiration_wire_test.go @@ -1,3 +1,17 @@ +// Copyright 2026, the Blazer authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package b2 import ( @@ -24,6 +38,7 @@ func newKeyExpirationWire(t *testing.T) (*b2wiretest.Server, *Client) { func TestKeyExpirationReachesPublicAPI(t *testing.T) { const noExpiration = `{"accountId":"account","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const missingExpiration = `{"accountId":"account","applicationKeyId":"missing-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"missing-expiration"}` const expiration = `{"accountId":"account","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` t.Run("create without expiration", func(t *testing.T) { @@ -38,6 +53,18 @@ func TestKeyExpirationReachesPublicAPI(t *testing.T) { } }) + t.Run("create with missing expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, missingExpiration) + key, err := client.CreateKey(context.Background(), "missing-expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().IsZero() { + t.Errorf("CreateKey missing expiration Expires = %v, want zero time", key.Expires()) + } + }) + t.Run("create with expiration", func(t *testing.T) { srv, client := newKeyExpirationWire(t) srv.Reply("b2_create_key", http.StatusOK, expiration) @@ -52,19 +79,22 @@ func TestKeyExpirationReachesPublicAPI(t *testing.T) { t.Run("list keys", func(t *testing.T) { srv, client := newKeyExpirationWire(t) - srv.Reply("b2_list_keys", http.StatusOK, `{"keys":[`+noExpiration+`,`+expiration+`],"nextApplicationKeyId":null}`) + srv.Reply("b2_list_keys", http.StatusOK, `{"keys":[`+noExpiration+`,`+missingExpiration+`,`+expiration+`],"nextApplicationKeyId":null}`) keys, _, err := client.ListKeys(context.Background(), 10, "") if err != nil && err != io.EOF { t.Fatalf("ListKeys: %v", err) } - if len(keys) != 2 { - t.Fatalf("ListKeys returned %d keys, want 2", len(keys)) + if len(keys) != 3 { + t.Fatalf("ListKeys returned %d keys, want 3", len(keys)) } if !keys[0].Expires().IsZero() { t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires()) } - if !keys[1].Expires().Equal(wireKeyExpirationTime) { - t.Errorf("ListKeys expiration Expires = %v, want %v", keys[1].Expires(), wireKeyExpirationTime) + if !keys[1].Expires().IsZero() { + t.Errorf("ListKeys missing expiration Expires = %v, want zero time", keys[1].Expires()) + } + if !keys[2].Expires().Equal(wireKeyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[2].Expires(), wireKeyExpirationTime) } }) } diff --git a/base/base.go b/base/base.go index 46af70b..092890d 100644 --- a/base/base.go +++ b/base/base.go @@ -2044,10 +2044,13 @@ type Key struct { Secret string Name string Capabilities []string - Expires time.Time - BucketIDs []string - Prefix string - b2 *B2 + // Expires is the application key's expiration date. A zero time.Time means + // the key never expires; check that with IsZero, not Unix, which is + // -62135596800 for the zero time. + Expires time.Time + BucketIDs []string + Prefix string + b2 *B2 } func keyBucketIDs(key b2types.Key) []string { diff --git a/base/key_expiration_test.go b/base/key_expiration_test.go index 7dbc842..41578b1 100644 --- a/base/key_expiration_test.go +++ b/base/key_expiration_test.go @@ -1,3 +1,17 @@ +// Copyright 2026, the Blazer authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package base import ( @@ -10,8 +24,9 @@ var keyExpirationTime = time.Date(2023, time.November, 14, 22, 13, 20, 123000000 func TestKeyExpiration(t *testing.T) { const noExpirationCreate = `{"accountId":"account-id","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const missingExpirationCreate = `{"accountId":"account-id","applicationKeyId":"missing-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"missing-expiration"}` const expirationCreate = `{"accountId":"account-id","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` - const listResponse = `{"keys":[` + noExpirationCreate + `,` + expirationCreate + `],"nextApplicationKeyId":null}` + const listResponse = `{"keys":[` + noExpirationCreate + `,` + missingExpirationCreate + `,` + expirationCreate + `],"nextApplicationKeyId":null}` t.Run("create without expiration", func(t *testing.T) { b := newKeyRestrictionsB2(t, noExpirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) @@ -24,6 +39,17 @@ func TestKeyExpiration(t *testing.T) { } }) + t.Run("create with missing expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, missingExpirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "missing-expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.IsZero() { + t.Errorf("CreateKey missing expiration Expires = %v, want zero time", key.Expires) + } + }) + t.Run("create with expiration", func(t *testing.T) { b := newKeyRestrictionsB2(t, expirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) key, err := b.CreateKey(context.Background(), "expiration", []string{"listBuckets"}, 0, "", "") @@ -41,14 +67,17 @@ func TestKeyExpiration(t *testing.T) { if err != nil { t.Fatalf("ListKeys: %v", err) } - if len(keys) != 2 { - t.Fatalf("ListKeys returned %d keys, want 2", len(keys)) + if len(keys) != 3 { + t.Fatalf("ListKeys returned %d keys, want 3", len(keys)) } if !keys[0].Expires.IsZero() { t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires) } - if !keys[1].Expires.Equal(keyExpirationTime) { - t.Errorf("ListKeys expiration Expires = %v, want %v", keys[1].Expires, keyExpirationTime) + if !keys[1].Expires.IsZero() { + t.Errorf("ListKeys missing expiration Expires = %v, want zero time", keys[1].Expires) + } + if !keys[2].Expires.Equal(keyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[2].Expires, keyExpirationTime) } }) } From c0484e4efd90eb5a2fa2fd1c9b7c03660c035931 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:19:17 -0600 Subject: [PATCH 4/5] test(b2): assert live keys never expire --- b2/integration_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/b2/integration_test.go b/b2/integration_test.go index 533a822..339fd25 100644 --- a/b2/integration_test.go +++ b/b2/integration_test.go @@ -1114,6 +1114,9 @@ func TestListBucketsWithKey(t *testing.T) { if err != nil { t.Fatal(err) } + if !key.Expires().IsZero() { + t.Errorf("key.Expires() = %v, want zero time for a key without an expiration", key.Expires()) + } defer func() { if err := key.Delete(ctx); err != nil { t.Error(err) @@ -1340,6 +1343,9 @@ func TestKeyRestrictionAccessors(t *testing.T) { if err != nil { t.Fatalf("Bucket.CreateKey: %v", err) } + if !key.Expires().IsZero() { + t.Errorf("key.Expires() = %v, want zero time for a key without an expiration", key.Expires()) + } defer func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanupCancel() @@ -1371,6 +1377,9 @@ func TestKeyRestrictionAccessors(t *testing.T) { if listed == nil { t.Fatalf("ListKeys did not return key %q", key.ID()) } + if !listed.Expires().IsZero() { + t.Errorf("listed.Expires() = %v, want zero time for a key without an expiration", listed.Expires()) + } assertKeyRestrictions(t, listed, []string{bucket.ID()}, "live-227/") } From aad79e8b1096094b504f9ceb7131bfa0aff6a4b7 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:19:26 -0600 Subject: [PATCH 5/5] docs(base): explain keyExpiration --- base/base.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/base/base.go b/base/base.go index 092890d..d743f71 100644 --- a/base/base.go +++ b/base/base.go @@ -368,6 +368,9 @@ func millitime(t int64) time.Time { return time.Unix(t/1000, t%1000*1e6) } +// b2_create_key and b2_list_keys return null expirationTimestamp for keys that +// never expire; it decodes as zero, which must map to the zero time. Keep +// millitime unchanged because it also converts file timestamps. func keyExpiration(t int64) time.Time { if t == 0 { return time.Time{}