Skip to content

Commit 1cafc4f

Browse files
committed
Reproduce late doctor storage errors and migration deletion timeouts
1 parent 7ad6741 commit 1cafc4f

3 files changed

Lines changed: 128 additions & 3 deletions

File tree

‎internal/commands/doctor.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
2323

2424
"github.com/basecamp/basecamp-cli/internal/appctx"
25+
"github.com/basecamp/basecamp-cli/internal/auth"
2526
"github.com/basecamp/basecamp-cli/internal/config"
2627
"github.com/basecamp/basecamp-cli/internal/harness"
2728
"github.com/basecamp/basecamp-cli/internal/output"
@@ -787,6 +788,12 @@ func checkStoredCredentials(ctx context.Context, app *appctx.App, verbose bool)
787788

788789
// checkAuthentication checks token validity.
789790
func checkAuthentication(ctx context.Context, app *appctx.App, verbose bool) Check {
791+
return checkAuthenticationWith(ctx, app, verbose, app.Auth.GetStore().LoadContext, app.Auth.Refresh)
792+
}
793+
794+
func checkAuthenticationWith(ctx context.Context, app *appctx.App, verbose bool,
795+
load func(context.Context, string) (*auth.Credentials, error), refresh func(context.Context) error,
796+
) Check {
790797
check := Check{
791798
Name: "Authentication",
792799
}
@@ -800,8 +807,7 @@ func checkAuthentication(ctx context.Context, app *appctx.App, verbose bool) Che
800807

801808
// Load credentials to check expiration
802809
credKey := app.Auth.CredentialKey()
803-
store := app.Auth.GetStore()
804-
creds, err := store.Load(credKey)
810+
creds, err := load(ctx, credKey)
805811
if err != nil {
806812
check.Status = "fail"
807813
check.Message = "Cannot load credentials"
@@ -815,7 +821,7 @@ func checkAuthentication(ctx context.Context, app *appctx.App, verbose bool) Che
815821

816822
if expiresIn < 0 {
817823
// Token expired - try to refresh
818-
if err := app.Auth.Refresh(ctx); err != nil {
824+
if err := refresh(ctx); err != nil {
819825
check.Status = "fail"
820826
check.Message = "Token expired and refresh failed"
821827
// The failure's own remedy, not a blanket login: for an

‎internal/commands/doctor_test.go‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"debug/pe"
77
"encoding/binary"
88
"errors"
9+
"fmt"
910
"net/http"
1011
"os"
1112
"path/filepath"
@@ -400,6 +401,71 @@ func TestDoctorDoesNotCallAnUnreadableStoreAMissingLogin(t *testing.T) {
400401
}
401402
}
402403

404+
func TestDoctorAuthenticationPreservesLaterStoreTimeouts(t *testing.T) {
405+
for _, failure := range []string{"authentication read", "refresh read", "refresh write"} {
406+
t.Run(failure, func(t *testing.T) {
407+
app, _ := setupDoctorTestApp(t, "12345")
408+
t.Setenv("BASECAMP_TOKEN", "")
409+
creds := &auth.Credentials{
410+
AccessToken: "test-token", RefreshToken: "test-refresh",
411+
OAuthType: "bc5", ExpiresAt: time.Now().Add(time.Hour).Unix(),
412+
}
413+
if failure != "authentication read" {
414+
creds.ExpiresAt = time.Now().Add(-time.Hour).Unix()
415+
}
416+
require.NoError(t, app.Auth.GetStore().Save(app.Auth.CredentialKey(), creds))
417+
initial, unreadable := checkStoredCredentials(context.Background(), app, false)
418+
require.False(t, unreadable)
419+
require.Equal(t, "pass", initial.Status)
420+
421+
operation := "read"
422+
if failure == "refresh write" {
423+
operation = "write"
424+
}
425+
timeout := fmt.Errorf("keyring %s timed out: %w; set BASECAMP_NO_KEYRING=1", operation, context.DeadlineExceeded)
426+
loads, refreshes := 0, 0
427+
load := func(context.Context, string) (*auth.Credentials, error) {
428+
loads++
429+
if failure == "authentication read" || (failure == "refresh read" && loads == 2) {
430+
return nil, timeout
431+
}
432+
return creds, nil
433+
}
434+
refresh := func(ctx context.Context) error {
435+
refreshes++
436+
if _, err := load(ctx, app.Auth.CredentialKey()); err != nil {
437+
return err
438+
}
439+
return timeout // The refresh read succeeded, but saving its result timed out.
440+
}
441+
check := checkAuthenticationWith(context.Background(), app, false, load, refresh)
442+
assert.Equal(t, "fail", check.Status)
443+
assert.Contains(t, check.Message, "keyring "+operation+" timed out")
444+
assert.Contains(t, check.Message, "BASECAMP_NO_KEYRING=1")
445+
assert.NotContains(t, check.Hint, app.Auth.LoginCommand())
446+
assert.Empty(t, buildDoctorBreadcrumbs([]Check{initial, check}, app.Auth.LoginCommand()))
447+
if failure == "authentication read" {
448+
assert.Equal(t, 1, loads)
449+
assert.Zero(t, refreshes)
450+
} else {
451+
assert.Equal(t, 2, loads)
452+
assert.Equal(t, 1, refreshes)
453+
}
454+
})
455+
}
456+
}
457+
458+
func TestDoctorAuthenticationBreadcrumbRequiresLoginRemedy(t *testing.T) {
459+
for _, status := range []string{"fail", "warn"} {
460+
check := Check{Name: "Authentication", Status: status, Hint: "Try again later"}
461+
assert.Empty(t, buildDoctorBreadcrumbs([]Check{check}, "basecamp auth login"))
462+
}
463+
check := Check{Name: "Authentication", Status: "fail", Hint: "Run: basecamp auth login"}
464+
crumbs := buildDoctorBreadcrumbs([]Check{check}, "basecamp auth login")
465+
require.Len(t, crumbs, 1)
466+
assert.Equal(t, "basecamp auth login", crumbs[0].Cmd)
467+
}
468+
403469
func TestDoctorLegacyKeyringProbeIsBounded(t *testing.T) {
404470
original := legacyKeyringGet
405471
t.Cleanup(func() { legacyKeyringGet = original })

‎internal/commands/migrate_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,59 @@ func TestMigrateKeyring_StalledKeyringIsBounded(t *testing.T) {
432432
assert.EqualValues(t, 1, calls.Load())
433433
}
434434

435+
func TestMigrateDeletionTimeoutDoesNotMarkCompletion(t *testing.T) {
436+
for _, alreadyCopied := range []bool{false, true} {
437+
t.Run(fmt.Sprintf("destination_exists=%t", alreadyCopied), func(t *testing.T) {
438+
t.Setenv("HOME", t.TempDir())
439+
t.Setenv("BASECAMP_NO_KEYRING", "")
440+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
441+
cacheBase := t.TempDir()
442+
t.Setenv("XDG_CACHE_HOME", cacheBase)
443+
require.NoError(t, os.MkdirAll(filepath.Join(cacheBase, "bcq"), 0700))
444+
445+
fk := newFakeKeyring()
446+
fk.store[fk.key("bcq", "bcq::https://3.basecampapi.com")] = "legacy-value"
447+
if alreadyCopied {
448+
fk.store[fk.key("basecamp", "basecamp::https://3.basecampapi.com")] = "current-value"
449+
}
450+
fk.install(t)
451+
originalTimeout := legacyKeyringTimeout
452+
legacyKeyringTimeout = 40 * time.Millisecond
453+
t.Cleanup(func() { legacyKeyringTimeout = originalTimeout })
454+
release, finished := make(chan struct{}), make(chan struct{})
455+
keyringOps.delete = func(string, string) error {
456+
defer close(finished)
457+
<-release
458+
return nil
459+
}
460+
t.Cleanup(func() { close(release); <-finished })
461+
462+
cmd := NewMigrateCmd()
463+
var out bytes.Buffer
464+
cmd.SetOut(&out)
465+
cmd.SetArgs(nil)
466+
require.NoError(t, cmd.Execute())
467+
var result MigrateResult
468+
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
469+
assert.True(t, result.CacheMoved, "even a successful cache move must not hide incomplete keyring cleanup")
470+
assert.Zero(t, result.KeyringMigrated)
471+
require.Len(t, result.KeyringErrors, 1)
472+
assert.Contains(t, result.KeyringErrors[0], "delete")
473+
assert.Contains(t, result.KeyringErrors[0], "timed out")
474+
assert.Contains(t, result.KeyringErrors[0], "BASECAMP_NO_KEYRING=1")
475+
_, err := os.Stat(filepath.Join(config.GlobalConfigDir(), migratedMarker))
476+
assert.True(t, os.IsNotExist(err), "migration must stay discoverable after a deletion timeout")
477+
value, err := fk.get("basecamp", "basecamp::https://3.basecampapi.com")
478+
require.NoError(t, err)
479+
if alreadyCopied {
480+
assert.Equal(t, "current-value", value)
481+
} else {
482+
assert.Equal(t, "legacy-value", value)
483+
}
484+
})
485+
}
486+
}
487+
435488
func TestMigrateSkipsKeyringWhenNoKeyring(t *testing.T) {
436489
t.Setenv("BASECAMP_NO_KEYRING", "1")
437490
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

0 commit comments

Comments
 (0)