@@ -354,3 +354,238 @@ pub fn list(cfg: &Config) -> Result<()> {
354354pub fn list ( _cfg : & Config ) -> Result < ( ) > {
355355 bail ! ( "Session listing is not available in WASM builds." )
356356}
357+
358+ #[ cfg( all( test, not( target_arch = "wasm32" ) ) ) ]
359+ mod tests {
360+ use super :: * ;
361+ use crate :: config:: { Config , OutputFormat } ;
362+
363+ /// Temporary directory that removes itself on drop. Mirrors the helper
364+ /// used in src/auth/storage.rs tests so we don't depend on an external
365+ /// tempdir crate.
366+ struct TempDir ( std:: path:: PathBuf ) ;
367+
368+ impl TempDir {
369+ fn new ( label : & str ) -> Self {
370+ let nanos = std:: time:: SystemTime :: now ( )
371+ . duration_since ( std:: time:: UNIX_EPOCH )
372+ . map ( |d| d. subsec_nanos ( ) )
373+ . unwrap_or ( 0 ) ;
374+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "pup_auth_cmd_test_{label}_{nanos}" ) ) ;
375+ std:: fs:: create_dir_all ( & dir) . unwrap ( ) ;
376+ TempDir ( dir)
377+ }
378+
379+ fn path ( & self ) -> & std:: path:: PathBuf {
380+ & self . 0
381+ }
382+ }
383+
384+ impl Drop for TempDir {
385+ fn drop ( & mut self ) {
386+ let _ = std:: fs:: remove_dir_all ( & self . 0 ) ;
387+ }
388+ }
389+
390+ fn base_config ( ) -> Config {
391+ Config {
392+ api_key : None ,
393+ app_key : None ,
394+ access_token : None ,
395+ site : "datadoghq.com" . into ( ) ,
396+ org : None ,
397+ output_format : OutputFormat :: Json ,
398+ auto_approve : false ,
399+ agent_mode : false ,
400+ read_only : false ,
401+ }
402+ }
403+
404+ // ------------------------------------------------------------------
405+ // token() — the one function with an access_token bypass that never
406+ // touches the global STORAGE singleton. Hermetic.
407+ // ------------------------------------------------------------------
408+
409+ #[ test]
410+ fn test_token_prints_access_token_from_config ( ) {
411+ let mut cfg = base_config ( ) ;
412+ cfg. access_token = Some ( "oauth-access-token-from-cfg" . into ( ) ) ;
413+ // Positive path: cfg.access_token is Some → returns Ok without
414+ // touching storage. We only assert the Result; capturing stdout
415+ // would require redirecting std::io::stdout and is not necessary
416+ // to verify the bypass branch runs.
417+ assert ! ( token( & cfg) . is_ok( ) ) ;
418+ }
419+
420+ #[ test]
421+ fn test_token_empty_string_still_bypasses_storage ( ) {
422+ // An empty-string access_token is still Some(_) — the guard uses
423+ // `if let Some(token)` and does not check for empty. Pin this
424+ // behaviour so future refactors are intentional.
425+ let mut cfg = base_config ( ) ;
426+ cfg. access_token = Some ( String :: new ( ) ) ;
427+ assert ! ( token( & cfg) . is_ok( ) ) ;
428+ }
429+
430+ // ------------------------------------------------------------------
431+ // list() — empty session registry path is hermetic: when there are
432+ // no sessions on disk, the .map() closure that calls with_storage
433+ // never runs, so the global STORAGE singleton is not touched.
434+ //
435+ // All tests below use the tokio-based lock from test_support so that
436+ // both sync and async tests in this module serialize against a single
437+ // mutex and don't race each other for PUP_CONFIG_DIR / DD_TOKEN_STORAGE.
438+ // ------------------------------------------------------------------
439+
440+ #[ tokio:: test]
441+ async fn test_list_empty_session_registry_returns_ok ( ) {
442+ let _lock = crate :: test_support:: lock_env ( ) . await ;
443+ let tmp = TempDir :: new ( "list_empty" ) ;
444+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
445+
446+ let cfg = base_config ( ) ;
447+ let result = list ( & cfg) ;
448+
449+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
450+ assert ! (
451+ result. is_ok( ) ,
452+ "list with empty session registry should be Ok"
453+ ) ;
454+ }
455+
456+ #[ tokio:: test]
457+ async fn test_list_with_saved_sessions_returns_ok ( ) {
458+ // After save_session, the sessions.json file is present but we
459+ // haven't stored any tokens. The storage backend is exercised but
460+ // returns None → list() enriches each session with "no token"
461+ // status and returns Ok. This covers the populated branch of
462+ // list() without requiring real credentials.
463+ let _lock = crate :: test_support:: lock_env ( ) . await ;
464+ let tmp = TempDir :: new ( "list_populated" ) ;
465+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
466+ std:: env:: set_var ( "DD_TOKEN_STORAGE" , "file" ) ;
467+
468+ storage:: save_session ( "datadoghq.com" , None ) . unwrap ( ) ;
469+ storage:: save_session ( "datadoghq.com" , Some ( "prod-child" ) ) . unwrap ( ) ;
470+
471+ let cfg = base_config ( ) ;
472+ let result = list ( & cfg) ;
473+
474+ std:: env:: remove_var ( "DD_TOKEN_STORAGE" ) ;
475+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
476+ assert ! (
477+ result. is_ok( ) ,
478+ "list with saved sessions should be Ok even when no tokens stored"
479+ ) ;
480+ }
481+
482+ // ------------------------------------------------------------------
483+ // status() — reads tokens via the global STORAGE singleton. We can
484+ // assert the return is Ok on the unauthenticated branch (no tokens
485+ // in whatever dir STORAGE was bound to), and cannot cleanly test
486+ // the authenticated branch from here without writing to STORAGE's
487+ // captured base_dir (which the singleton freezes at first init and
488+ // we cannot observe from outside auth/storage.rs).
489+ // ------------------------------------------------------------------
490+
491+ #[ tokio:: test]
492+ async fn test_status_returns_ok_for_unauthenticated ( ) {
493+ let _lock = crate :: test_support:: lock_env ( ) . await ;
494+ let tmp = TempDir :: new ( "status_unauth" ) ;
495+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
496+ std:: env:: set_var ( "DD_TOKEN_STORAGE" , "file" ) ;
497+
498+ let mut cfg = base_config ( ) ;
499+ cfg. site = "unauth-site.example.invalid" . into ( ) ;
500+ let result = status ( & cfg) ;
501+
502+ std:: env:: remove_var ( "DD_TOKEN_STORAGE" ) ;
503+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
504+ // status() always returns Ok; it reports authentication state
505+ // via printed JSON, not via the Result.
506+ assert ! ( result. is_ok( ) ) ;
507+ }
508+
509+ #[ tokio:: test]
510+ async fn test_status_returns_ok_with_org_label ( ) {
511+ // Same contract as above but with an org set. Covers the
512+ // org_label = " (org: ...)" branch in the unauthenticated arm.
513+ let _lock = crate :: test_support:: lock_env ( ) . await ;
514+ let tmp = TempDir :: new ( "status_org" ) ;
515+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
516+ std:: env:: set_var ( "DD_TOKEN_STORAGE" , "file" ) ;
517+
518+ let mut cfg = base_config ( ) ;
519+ cfg. site = "unauth-org-site.example.invalid" . into ( ) ;
520+ cfg. org = Some ( "test-org-label" . into ( ) ) ;
521+ let result = status ( & cfg) ;
522+
523+ std:: env:: remove_var ( "DD_TOKEN_STORAGE" ) ;
524+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
525+ assert ! ( result. is_ok( ) ) ;
526+ }
527+
528+ // ------------------------------------------------------------------
529+ // logout() — removes tokens / credentials / session entry. All three
530+ // are idempotent: deleting non-existent items returns Ok. That gives
531+ // us a clean negative-space test.
532+ // ------------------------------------------------------------------
533+
534+ #[ tokio:: test]
535+ async fn test_logout_is_idempotent_when_not_logged_in ( ) {
536+ let _lock = crate :: test_support:: lock_env ( ) . await ;
537+ let tmp = TempDir :: new ( "logout_idempotent" ) ;
538+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
539+ std:: env:: set_var ( "DD_TOKEN_STORAGE" , "file" ) ;
540+
541+ let mut cfg = base_config ( ) ;
542+ cfg. site = "logout-clean.example.invalid" . into ( ) ;
543+ let result = logout ( & cfg) . await ;
544+
545+ std:: env:: remove_var ( "DD_TOKEN_STORAGE" ) ;
546+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
547+ assert ! (
548+ result. is_ok( ) ,
549+ "logout on an un-logged-in site should be a no-op"
550+ ) ;
551+ }
552+
553+ #[ tokio:: test]
554+ async fn test_logout_with_org_removes_session_entry ( ) {
555+ // save a session, then logout should remove just that org's entry
556+ // from sessions.json. PUP_CONFIG_DIR is read fresh by the session
557+ // registry helpers (unlike the frozen STORAGE singleton), so this
558+ // assertion is hermetic.
559+ let _lock = crate :: test_support:: lock_env ( ) . await ;
560+ let tmp = TempDir :: new ( "logout_session" ) ;
561+ std:: env:: set_var ( "PUP_CONFIG_DIR" , tmp. path ( ) ) ;
562+ std:: env:: set_var ( "DD_TOKEN_STORAGE" , "file" ) ;
563+
564+ let site = "logout-session.example.invalid" ;
565+ storage:: save_session ( site, None ) . unwrap ( ) ;
566+ storage:: save_session ( site, Some ( "keep-me" ) ) . unwrap ( ) ;
567+
568+ let mut cfg = base_config ( ) ;
569+ cfg. site = site. into ( ) ;
570+ cfg. org = Some ( "keep-me" . into ( ) ) ;
571+ let result = logout ( & cfg) . await ;
572+
573+ let remaining = storage:: list_sessions ( ) . unwrap ( ) ;
574+ std:: env:: remove_var ( "DD_TOKEN_STORAGE" ) ;
575+ std:: env:: remove_var ( "PUP_CONFIG_DIR" ) ;
576+
577+ assert ! ( result. is_ok( ) ) ;
578+ // The "keep-me" org entry was removed; the default-org entry for
579+ // the same site survives.
580+ assert ! (
581+ remaining. iter( ) . any( |s| s. site == site && s. org. is_none( ) ) ,
582+ "default-org session for site should remain after logging out of a different org"
583+ ) ;
584+ assert ! (
585+ !remaining
586+ . iter( )
587+ . any( |s| s. site == site && s. org. as_deref( ) == Some ( "keep-me" ) ) ,
588+ "logged-out org session should be removed"
589+ ) ;
590+ }
591+ }
0 commit comments