@@ -65,6 +65,100 @@ func TestFindGmailSource(t *testing.T) {
6565 }
6666}
6767
68+ // TestAddAccount_InheritedBindingValidatesToken verifies that re-running
69+ // add-account without --oauth-app on a named-app account validates the
70+ // token's client_id against the inherited binding.
71+ func TestAddAccount_InheritedBindingValidatesToken (t * testing.T ) {
72+ for _ , tc := range []struct {
73+ name string
74+ clientID string
75+ wantError bool
76+ }{
77+ {"matching token reused" , "test.apps.googleusercontent.com" , false },
78+ {"mismatched token rejected" , "wrong.apps.googleusercontent.com" , true },
79+ } {
80+ t .Run (tc .name , func (t * testing.T ) {
81+ tmpDir := t .TempDir ()
82+ dbPath := filepath .Join (tmpDir , "msgvault.db" )
83+
84+ s , err := store .Open (dbPath )
85+ if err != nil {
86+ t .Fatalf ("open store: %v" , err )
87+ }
88+ if err := s .InitSchema (); err != nil {
89+ t .Fatalf ("init schema: %v" , err )
90+ }
91+ source , err := s .GetOrCreateSource ("gmail" , "user@acme.com" )
92+ if err != nil {
93+ t .Fatalf ("create source: %v" , err )
94+ }
95+ err = s .UpdateSourceOAuthApp (source .ID , sql.NullString {String : "acme" , Valid : true })
96+ if err != nil {
97+ t .Fatalf ("set oauth_app: %v" , err )
98+ }
99+ _ = s .Close ()
100+
101+ tokensDir := filepath .Join (tmpDir , "tokens" )
102+ if err := os .MkdirAll (tokensDir , 0700 ); err != nil {
103+ t .Fatalf ("mkdir: %v" , err )
104+ }
105+ tokenData , _ := json .Marshal (map [string ]string {
106+ "access_token" : "fake" ,
107+ "refresh_token" : "fake" ,
108+ "token_type" : "Bearer" ,
109+ "client_id" : tc .clientID ,
110+ })
111+ if err := os .WriteFile (filepath .Join (tokensDir , "user@acme.com.json" ), tokenData , 0600 ); err != nil {
112+ t .Fatalf ("write token: %v" , err )
113+ }
114+
115+ secretsPath := filepath .Join (tmpDir , "secret.json" )
116+ if err := os .WriteFile (secretsPath , []byte (fakeClientSecrets ), 0600 ); err != nil {
117+ t .Fatalf ("write secrets: %v" , err )
118+ }
119+
120+ savedCfg , savedLogger , savedOAuthApp := cfg , logger , oauthAppName
121+ defer func () { cfg , logger , oauthAppName = savedCfg , savedLogger , savedOAuthApp }()
122+
123+ cfg = & config.Config {
124+ HomeDir : tmpDir ,
125+ Data : config.DataConfig {DataDir : tmpDir },
126+ OAuth : config.OAuthConfig {
127+ Apps : map [string ]config.OAuthApp {
128+ "acme" : {ClientSecrets : secretsPath },
129+ },
130+ },
131+ }
132+ logger = slog .New (slog .NewTextHandler (os .Stderr , nil ))
133+
134+ ctx , cancel := context .WithCancel (context .Background ())
135+ cancel ()
136+
137+ testCmd := & cobra.Command {
138+ Use : "add-account <email>" , Args : cobra .ExactArgs (1 ),
139+ RunE : addAccountCmd .RunE ,
140+ }
141+ testCmd .Flags ().StringVar (& oauthAppName , "oauth-app" , "" , "" )
142+ testCmd .Flags ().BoolVar (& headless , "headless" , false , "" )
143+ testCmd .Flags ().BoolVar (& forceReauth , "force" , false , "" )
144+ testCmd .Flags ().StringVar (& accountDisplayName , "display-name" , "" , "" )
145+
146+ root := newTestRootCmd ()
147+ root .AddCommand (testCmd )
148+ // No --oauth-app flag: binding inherited from DB
149+ root .SetArgs ([]string {"add-account" , "user@acme.com" })
150+
151+ err = root .ExecuteContext (ctx )
152+ if tc .wantError && err == nil {
153+ t .Fatal ("expected error for mismatched token" )
154+ }
155+ if ! tc .wantError && err != nil {
156+ t .Fatalf ("unexpected error: %v" , err )
157+ }
158+ })
159+ }
160+ }
161+
68162// TestAddAccount_RebindWithExistingToken verifies that switching
69163// OAuth app binding with an existing token updates the binding
70164// without re-authorizing (headless rebind scenario).
0 commit comments