Skip to content
2 changes: 1 addition & 1 deletion pkg/cmd/resource/terminal_quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func (cc *QuickstartCmd) runQuickstartCmd(cmd *cobra.Command, args []string) err
return fmt.Errorf(err.Error())
}

err = validators.APIKeyNotRestricted(key)
err = validators.APIKeyNotRestricted(key.Key)

if err != nil {
return fmt.Errorf(err.Error())
Expand Down
129 changes: 129 additions & 0 deletions pkg/config/api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package config

import (
"fmt"
"math"
"os"
"strconv"
"strings"
"time"

"github.com/spf13/viper"

"github.com/stripe/stripe-cli/pkg/ansi"
)

const liveModeKeyLastExpirationWarningField = "live_mode_api_key_last_expiration_warning"
const testModeKeyLastExpirationWarningField = "test_mode_api_key_last_expiration_warning"

const upcomingExpirationThreshold = 14 * 24 * time.Hour
const imminentExpirationThreshold = 24 * time.Hour

const upcomingExpirationReminderFrequency = 12 * time.Hour

// Useful for stubbing in tests
var timeNow = time.Now
var printWarning = printWarningMessage

// APIKey holds various details about an API key like the key itself, its expiration,
// and whether its for live or testmode usage.
type APIKey struct {
Key string
Livemode bool
Expiration time.Time
}

// NewAPIKey creates an APIKey from the relevant data
func NewAPIKey(key string, expiration time.Time, livemode bool) *APIKey {
if key == "" {
return nil
}

return &APIKey{
Key: key,
Livemode: livemode,
Expiration: expiration,
}
}

// NewAPIKeyFromString creates an APIKey when only the key is known
func NewAPIKeyFromString(key string) *APIKey {
if key == "" {
return nil
}

return &APIKey{
Key: key,
// Not guaranteed to be right, but we'll try our best to infer live/test mode
// via a heuristic
Livemode: strings.Contains(key, "live"),
// Expiration intentionally omitted to leave it as the zero value, since
// it's not known when e.g. a key is passed using an environment variable.
}
}

// WarnIfExpirationSoon shows the relevant warning if the key is due to expire soon
func (k *APIKey) WarnIfExpirationSoon(profile *Profile) {
if k.Expiration.IsZero() {
return
}

remainingValidity := k.Expiration.Sub(timeNow())
if k.shouldShowImminentExpirationWarning() {
warnMsg := fmt.Sprintf("Your API key will expire in less than %.0f hours. You can obtain a new key from the Dashboard or `stripe login`.", imminentExpirationThreshold.Hours())
printWarning(warnMsg)
_ = k.setLastExpirationWarning(timeNow(), profile)
} else if k.shouldShowUpcomingExpirationWarning(profile) {
remainingDays := int(math.Round(remainingValidity.Hours() / 24.0))
warnMsg := fmt.Sprintf("Your API key will expire in %d days. You can obtain a new key from the Dashboard or `stripe login`.", remainingDays)
printWarning(warnMsg)
_ = k.setLastExpirationWarning(timeNow(), profile)
}
}

func (k *APIKey) shouldShowImminentExpirationWarning() bool {
remainingValidity := k.Expiration.Sub(timeNow())
return remainingValidity < imminentExpirationThreshold
}

func (k *APIKey) shouldShowUpcomingExpirationWarning(profile *Profile) bool {
remainingValidity := k.Expiration.Sub(timeNow())
if remainingValidity < upcomingExpirationThreshold {
lastWarning := k.fetchLastExpirationWarning(profile)

if timeNow().Sub(lastWarning) > upcomingExpirationReminderFrequency {
return true
}
}

return false
}

func (k *APIKey) fetchLastExpirationWarning(profile *Profile) time.Time {
configKey := profile.GetConfigField(k.expirationWarningField())
lastWarningTimeString := viper.GetString(configKey)
lastWarningUnixTime, err := strconv.ParseInt(lastWarningTimeString, 10, 64)
if err != nil {
return time.Time{}
}

return time.Unix(lastWarningUnixTime, 0)
}

func (k *APIKey) setLastExpirationWarning(warningTime time.Time, profile *Profile) error {
timeStr := strconv.FormatInt(warningTime.Unix(), 10)
return profile.WriteConfigField(k.expirationWarningField(), timeStr)
}

func (k *APIKey) expirationWarningField() string {
if k.Livemode {
return liveModeKeyLastExpirationWarningField
}
return testModeKeyLastExpirationWarningField
}

func printWarningMessage(message string) {
formattedMessage := ansi.Color(os.Stderr).Yellow(message).Bold()
_, err := fmt.Fprintln(os.Stderr, formattedMessage)
_ = err
}
194 changes: 194 additions & 0 deletions pkg/config/api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package config

import (
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
)

func TestNewAPIKeyFromString(t *testing.T) {
sampleLivemodeKeyString := "rk_live_1234"
sampleTestmodeKeyString := "rk_test_1234"

livemodeKey := NewAPIKeyFromString(sampleLivemodeKeyString)
testmodeKey := NewAPIKeyFromString(sampleTestmodeKeyString)

assert.Equal(t, sampleLivemodeKeyString, livemodeKey.Key)
assert.True(t, livemodeKey.Livemode)
assert.Zero(t, livemodeKey.Expiration)

assert.Equal(t, sampleTestmodeKeyString, testmodeKey.Key)
assert.False(t, testmodeKey.Livemode)
assert.Zero(t, testmodeKey.Expiration)
}

func TestWarnIfExpirationSoon(t *testing.T) {
t.Run("warn repeatedly when expiration is imminent", func(t *testing.T) {
now := time.Unix(1000, 0)
expiration := now.Add(imminentExpirationThreshold - 1*time.Hour)

timeCleanup := setupFakeTimeNow(now)
defer timeCleanup()

printed, printWarningCleanup := setupFakePrintWarning()
defer printWarningCleanup()

k := &APIKey{
Key: "rk_test_1234",
Livemode: false,
Expiration: expiration,
}

config, configCleanup := setupTestConfig(k)
defer configCleanup()

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, 1, len(printed.messages))

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, 2, len(printed.messages))
})

t.Run("warn once per period when expiration is upcoming", func(t *testing.T) {
now := time.Unix(5000, 0)
expiration := now.Add(upcomingExpirationThreshold - 1*time.Hour)

initialTimeCleanup := setupFakeTimeNow(now)
defer initialTimeCleanup()

printed, printWarningCleanup := setupFakePrintWarning()
defer printWarningCleanup()

k := &APIKey{
Key: "rk_test_1234",
Livemode: false,
Expiration: expiration,
}

config, configCleanup := setupTestConfig(k)
defer configCleanup()

nextTime := now
for i := 0; i < 4; i++ {
nextTime = nextTime.Add(upcomingExpirationReminderFrequency + 1*time.Hour)

advancedTimeCleanup := setupFakeTimeNow(nextTime)
defer advancedTimeCleanup()

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, i+1, len(printed.messages))

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, i+1, len(printed.messages))

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, i+1, len(printed.messages))
}
})

t.Run("do not warn when expiration is not near", func(t *testing.T) {
now := time.Unix(900000, 0)
expiration := now.Add(90 * 24 * time.Hour)

initialTimeCleanup := setupFakeTimeNow(now)
defer initialTimeCleanup()

printed, printWarningCleanup := setupFakePrintWarning()
defer printWarningCleanup()

k := &APIKey{
Key: "rk_test_1234",
Livemode: false,
Expiration: expiration,
}

config, configCleanup := setupTestConfig(k)
defer configCleanup()

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, 0, len(printed.messages))
})

t.Run("do not warn when expiration is unset", func(t *testing.T) {
now := time.Unix(900000, 0)

initialTimeCleanup := setupFakeTimeNow(now)
defer initialTimeCleanup()

printed, printWarningCleanup := setupFakePrintWarning()
defer printWarningCleanup()

k := NewAPIKeyFromString("rk_test_1234")

config, configCleanup := setupTestConfig(k)
defer configCleanup()

k.WarnIfExpirationSoon(&config.Profile)
assert.Equal(t, 0, len(printed.messages))
})
}

func setupTestConfig(testmodeKey *APIKey) (*Config, func()) {
uniqueConfig := fmt.Sprintf("config-%d.toml", time.Now().UnixMilli())
profilesFile := filepath.Join(os.TempDir(), "stripe", uniqueConfig)

p := Profile{
DeviceName: "st-testing",
ProfileName: "tests",
DisplayName: "test-account-display-name",
TestModeAPIKey: testmodeKey,
}

c := &Config{
Color: "auto",
LogLevel: "info",
Profile: p,
ProfilesFile: profilesFile,
}
c.InitConfig()

v := viper.New()
_ = p.writeProfile(v)

return c, func() {
_ = os.Remove(profilesFile)
}
}

// Mocks the result of time.Now as used in api_key.go and returns a cleanup
// function which should be called in a defer in the consuming test.
func setupFakeTimeNow(t time.Time) func() {
original := timeNow
timeNow = func() time.Time {
return t
}

return func() {
timeNow = original
}
}

// This struct encapsulates the message slice since that's the most idiomatic
// way to retain a pointer to the slice outside of the mocked function
type messageRecorder struct {
messages []string
}

func setupFakePrintWarning() (*messageRecorder, func()) {
original := printWarning

printed := &messageRecorder{}

printWarning = func(message string) {
printed.messages = append(printed.messages, message)
}

return printed, func() {
printWarning = original
}
}
Loading