Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Fixed-20261030-130622.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Fixed
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
9 changes: 9 additions & 0 deletions b2/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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/")
}

Expand Down
3 changes: 2 additions & 1 deletion b2/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 100 additions & 0 deletions b2/key_expiration_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2026, the Blazer authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package b2

import (
"context"
"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 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) {
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 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)
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+`,`+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) != 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().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)
}
})
}
25 changes: 19 additions & 6 deletions base/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ 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{}
}
return millitime(t)
}

type b2Options struct {
transport http.RoundTripper
failSomeUploads bool
Expand Down Expand Up @@ -2037,10 +2047,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 {
Expand Down Expand Up @@ -2098,7 +2111,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,
Expand Down Expand Up @@ -2138,7 +2151,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,
Expand Down
83 changes: 83 additions & 0 deletions base/key_expiration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026, the Blazer authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package base

import (
"context"
"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 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 + `,` + missingExpirationCreate + `,` + 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 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, "", "")
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) != 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.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)
}
})
}
Loading