Skip to content

Commit 4433b0a

Browse files
committed
fix(plaidlogin,cli,config): reject team switch without force in same environment
When a user has full sandbox credentials for TeamA and runs , the command now returns PLAID_CREDENTIALS_OVERWRITE_REQUIRED (exit code 10) instead of letting the OAuth flow complete and then failing with an opaque CONFIG_WRITE_FAILED. Changes: - login.go: add explicit mismatch-team check before write; use LoadProviderConfig so only plaid errors surface. - login.go: set effectiveForce=true when any existing credential is present so ConfigureProvider overwrites instead of conflict-error. - config.go: add LoadProviderConfig to resolve one provider block without requiring full config validity. - cli.go: map PLAID_CREDENTIALS_OVERWRITE_REQUIRED to safety/10. - errors.go: add ErrorPlaidCredentialsOverwriteRequired constant. - tests: add TestRunLoginOverwritesRotatedSecretForSameTeamAndEnvironment and update existing mismatch-team test to assert the correct code. - CONTRACTS.md: document the new error code. This closes the review comment from PR #3 about same-environment team switches silently ending in CONFIG_WRITE_FAILED.
1 parent 42254f2 commit 4433b0a

7 files changed

Lines changed: 176 additions & 14 deletions

File tree

docs/CONTRACTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Shared command error codes include:
163163
| `DASHBOARD_TOKEN_REFRESH_FAILED` | `auth` | false | 3 | Stored Dashboard refresh token cannot refresh; rerun `money plaid login`. |
164164
| `DASHBOARD_CONTRACT_CHANGED` | `api` | false | 6 | Plaid Dashboard private response shape no longer matches the known CLI-compatible contract. |
165165
| `PLAID_DASHBOARD_LOGIN_REJECTED` | `auth` | false | 3 | Plaid rejected the CLI-compatible Dashboard OAuth path. |
166+
| `PLAID_CREDENTIALS_OVERWRITE_REQUIRED` | `safety` | false | 10 | Existing Plaid API credentials would be replaced and `--force` is required. |
166167
| `PLAID_ENVIRONMENT_NOT_PROVISIONED` | `validation` | false | 2 | Dashboard returned no secret for the selected Plaid environment. |
167168
| `READ_ONLY_VIOLATION` | `safety` | false | 4 | The command would mutate local config, env, store, or auth files while read-only mode is enabled. |
168169

internal/cli/cli.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,9 @@ func plaidLoginError(command string, err error) error {
14051405
case plaidlogin.ErrorTeamSelectionRequired, plaidlogin.ErrorPlaidEnvironmentNotProvisioned, plaidlogin.ErrorInvalidEnum:
14061406
category = contracts.CategoryValidation
14071407
exitCode = 2
1408+
case plaidlogin.ErrorPlaidCredentialsOverwriteRequired:
1409+
category = contracts.CategorySafety
1410+
exitCode = 10
14081411
case plaidlogin.ErrorAPIKeysFetchRequired:
14091412
category = contracts.CategoryAuth
14101413
exitCode = 3

internal/config/config.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,32 @@ func Load(options Options) (Config, error) {
170170
return cfg, nil
171171
}
172172

173+
// LoadProviderConfig resolves only one provider block without requiring the
174+
// rest of the config to resolve successfully.
175+
func LoadProviderConfig(options Options, provider string) (ProviderConfig, error) {
176+
meta, raw, err := resolveMetadataAndRaw(options)
177+
if err != nil {
178+
return ProviderConfig{}, err
179+
}
180+
mergedEnv, err := mergedEnvironment(options, meta.EnvPath)
181+
if err != nil {
182+
return ProviderConfig{}, err
183+
}
184+
fields, ok := raw.Providers[provider]
185+
if !ok {
186+
return ProviderConfig{Fields: map[string]string{}}, nil
187+
}
188+
resolved := ProviderConfig{Fields: map[string]string{}}
189+
for name, node := range fields {
190+
value, _, err := resolveValue("providers."+provider+"."+name, node, mergedEnv, isSecretField(name))
191+
if err != nil {
192+
return ProviderConfig{}, err
193+
}
194+
resolved.Fields[name] = value
195+
}
196+
return resolved, nil
197+
}
198+
173199
func ResolveMetadata(options Options) (Metadata, error) {
174200
meta, _, err := resolveMetadataAndRaw(options)
175201
return meta, err

internal/config/metadata_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,44 @@ providers: {}
151151
}
152152
}
153153

154+
func TestLoadProviderConfigIgnoresUnrelatedProviderEnvErrors(t *testing.T) {
155+
dir := t.TempDir()
156+
configPath := filepath.Join(dir, "config.yaml")
157+
key := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
158+
159+
if err := os.WriteFile(configPath, []byte(`
160+
database:
161+
path: ./money.db
162+
encryption_key:
163+
env: MONEY_DB_ENCRYPTION_KEY
164+
providers:
165+
plaid:
166+
client_id:
167+
env: PLAID_CLIENT_ID
168+
secret:
169+
env: PLAID_SECRET
170+
environment: sandbox
171+
bridge:
172+
client_id:
173+
env: BRIDGE_CLIENT_ID
174+
client_secret:
175+
env: BRIDGE_CLIENT_SECRET
176+
`), 0o600); err != nil {
177+
t.Fatal(err)
178+
}
179+
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("MONEY_DB_ENCRYPTION_KEY="+key+"\nPLAID_CLIENT_ID=client\nPLAID_SECRET=secret\n"), 0o600); err != nil {
180+
t.Fatal(err)
181+
}
182+
183+
provider, err := LoadProviderConfig(Options{ConfigPath: configPath, Env: map[string]string{}}, "plaid")
184+
if err != nil {
185+
t.Fatalf("LoadProviderConfig: %v", err)
186+
}
187+
if provider.Fields["client_id"] != "client" || provider.Fields["secret"] != "secret" || provider.Fields["environment"] != "sandbox" {
188+
t.Fatalf("provider fields = %#v", provider.Fields)
189+
}
190+
}
191+
154192
func containsAll(value string, needles ...string) bool {
155193
for _, needle := range needles {
156194
if !strings.Contains(value, needle) {

internal/plaidlogin/errors.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ package plaidlogin
33
import "fmt"
44

55
const (
6-
ErrorBaseConfigMissing = "BASE_CONFIG_MISSING"
7-
ErrorNotLoggedIn = "NOT_LOGGED_IN"
8-
ErrorTeamSelectionRequired = "TEAM_SELECTION_REQUIRED"
9-
ErrorAPIKeysFetchRequired = "API_KEYS_FETCH_REQUIRED"
10-
ErrorDashboardTokenRefreshFailed = "DASHBOARD_TOKEN_REFRESH_FAILED"
11-
ErrorDashboardContractChanged = "DASHBOARD_CONTRACT_CHANGED"
12-
ErrorPlaidDashboardLoginRejected = "PLAID_DASHBOARD_LOGIN_REJECTED"
13-
ErrorPlaidEnvironmentNotProvisioned = "PLAID_ENVIRONMENT_NOT_PROVISIONED"
14-
ErrorReadOnlyViolation = "READ_ONLY_VIOLATION"
15-
ErrorInvalidEnum = "INVALID_ENUM"
6+
ErrorBaseConfigMissing = "BASE_CONFIG_MISSING"
7+
ErrorNotLoggedIn = "NOT_LOGGED_IN"
8+
ErrorTeamSelectionRequired = "TEAM_SELECTION_REQUIRED"
9+
ErrorAPIKeysFetchRequired = "API_KEYS_FETCH_REQUIRED"
10+
ErrorDashboardTokenRefreshFailed = "DASHBOARD_TOKEN_REFRESH_FAILED"
11+
ErrorDashboardContractChanged = "DASHBOARD_CONTRACT_CHANGED"
12+
ErrorPlaidDashboardLoginRejected = "PLAID_DASHBOARD_LOGIN_REJECTED"
13+
ErrorPlaidCredentialsOverwriteRequired = "PLAID_CREDENTIALS_OVERWRITE_REQUIRED"
14+
ErrorPlaidEnvironmentNotProvisioned = "PLAID_ENVIRONMENT_NOT_PROVISIONED"
15+
ErrorReadOnlyViolation = "READ_ONLY_VIOLATION"
16+
ErrorInvalidEnum = "INVALID_ENUM"
1617
)
1718

1819
type Error struct {

internal/plaidlogin/login.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func RunLogin(ctx context.Context, opts LoginOptions) (LoginResult, error) {
9191

9292
keysWritten := 0
9393
credentialAction := "written"
94-
loaded, loadErr := config.Load(config.Options{ConfigPath: meta.ConfigPath, Profile: opts.Profile})
94+
currentPlaid, loadErr := config.LoadProviderConfig(config.Options{ConfigPath: meta.ConfigPath, Profile: opts.Profile}, "plaid")
9595

9696
// Determine effective force flag.
9797
// When loadErr is a MissingEnvError for plaid fields, credentials are
@@ -109,9 +109,21 @@ func RunLogin(ctx context.Context, opts LoginOptions) (LoginResult, error) {
109109
}
110110
}
111111

112-
if loadErr == nil && loaded.Providers["plaid"].Fields["client_id"] != "" && loaded.Providers["plaid"].Fields["client_id"] == keys.ClientID && loaded.Providers["plaid"].Fields["secret"] != "" && loaded.Providers["plaid"].Fields["environment"] == environment && !opts.Force {
112+
currentClientID := currentPlaid.Fields["client_id"]
113+
currentSecret := currentPlaid.Fields["secret"]
114+
currentEnvironment := currentPlaid.Fields["environment"]
115+
if !opts.Force && currentClientID != "" && currentSecret != "" && currentClientID != keys.ClientID {
116+
return LoginResult{}, Error{
117+
Code: ErrorPlaidCredentialsOverwriteRequired,
118+
Message: "Plaid credentials already exist for a different team; rerun with --force to overwrite them",
119+
}
120+
}
121+
if currentClientID != "" && currentSecret != "" && currentClientID == keys.ClientID && currentSecret == secret && currentEnvironment == environment && !opts.Force {
113122
credentialAction = "preserved_existing"
114123
} else {
124+
if currentClientID != "" || currentSecret != "" {
125+
effectiveForce = true
126+
}
115127
configResult, err := config.ConfigureProvider(meta.ConfigPath, opts.Profile, config.PlaidSpec, map[string]string{
116128
"client_id": keys.ClientID,
117129
"secret": secret,

internal/plaidlogin/login_test.go

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package plaidlogin
33
import (
44
"context"
55
"encoding/base64"
6+
"errors"
67
"net/http"
78
"net/http/httptest"
89
"os"
@@ -150,6 +151,45 @@ providers:
150151
}
151152
}
152153

154+
func TestRunLoginOverwritesRotatedSecretForSameTeamAndEnvironment(t *testing.T) {
155+
configPath, envPath := writeLoginConfig(t, `
156+
providers:
157+
plaid:
158+
client_id:
159+
env: PLAID_CLIENT_ID
160+
secret:
161+
env: PLAID_SECRET
162+
environment: sandbox
163+
`)
164+
key := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
165+
if err := os.WriteFile(envPath, []byte("MONEY_DB_ENCRYPTION_KEY="+key+"\nPLAID_CLIENT_ID=existing-client\nPLAID_SECRET=existing-secret\n"), 0o600); err != nil {
166+
t.Fatal(err)
167+
}
168+
server := loginFakeDashboard(t, "existing-client", "rotated-secret")
169+
defer server.Close()
170+
result, err := RunLogin(context.Background(), LoginOptions{
171+
ConfigPath: configPath,
172+
Environment: "sandbox",
173+
CallbackCode: "auth-code",
174+
RedirectPort: 49152,
175+
CodeVerifier: "verifier",
176+
State: "state",
177+
HTTPClient: server.Client(),
178+
TokenURL: server.URL + "/oauth/token",
179+
DashboardURL: server.URL,
180+
})
181+
if err != nil {
182+
t.Fatalf("RunLogin: %v", err)
183+
}
184+
if result.CredentialAction != "written" || result.KeysWritten != 2 {
185+
t.Fatalf("result = %#v", result)
186+
}
187+
envContent, _ := os.ReadFile(envPath)
188+
if !strings.Contains(string(envContent), "PLAID_SECRET=rotated-secret") {
189+
t.Fatalf("env was not updated:\n%s", string(envContent))
190+
}
191+
}
192+
153193
func TestRunLoginRejectsMismatchedTeamCredentialsWithoutForce(t *testing.T) {
154194
configPath, envPath := writeLoginConfig(t, `
155195
providers:
@@ -180,7 +220,8 @@ providers:
180220
if err == nil {
181221
t.Fatal("expected error for mismatched team credentials without --force")
182222
}
183-
if !strings.Contains(err.Error(), "Plaid credentials could not be written") {
223+
var dashErr Error
224+
if !errors.As(err, &dashErr) || dashErr.Code != ErrorPlaidCredentialsOverwriteRequired {
184225
t.Fatalf("expected credential write error, got %v", err)
185226
}
186227
envContent, _ := os.ReadFile(envPath)
@@ -189,6 +230,46 @@ providers:
189230
}
190231
}
191232

233+
func TestRunLoginOverwritesMismatchedTeamCredentialsWithForce(t *testing.T) {
234+
configPath, envPath := writeLoginConfig(t, `
235+
providers:
236+
plaid:
237+
client_id:
238+
env: PLAID_CLIENT_ID
239+
secret:
240+
env: PLAID_SECRET
241+
environment: sandbox
242+
`)
243+
key := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
244+
if err := os.WriteFile(envPath, []byte("MONEY_DB_ENCRYPTION_KEY="+key+"\nPLAID_CLIENT_ID=team-a-client\nPLAID_SECRET=team-a-secret\n"), 0o600); err != nil {
245+
t.Fatal(err)
246+
}
247+
server := loginFakeDashboard(t, "team-b-client", "team-b-secret")
248+
defer server.Close()
249+
result, err := RunLogin(context.Background(), LoginOptions{
250+
ConfigPath: configPath,
251+
Environment: "sandbox",
252+
Force: true,
253+
CallbackCode: "auth-code",
254+
RedirectPort: 49152,
255+
CodeVerifier: "verifier",
256+
State: "state",
257+
HTTPClient: server.Client(),
258+
TokenURL: server.URL + "/oauth/token",
259+
DashboardURL: server.URL,
260+
})
261+
if err != nil {
262+
t.Fatalf("RunLogin: %v", err)
263+
}
264+
if result.CredentialAction != "written" || result.KeysWritten != 2 {
265+
t.Fatalf("result = %#v", result)
266+
}
267+
envContent, _ := os.ReadFile(envPath)
268+
if !strings.Contains(string(envContent), "PLAID_CLIENT_ID=team-b-client") || !strings.Contains(string(envContent), "PLAID_SECRET=team-b-secret") {
269+
t.Fatalf("env should be overwritten with force:\n%s", string(envContent))
270+
}
271+
}
272+
192273
func TestRunLoginPromptsForMultiTeamSelection(t *testing.T) {
193274
configPath, _ := writeLoginConfig(t, "")
194275
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -313,7 +394,7 @@ providers:
313394
TokenURL: server.URL + "/oauth/token",
314395
DashboardURL: server.URL,
315396
})
316-
if !isPlaidLoginCode(err, "CONFIG_WRITE_FAILED") {
397+
if !isPlaidLoginCode(err, ErrorPlaidCredentialsOverwriteRequired) {
317398
t.Fatalf("err = %#v", err)
318399
}
319400
}

0 commit comments

Comments
 (0)