@@ -3,13 +3,16 @@ package cmd
33import (
44 "bytes"
55 "errors"
6+ "io"
67 "net/http"
78 "net/http/httptest"
9+ "os"
810 "strings"
911 "testing"
1012
1113 "github.com/chatwoot/cli/internal/config"
1214 "github.com/chatwoot/cli/internal/output"
15+ "github.com/chatwoot/cli/internal/sdk"
1316 "github.com/zalando/go-keyring"
1417)
1518
@@ -125,6 +128,33 @@ func TestLoginSuccessMessageStripsTerminalControls(t *testing.T) {
125128 }
126129}
127130
131+ func TestVerifyAccountAccess (t * testing.T ) {
132+ accounts := []sdk.ProfileAccount {
133+ {ID : 7 , Name : "Acme" , Role : "administrator" },
134+ {ID : 9 , Name : "Beta" , Role : "agent" },
135+ }
136+
137+ if err := verifyAccountAccess (& sdk.ProfileResponse {Accounts : accounts }, 9 ); err != nil {
138+ t .Fatalf ("expected access to a member account, got error: %v" , err )
139+ }
140+
141+ err := verifyAccountAccess (& sdk.ProfileResponse {Accounts : accounts }, 42 )
142+ if err == nil {
143+ t .Fatal ("expected error for non-member account, got nil" )
144+ }
145+ // The message should name the accessible accounts so the user can correct the ID.
146+ for _ , want := range []string {"42" , "7 (Acme)" , "9 (Beta)" } {
147+ if ! strings .Contains (err .Error (), want ) {
148+ t .Fatalf ("error %q missing %q" , err .Error (), want )
149+ }
150+ }
151+
152+ // No accounts in payload (older instances) → skip rather than block login.
153+ if err := verifyAccountAccess (& sdk.ProfileResponse {}, 42 ); err != nil {
154+ t .Fatalf ("expected skip when no accounts present, got error: %v" , err )
155+ }
156+ }
157+
128158func TestMeAndWhoamiAliasAuthStatus (t * testing.T ) {
129159 profile := `{
130160 "id": 7,
@@ -167,22 +197,23 @@ func TestAuthStatusNotLoggedIn(t *testing.T) {
167197
168198func TestAuthLogoutRemovesKeyringTokenWithoutConfig (t * testing.T ) {
169199 keyring .MockInit ()
170- if err := keyring .DeleteAll ("chatwoot-cli" ); err != nil {
171- t .Fatalf ("keyring.DeleteAll: %v" , err )
172- }
173200 t .Setenv ("HOME" , t .TempDir ())
174201 t .Setenv (config .APIKeyEnv , "" )
175202
176- if err := keyring .Set ("chatwoot-cli" , "api-key" , "stale-token" ); err != nil {
177- t .Fatalf ("keyring.Set: %v" , err )
203+ // Seed the token through the production path so it lands under whichever
204+ // keyring service the active build profile uses (prod vs dev), without
205+ // writing config.yaml — this exercises logout with no config present.
206+ seed := & config.Config {BaseURL : "https://app.chatwoot.com" , AccountID : 1 }
207+ if err := config .SaveAPIKey (seed , "stale-token" ); err != nil {
208+ t .Fatalf ("SaveAPIKey: %v" , err )
178209 }
179210
180211 if err := (& AuthLogoutCmd {}).Run (& App {}); err != nil {
181212 t .Fatalf ("Run: %v" , err )
182213 }
183214
184- if _ , err := keyring . Get ( "chatwoot-cli" , "api-key" ); ! errors .Is (err , keyring . ErrNotFound ) {
185- t .Fatalf ("expected logout to delete stale keyring token, err = %v" , err )
215+ if _ , _ , err := config . ResolveAPIKey ( seed ); ! errors .Is (err , config . ErrAPIKeyNotFound ) {
216+ t .Fatalf ("expected logout to delete the keyring token, err = %v" , err )
186217 }
187218}
188219
@@ -244,3 +275,108 @@ func TestAuthStatusDoesNotCacheUserIDFromEnvironmentToken(t *testing.T) {
244275 t .Fatalf ("expected env-token auth status to preserve cached UserID=42, got %d" , post .UserID )
245276 }
246277}
278+
279+ // TestAuthLoginVerifiesAccountAccess drives the full `auth login` flow (stdin →
280+ // profile fetch → membership check → persist) to cover the wiring, not just the
281+ // verifyAccountAccess helper.
282+ func TestAuthLoginVerifiesAccountAccess (t * testing.T ) {
283+ profileBody := `{"id":5,"name":"Eve","email":"eve@example.com","availability_status":"online","role":"agent",` +
284+ `"accounts":[{"id":7,"name":"Acme","role":"administrator"},{"id":9,"name":"Beta","role":"agent"}]}`
285+
286+ t .Run ("rejects an account the token cannot access" , func (t * testing.T ) {
287+ server := loginProfileServer (t , profileBody )
288+ defer server .Close ()
289+ isolateAuthEnv (t )
290+
291+ err := runLogin (t , server .URL + "\n token\n 42\n " )
292+ if err == nil {
293+ t .Fatal ("expected login to fail for an inaccessible account" )
294+ }
295+ for _ , want := range []string {"42" , "Acme" , "Beta" } {
296+ if ! strings .Contains (err .Error (), want ) {
297+ t .Fatalf ("error %q should name entered + accessible accounts" , err .Error ())
298+ }
299+ }
300+ // Nothing must be persisted when login is rejected.
301+ if cfg , _ := config .Load (); cfg != nil {
302+ t .Fatalf ("config was saved despite a rejected login: %#v" , cfg )
303+ }
304+ })
305+
306+ t .Run ("accepts a member account and persists config + key" , func (t * testing.T ) {
307+ server := loginProfileServer (t , profileBody )
308+ defer server .Close ()
309+ isolateAuthEnv (t )
310+
311+ if err := runLogin (t , server .URL + "\n token\n 7\n " ); err != nil {
312+ t .Fatalf ("login: %v" , err )
313+ }
314+
315+ cfg , err := config .Load ()
316+ if err != nil || cfg == nil {
317+ t .Fatalf ("config not saved: cfg=%#v err=%v" , cfg , err )
318+ }
319+ if cfg .AccountID != 7 || cfg .UserID != 5 {
320+ t .Fatalf ("saved cfg = %#v, want AccountID 7, UserID 5" , cfg )
321+ }
322+ apiKey , source , err := config .ResolveAPIKey (cfg )
323+ if err != nil || apiKey != "token" || source != config .CredentialSourceKeyring {
324+ t .Fatalf ("ResolveAPIKey = (%q, %v, %v), want token/keyring" , apiKey , source , err )
325+ }
326+ })
327+ }
328+
329+ // isolateAuthEnv gives a test its own HOME + mocked keyring and clears the
330+ // CHATWOOT_API_KEY override so credential resolution exercises the keyring path.
331+ func isolateAuthEnv (t * testing.T ) {
332+ t .Helper ()
333+ keyring .MockInit ()
334+ if err := keyring .DeleteAll ("chatwoot-cli" ); err != nil {
335+ t .Fatalf ("keyring.DeleteAll: %v" , err )
336+ }
337+ t .Setenv ("HOME" , t .TempDir ())
338+ t .Setenv (config .APIKeyEnv , "" )
339+ }
340+
341+ func loginProfileServer (t * testing.T , body string ) * httptest.Server {
342+ t .Helper ()
343+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
344+ if r .URL .Path != "/api/v1/profile" {
345+ http .Error (w , "not found" , http .StatusNotFound )
346+ return
347+ }
348+ w .Header ().Set ("Content-Type" , "application/json" )
349+ _ , _ = w .Write ([]byte (body ))
350+ }))
351+ }
352+
353+ // runLogin feeds scripted answers to the interactive login prompts via os.Stdin
354+ // and silences the prompt/banner output, returning the command's error.
355+ func runLogin (t * testing.T , stdin string ) error {
356+ t .Helper ()
357+
358+ r , w , err := os .Pipe ()
359+ if err != nil {
360+ t .Fatalf ("os.Pipe: %v" , err )
361+ }
362+ if _ , err := io .WriteString (w , stdin ); err != nil {
363+ t .Fatalf ("write stdin: %v" , err )
364+ }
365+ _ = w .Close ()
366+
367+ devnull , err := os .OpenFile (os .DevNull , os .O_WRONLY , 0 )
368+ if err != nil {
369+ t .Fatalf ("open devnull: %v" , err )
370+ }
371+
372+ oldStdin , oldStdout := os .Stdin , os .Stdout
373+ os .Stdin , os .Stdout = r , devnull
374+ defer func () {
375+ os .Stdin , os .Stdout = oldStdin , oldStdout
376+ _ = r .Close ()
377+ _ = devnull .Close ()
378+ }()
379+
380+ printer := output .NewPrinter ("text" , false , false )
381+ return (& AuthLoginCmd {}).Run (& App {Printer : printer })
382+ }
0 commit comments