Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a warning when API keys will expire soon #1086

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
Loading