@@ -27,6 +27,7 @@ struct Credentials {
2727 email : Option < String > ,
2828}
2929
30+ #[ derive( Debug , PartialEq , Eq ) ]
3031pub enum AuthSource {
3132 Environment ,
3233 File ,
@@ -118,26 +119,44 @@ impl AuthManager {
118119 }
119120
120121 pub fn get_auth_info ( & self ) -> AuthInfo {
121- // Check environment first
122- if let Ok ( api_key) = std:: env:: var ( "WAVEDASH_TOKEN" ) {
123- if !api_key. is_empty ( ) {
122+ Self :: resolve_auth (
123+ std:: env:: var ( config:: ENV_TOKEN ) . ok ( ) ,
124+ self . read_file_credentials ( ) ,
125+ )
126+ }
127+
128+ /// Precedence and blank handling, split out from [`Self::get_auth_info`] so
129+ /// it's testable without mutating the process environment or reading the
130+ /// real credentials file.
131+ ///
132+ /// Blank counts as unset, the same rule every other `WAVEDASH_*` variable
133+ /// follows: an unpopulated CI secret falls back to stored credentials rather
134+ /// than being sent as a bare `Bearer `, which returns a 401 telling the user
135+ /// to run an interactive login they can't run. Trimming matters just as
136+ /// much — a `WAVEDASH_TOKEN=$(cat key)` trailing newline is not a valid
137+ /// header value, and reqwest rejects it as "failed to parse header value"
138+ /// with nothing to say it was the token.
139+ fn resolve_auth ( env_token : Option < String > , file : Option < Credentials > ) -> AuthInfo {
140+ if let Some ( api_key) = env_token. and_then ( config:: non_blank) {
141+ return AuthInfo {
142+ source : AuthSource :: Environment ,
143+ api_key : Some ( api_key) ,
144+ email : None , // No email available from env var
145+ } ;
146+ }
147+
148+ // A blank key on disk is a corrupt or half-written credentials file, and
149+ // is no more usable than a blank variable.
150+ if let Some ( creds) = file {
151+ if let Some ( api_key) = config:: non_blank ( creds. api_key ) {
124152 return AuthInfo {
125- source : AuthSource :: Environment ,
153+ source : AuthSource :: File ,
126154 api_key : Some ( api_key) ,
127- email : None , // No email available from env var
155+ email : creds . email ,
128156 } ;
129157 }
130158 }
131159
132- // Check file
133- if let Some ( creds) = self . read_file_credentials ( ) {
134- return AuthInfo {
135- source : AuthSource :: File ,
136- api_key : Some ( creds. api_key ) ,
137- email : creds. email ,
138- } ;
139- }
140-
141160 AuthInfo {
142161 source : AuthSource :: None ,
143162 api_key : None ,
@@ -496,6 +515,63 @@ pub async fn login_with_browser() -> Result<LoginResult> {
496515mod tests {
497516 use super :: * ;
498517
518+ fn stored ( api_key : & str ) -> Option < Credentials > {
519+ Some ( Credentials {
520+ api_key : api_key. to_string ( ) ,
521+ email : Some ( "dev@wavedash.com" . to_string ( ) ) ,
522+ } )
523+ }
524+
525+ #[ test]
526+ fn env_token_wins_over_stored_credentials ( ) {
527+ let info = AuthManager :: resolve_auth ( Some ( "from_env" . into ( ) ) , stored ( "from_file" ) ) ;
528+
529+ assert_eq ! ( info. source, AuthSource :: Environment ) ;
530+ assert_eq ! ( info. api_key. as_deref( ) , Some ( "from_env" ) ) ;
531+ }
532+
533+ /// The CI shape: an unpopulated secret expands to "", which must not shadow
534+ /// the stored credentials or be sent as a bare `Bearer `.
535+ #[ test]
536+ fn a_blank_env_token_falls_back_to_stored_credentials ( ) {
537+ for blank in [ "" , " " , "\t " , "\n " ] {
538+ let info = AuthManager :: resolve_auth ( Some ( blank. into ( ) ) , stored ( "from_file" ) ) ;
539+
540+ assert_eq ! ( info. source, AuthSource :: File , "blank: {:?}" , blank) ;
541+ assert_eq ! ( info. api_key. as_deref( ) , Some ( "from_file" ) ) ;
542+ }
543+ }
544+
545+ #[ test]
546+ fn a_blank_env_token_with_nothing_stored_is_unauthenticated ( ) {
547+ let info = AuthManager :: resolve_auth ( Some ( " " . into ( ) ) , None ) ;
548+
549+ assert_eq ! ( info. source, AuthSource :: None ) ;
550+ assert ! ( info. api_key. is_none( ) ) ;
551+ }
552+
553+ /// `WAVEDASH_TOKEN=$(cat key.txt)` keeps the trailing newline, which is not a
554+ /// legal header value — trim it here rather than fail opaquely at send time.
555+ #[ test]
556+ fn an_env_token_is_trimmed_so_it_survives_becoming_a_header ( ) {
557+ let info = AuthManager :: resolve_auth ( Some ( " wdcli_abc123\n " . into ( ) ) , None ) ;
558+
559+ assert_eq ! ( info. api_key. as_deref( ) , Some ( "wdcli_abc123" ) ) ;
560+ assert ! ( reqwest:: header:: HeaderValue :: from_str( & format!(
561+ "Bearer {}" ,
562+ info. api_key. unwrap( )
563+ ) )
564+ . is_ok( ) ) ;
565+ }
566+
567+ #[ test]
568+ fn a_blank_stored_key_is_unauthenticated ( ) {
569+ let info = AuthManager :: resolve_auth ( None , stored ( " " ) ) ;
570+
571+ assert_eq ! ( info. source, AuthSource :: None ) ;
572+ assert ! ( info. api_key. is_none( ) ) ;
573+ }
574+
499575 #[ test]
500576 fn derives_rfc7636_s256_pkce_challenge ( ) {
501577 let challenge = pkce_challenge_from_verifier ( "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" ) ;
0 commit comments