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
34 changes: 23 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,6 @@ jobs:
exit 1
fi

- name: Create tag
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
env:
TAG: ${{ steps.version.outputs.VERSION }}
run: |
git tag "$TAG"
git push origin "$TAG"

- name: Set up Go
uses: actions/setup-go@v5
with:
Expand All @@ -76,24 +68,44 @@ jobs:
- name: Run tests
run: go test ./... -short

- name: Validate release build
if: github.event_name == 'workflow_dispatch'
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean --snapshot --skip=publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.VERSION }}

- name: Create tag
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
env:
TAG: ${{ steps.version.outputs.VERSION }}
run: |
git tag "$TAG"
git push origin "$TAG"

- name: Run GoReleaser
if: github.event_name == 'push'
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--snapshot' || '' }}
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.VERSION }}

- name: Create DMG files
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
if: github.event_name == 'push'
env:
TAG: ${{ steps.version.outputs.VERSION }}
run: ./scripts/create-dmg.sh "$TAG"

- name: Upload DMG to release
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
if: github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION }}
Expand Down
7 changes: 1 addition & 6 deletions internal/app/dashboard/app_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,5 @@ func deduplicateApps(apps []domain.GatewayApplication) []domain.GatewayApplicati

// loadTokens retrieves the stored dashboard tokens.
func (s *AppService) loadTokens() (userToken, orgToken string, err error) {
userToken, err = s.secrets.Get(ports.KeyDashboardUserToken)
if err != nil || userToken == "" {
return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn)
}
orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken)
return userToken, orgToken, nil
return loadDashboardTokens(s.secrets)
}
15 changes: 6 additions & 9 deletions internal/app/dashboard/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ func (s *AuthService) SwitchOrg(ctx context.Context, orgPublicID string) (*domai
func (s *AuthService) SyncSessionOrg(ctx context.Context) error {
session, err := s.GetCurrentSession(ctx)
if err != nil {
return nil // best effort — login already succeeded
return fmt.Errorf("failed to fetch current dashboard session: %w", err)
}
if session.CurrentOrg != "" {
_ = s.secrets.Set(ports.KeyDashboardOrgPublicID, session.CurrentOrg)
if err := s.secrets.Set(ports.KeyDashboardOrgPublicID, session.CurrentOrg); err != nil {
return fmt.Errorf("failed to store active organization: %w", err)
}
}
return nil
}
Expand All @@ -232,7 +234,7 @@ func (s *AuthService) storeTokens(resp *domain.DashboardAuthResponse) error {
return err
}
}
if len(resp.Organizations) > 0 {
if len(resp.Organizations) == 1 {
if err := s.secrets.Set(ports.KeyDashboardOrgPublicID, resp.Organizations[0].PublicID); err != nil {
return err
}
Expand All @@ -258,10 +260,5 @@ func (s *AuthService) clearTokens() {

// loadTokens retrieves the stored tokens.
func (s *AuthService) loadTokens() (userToken, orgToken string, err error) {
userToken, err = s.secrets.Get(ports.KeyDashboardUserToken)
if err != nil || userToken == "" {
return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn)
}
orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken)
return userToken, orgToken, nil
return loadDashboardTokens(s.secrets)
}
105 changes: 96 additions & 9 deletions internal/app/dashboard/auth_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ func (m *memSecretStore) IsAvailable() bool { return true }

func (m *memSecretStore) Name() string { return "mem" }

type failingSecretStore struct {
*memSecretStore
failSetKey string
}

func (f *failingSecretStore) Set(key, value string) error {
if key == f.failSetKey {
return errors.New("set failed")
}
return f.memSecretStore.Set(key, value)
}

// seedTokens pre-populates userToken (and optionally orgToken) so that
// loadTokens() succeeds without going through a full Login flow.
func seedTokens(s ports.SecretStore, userToken, orgToken string) {
Expand Down Expand Up @@ -335,8 +347,10 @@ func TestAuthService_SyncSessionOrg(t *testing.T) {
name string
seedUser string
seedOrg string
storeFactory func() ports.SecretStore
mockFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error)
wantErr bool // SyncSessionOrg is best-effort; always returns nil
wantErr bool
wantErrIs error
wantOrgPublicID string
wantNoOrgPublicID bool
}{
Expand All @@ -351,18 +365,18 @@ func TestAuthService_SyncSessionOrg(t *testing.T) {
wantOrgPublicID: "org-synced",
},
{
name: "returns nil when GetCurrentSession fails (best-effort)",
name: "returns error when GetCurrentSession fails",
seedUser: "ut-abc",
mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) {
return nil, errors.New("session fetch failed")
},
// wantErr is false — SyncSessionOrg is best-effort.
wantErr: true,
wantNoOrgPublicID: true,
},
{
name: "returns nil when not logged in (best-effort)",
// No seedUser means loadTokens returns ErrDashboardNotLoggedIn.
// SyncSessionOrg should still return nil.
name: "returns error when not logged in",
wantErr: true,
wantErrIs: domain.ErrDashboardNotLoggedIn,
wantNoOrgPublicID: true,
},
{
Expand All @@ -386,14 +400,36 @@ func TestAuthService_SyncSessionOrg(t *testing.T) {
},
wantOrgPublicID: "org-from-server",
},
{
name: "returns error when storing synced org fails",
seedUser: "ut-abc",
storeFactory: func() ports.SecretStore {
return &failingSecretStore{
memSecretStore: newMemSecretStore(),
failSetKey: ports.KeyDashboardOrgPublicID,
}
},
mockFn: func(_ context.Context, _, _ string) (*domain.DashboardSessionResponse, error) {
return &domain.DashboardSessionResponse{
CurrentOrg: "org-synced",
}, nil
},
wantErr: true,
wantNoOrgPublicID: true,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

store := newMemSecretStore()
var store ports.SecretStore
if tt.storeFactory != nil {
store = tt.storeFactory()
} else {
store = newMemSecretStore()
}
seedTokens(store, tt.seedUser, tt.seedOrg)

mock := &dashboardadapter.MockAccountClient{
Expand All @@ -403,8 +439,14 @@ func TestAuthService_SyncSessionOrg(t *testing.T) {
svc := NewAuthService(mock, store)
err := svc.SyncSessionOrg(context.Background())

// SyncSessionOrg is always best-effort — it must never return an error.
require.NoError(t, err)
if tt.wantErr {
require.Error(t, err)
if tt.wantErrIs != nil {
assert.ErrorIs(t, err, tt.wantErrIs)
}
} else {
require.NoError(t, err)
}

stored, _ := store.Get(ports.KeyDashboardOrgPublicID)
if tt.wantOrgPublicID != "" {
Expand All @@ -416,3 +458,48 @@ func TestAuthService_SyncSessionOrg(t *testing.T) {
})
}
}

func TestAuthServiceStoreTokens(t *testing.T) {
t.Parallel()

t.Run("stores org when there is exactly one organization", func(t *testing.T) {
t.Parallel()

store := newMemSecretStore()
svc := NewAuthService(&dashboardadapter.MockAccountClient{}, store)

err := svc.storeTokens(&domain.DashboardAuthResponse{
UserToken: "user-token",
User: domain.DashboardUser{PublicID: "user-1"},
Organizations: []domain.DashboardOrganization{
{PublicID: "org-only"},
},
})

require.NoError(t, err)

storedOrgID, _ := store.Get(ports.KeyDashboardOrgPublicID)
assert.Equal(t, "org-only", storedOrgID)
})

t.Run("does not guess active org when multiple organizations exist", func(t *testing.T) {
t.Parallel()

store := newMemSecretStore()
svc := NewAuthService(&dashboardadapter.MockAccountClient{}, store)

err := svc.storeTokens(&domain.DashboardAuthResponse{
UserToken: "user-token",
User: domain.DashboardUser{PublicID: "user-1"},
Organizations: []domain.DashboardOrganization{
{PublicID: "org-1"},
{PublicID: "org-2"},
},
})

require.NoError(t, err)

storedOrgID, _ := store.Get(ports.KeyDashboardOrgPublicID)
assert.Empty(t, storedOrgID)
})
}
19 changes: 19 additions & 0 deletions internal/app/dashboard/session_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dashboard

import (
"fmt"

"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
)

// loadDashboardTokens retrieves the stored dashboard access and session tokens.
// Returns ErrDashboardNotLoggedIn when no user token is present.
func loadDashboardTokens(secrets ports.SecretStore) (userToken, orgToken string, err error) {
userToken, err = secrets.Get(ports.KeyDashboardUserToken)
if err != nil || userToken == "" {
return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn)
}
orgToken, _ = secrets.Get(ports.KeyDashboardOrgToken)
return userToken, orgToken, nil
}
Loading
Loading