1package config
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "testing"
9 "time"
10
11 "github.com/zalando/go-keyring"
12)
13
14// TestSaveAndLoadConfig verifies that the config can be saved to and loaded from a file correctly.
15func TestSaveAndLoadConfig(t *testing.T) {
16 // Use an in-memory mock keyring so tests do not interact with the host OS keyring
17 keyring.MockInit()
18
19 // Create a temporary directory for the test to avoid interfering with actual user config.
20 tempDir := t.TempDir()
21
22 // Temporarily override the user home directory to our temp directory.
23 // This ensures that our config file is written to a predictable, temporary location.
24 t.Setenv("HOME", tempDir)
25
26 // Define a sample configuration to save with multiple accounts.
27 expectedConfig := &Config{
28 Accounts: []Account{
29 {
30 ID: "test-id-1",
31 Name: "Test User",
32 Email: "test@example.com",
33 Password: "supersecret",
34 ServiceProvider: "gmail",
35 SendAsEmail: "alias@example.com",
36 SC: &SessionCache{},
37 },
38 {
39 ID: "test-id-2",
40 Name: "Custom User",
41 Email: "custom@example.com",
42 Password: "customsecret",
43 ServiceProvider: "custom",
44 IMAPServer: "imap.custom.com",
45 IMAPPort: 993,
46 SMTPServer: "smtp.custom.com",
47 SMTPPort: 587,
48 CatchAll: true,
49 SC: &SessionCache{},
50 },
51 },
52 }
53
54 // Attempt to save the configuration.
55 err := SaveConfig(expectedConfig)
56 if err != nil {
57 t.Fatalf("SaveConfig() failed: %v", err)
58 }
59
60 // Attempt to load the configuration back.
61 loadedConfig, err := LoadConfig()
62 if err != nil {
63 t.Fatalf("LoadConfig() failed: %v", err)
64 }
65
66 // Compare the loaded configuration with the original one.
67 // reflect.DeepEqual is used for a deep comparison of the structs.
68 if !reflect.DeepEqual(loadedConfig, expectedConfig) {
69 t.Errorf("Loaded config does not match expected config.\nGot: %+v\nWant: %+v", loadedConfig, expectedConfig)
70 }
71}
72
73// TestAccountGetIMAPServer tests the logic that determines the IMAP server address.
74func TestAccountGetIMAPServer(t *testing.T) {
75 testCases := []struct {
76 name string
77 account Account
78 want string
79 }{
80 {"Gmail", Account{ServiceProvider: "gmail"}, "imap.gmail.com"},
81 {"iCloud", Account{ServiceProvider: "icloud"}, "imap.mail.me.com"},
82 {"Custom", Account{ServiceProvider: "custom", IMAPServer: "imap.custom.com"}, "imap.custom.com"},
83 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
84 {"Empty", Account{ServiceProvider: ""}, ""},
85 }
86
87 for _, tc := range testCases {
88 t.Run(tc.name, func(t *testing.T) {
89 got := tc.account.GetIMAPServer()
90 if got != tc.want {
91 t.Errorf("GetIMAPServer() = %q, want %q", got, tc.want)
92 }
93 })
94 }
95}
96
97// TestAccountGetSMTPServer tests the logic that determines the SMTP server address.
98func TestAccountGetSMTPServer(t *testing.T) {
99 testCases := []struct {
100 name string
101 account Account
102 want string
103 }{
104 {"Gmail", Account{ServiceProvider: "gmail"}, "smtp.gmail.com"},
105 {"iCloud", Account{ServiceProvider: "icloud"}, "smtp.mail.me.com"},
106 {"Custom", Account{ServiceProvider: "custom", SMTPServer: "smtp.custom.com"}, "smtp.custom.com"},
107 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
108 {"Empty", Account{ServiceProvider: ""}, ""},
109 }
110
111 for _, tc := range testCases {
112 t.Run(tc.name, func(t *testing.T) {
113 got := tc.account.GetSMTPServer()
114 if got != tc.want {
115 t.Errorf("GetSMTPServer() = %q, want %q", got, tc.want)
116 }
117 })
118 }
119}
120
121// TestConfigAddRemoveAccount tests adding and removing accounts from config.
122func TestConfigAddRemoveAccount(t *testing.T) {
123 // Use an in-memory mock keyring to test the deletion step cleanly
124 keyring.MockInit()
125
126 cfg := &Config{}
127
128 // Add an account
129 account := Account{
130 Name: "Test",
131 Email: "test@example.com",
132 ServiceProvider: "gmail",
133 }
134 cfg.AddAccount(account)
135
136 if len(cfg.Accounts) != 1 {
137 t.Fatalf("Expected 1 account, got %d", len(cfg.Accounts))
138 }
139
140 // Check that ID was auto-generated
141 if cfg.Accounts[0].ID == "" {
142 t.Error("Expected account ID to be auto-generated")
143 }
144
145 // Remove the account
146 accountID := cfg.Accounts[0].ID
147 removed := cfg.RemoveAccount(accountID)
148 if !removed {
149 t.Error("RemoveAccount should return true when account exists")
150 }
151
152 if len(cfg.Accounts) != 0 {
153 t.Fatalf("Expected 0 accounts after removal, got %d", len(cfg.Accounts))
154 }
155
156 // Try to remove non-existent account
157 removed = cfg.RemoveAccount("non-existent")
158 if removed {
159 t.Error("RemoveAccount should return false for non-existent account")
160 }
161}
162
163// TestConfigGetAccountByID tests retrieving accounts by ID.
164func TestConfigGetAccountByID(t *testing.T) {
165 cfg := &Config{
166 Accounts: []Account{
167 {ID: "id-1", Email: "test1@example.com"},
168 {ID: "id-2", Email: "test2@example.com"},
169 },
170 }
171
172 account := cfg.GetAccountByID("id-1")
173 if account == nil {
174 t.Fatal("Expected to find account with id-1")
175 }
176 if account.Email != "test1@example.com" {
177 t.Errorf("Expected email test1@example.com, got %s", account.Email)
178 }
179
180 // Non-existent ID
181 account = cfg.GetAccountByID("non-existent")
182 if account != nil {
183 t.Error("Expected nil for non-existent account ID")
184 }
185}
186
187// TestConfigGetAccountByEmail tests retrieving accounts by email.
188func TestConfigGetAccountByEmail(t *testing.T) {
189 cfg := &Config{
190 Accounts: []Account{
191 {ID: "id-1", Email: "test1@example.com"},
192 {ID: "id-2", Email: "test2@example.com"},
193 },
194 }
195
196 account := cfg.GetAccountByEmail("test2@example.com")
197 if account == nil {
198 t.Fatal("Expected to find account with test2@example.com")
199 }
200 if account.ID != "id-2" {
201 t.Errorf("Expected ID id-2, got %s", account.ID)
202 }
203
204 // Non-existent email
205 account = cfg.GetAccountByEmail("nonexistent@example.com")
206 if account != nil {
207 t.Error("Expected nil for non-existent account email")
208 }
209}
210
211func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) {
212 t.Setenv("HOME", t.TempDir())
213
214 if err := AddContactForAccount("Alice", "Alice@Example.com", "account-1"); err != nil {
215 t.Fatalf("AddContactForAccount() failed: %v", err)
216 }
217 if err := AddContactForAccount("", "alice@example.com", "account-1"); err != nil {
218 t.Fatalf("AddContactForAccount() failed: %v", err)
219 }
220
221 cache, err := LoadContactsCache()
222 if err != nil {
223 t.Fatalf("LoadContactsCache() failed: %v", err)
224 }
225
226 if len(cache.Contacts) != 1 {
227 t.Fatalf("Expected 1 contact after deduplication, got %d", len(cache.Contacts))
228 }
229
230 contact := cache.Contacts[0]
231 if contact.Email != "alice@example.com" {
232 t.Errorf("Expected normalized email alice@example.com, got %s", contact.Email)
233 }
234 usage := contact.Usage["account-1"]
235 if usage.UseCount != 2 {
236 t.Errorf("Expected UseCount 2 after duplicate add, got %d", usage.UseCount)
237 }
238}
239
240func TestMigrateContactsCacheUsageExpandsLegacyUsage(t *testing.T) {
241 t.Setenv("HOME", t.TempDir())
242
243 lastUsed := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
244 path, err := GetContactsCachePath()
245 if err != nil {
246 t.Fatalf("GetContactsCachePath() failed: %v", err)
247 }
248 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
249 t.Fatalf("MkdirAll() failed: %v", err)
250 }
251 legacyJSON := `{"contacts":[{"name":"Alice","email":"alice@example.com","last_used":"` + lastUsed.Format(time.RFC3339) + `","use_count":7}]}`
252 if err := os.WriteFile(path, []byte(legacyJSON), 0600); err != nil {
253 t.Fatalf("WriteFile() failed: %v", err)
254 }
255
256 if err := MigrateContactsCacheUsage([]string{"account-1", "account-2"}); err != nil {
257 t.Fatalf("MigrateContactsCacheUsage() failed: %v", err)
258 }
259
260 cache, err := LoadContactsCache()
261 if err != nil {
262 t.Fatalf("LoadContactsCache() failed: %v", err)
263 }
264 if len(cache.Contacts) != 1 {
265 t.Fatalf("Expected 1 contact, got %d", len(cache.Contacts))
266 }
267 for _, accountID := range []string{"account-1", "account-2"} {
268 usage, ok := cache.Contacts[0].Usage[accountID]
269 if !ok {
270 t.Fatalf("Expected usage for %s", accountID)
271 }
272 if usage.UseCount != 7 || !usage.LastUsed.Equal(lastUsed) {
273 t.Fatalf("Unexpected usage for %s: %+v", accountID, usage)
274 }
275 }
276 if _, ok := cache.Contacts[0].Usage[legacyContactUsageKey]; ok {
277 t.Fatal("Legacy usage key should be removed after migration")
278 }
279}
280
281func TestSearchContactsForAccountFiltersAndSortsByUsage(t *testing.T) {
282 t.Setenv("HOME", t.TempDir())
283
284 now := time.Now()
285 cache := &ContactsCache{Contacts: []Contact{
286 {
287 Name: "Alice",
288 Email: "alice@example.com",
289 Usage: map[string]ContactUsage{
290 "account-1": {UseCount: 1, LastUsed: now},
291 },
292 },
293 {
294 Name: "Alicia",
295 Email: "alicia@example.com",
296 Usage: map[string]ContactUsage{
297 "account-2": {UseCount: 9, LastUsed: now.Add(time.Hour)},
298 },
299 },
300 {
301 Name: "Alina",
302 Email: "alina@example.com",
303 Usage: map[string]ContactUsage{
304 "account-1": {UseCount: 3, LastUsed: now.Add(-time.Hour)},
305 },
306 },
307 }}
308 if err := SaveContactsCache(cache); err != nil {
309 t.Fatalf("SaveContactsCache() failed: %v", err)
310 }
311
312 matches := SearchContactsForAccount("ali", "account-1")
313 if len(matches) != 2 {
314 t.Fatalf("Expected 2 account-1 matches, got %d", len(matches))
315 }
316 if matches[0].Email != "alina@example.com" {
317 t.Fatalf("Expected highest account-1 usage first, got %s", matches[0].Email)
318 }
319}
320
321func TestCleanupAccountCacheRemovesOnlyTargetAccountData(t *testing.T) {
322 t.Setenv("HOME", t.TempDir())
323
324 now := time.Now()
325 emailFor := func(accountID string, uid uint32) CachedEmail {
326 return CachedEmail{
327 UID: uid,
328 From: accountID + "@example.com",
329 Subject: "subject",
330 Date: now,
331 AccountID: accountID,
332 }
333 }
334
335 if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{
336 emailFor("account-1", 1),
337 emailFor("account-2", 2),
338 }}); err != nil {
339 t.Fatalf("SaveEmailCache() failed: %v", err)
340 }
341 if err := SaveFolderCache(&FolderCache{Accounts: []CachedFolders{
342 {AccountID: "account-1", Folders: []string{"INBOX"}},
343 {AccountID: "account-2", Folders: []string{"INBOX", "Sent"}},
344 }}); err != nil {
345 t.Fatalf("SaveFolderCache() failed: %v", err)
346 }
347 if err := SaveFolderEmailCache("INBOX", []CachedEmail{
348 emailFor("account-1", 1),
349 emailFor("account-2", 2),
350 }); err != nil {
351 t.Fatalf("SaveFolderEmailCache(INBOX) failed: %v", err)
352 }
353 if err := SaveFolderEmailCache("OnlyDeleted", []CachedEmail{
354 emailFor("account-1", 3),
355 }); err != nil {
356 t.Fatalf("SaveFolderEmailCache(OnlyDeleted) failed: %v", err)
357 }
358 if err := SaveDraftsCache(&DraftsCache{Drafts: []Draft{
359 {ID: "draft-1", AccountID: "account-1", Subject: "delete"},
360 {ID: "draft-2", AccountID: "account-2", Subject: "keep"},
361 }}); err != nil {
362 t.Fatalf("SaveDraftsCache() failed: %v", err)
363 }
364 if err := SaveContactsCache(&ContactsCache{Contacts: []Contact{
365 {
366 Name: "Shared",
367 Email: "shared@example.com",
368 Usage: map[string]ContactUsage{
369 "account-1": {UseCount: 1, LastUsed: now},
370 "account-2": {UseCount: 2, LastUsed: now},
371 },
372 },
373 {
374 Name: "Only Deleted",
375 Email: "deleted@example.com",
376 Usage: map[string]ContactUsage{
377 "account-1": {UseCount: 1, LastUsed: now},
378 },
379 },
380 }}); err != nil {
381 t.Fatalf("SaveContactsCache() failed: %v", err)
382 }
383 if err := SaveEmailBody("INBOX", CachedEmailBody{
384 UID: 1,
385 AccountID: "account-1",
386 Body: "delete",
387 }, 1<<20); err != nil {
388 t.Fatalf("SaveEmailBody(account-1) failed: %v", err)
389 }
390 if err := SaveEmailBody("INBOX", CachedEmailBody{
391 UID: 2,
392 AccountID: "account-2",
393 Body: "keep",
394 }, 1<<20); err != nil {
395 t.Fatalf("SaveEmailBody(account-2) failed: %v", err)
396 }
397 if err := SaveEmailBody("OnlyDeleted", CachedEmailBody{
398 UID: 3,
399 AccountID: "account-1",
400 Body: "delete",
401 }, 1<<20); err != nil {
402 t.Fatalf("SaveEmailBody(OnlyDeleted) failed: %v", err)
403 }
404
405 if err := CleanupAccountCache("account-1"); err != nil {
406 t.Fatalf("CleanupAccountCache() failed: %v", err)
407 }
408
409 emailCache, err := LoadEmailCache()
410 if err != nil {
411 t.Fatalf("LoadEmailCache() failed: %v", err)
412 }
413 if len(emailCache.Emails) != 1 || emailCache.Emails[0].AccountID != "account-2" {
414 t.Fatalf("Unexpected email cache after cleanup: %+v", emailCache.Emails)
415 }
416
417 folderCache, err := LoadFolderCache()
418 if err != nil {
419 t.Fatalf("LoadFolderCache() failed: %v", err)
420 }
421 if len(folderCache.Accounts) != 1 || folderCache.Accounts[0].AccountID != "account-2" {
422 t.Fatalf("Unexpected folder cache after cleanup: %+v", folderCache.Accounts)
423 }
424
425 folderEmails, err := LoadFolderEmailCache("INBOX")
426 if err != nil {
427 t.Fatalf("LoadFolderEmailCache(INBOX) failed: %v", err)
428 }
429 if len(folderEmails) != 1 || folderEmails[0].AccountID != "account-2" {
430 t.Fatalf("Unexpected folder emails after cleanup: %+v", folderEmails)
431 }
432 onlyDeletedFolderPath, err := folderEmailCacheFile("OnlyDeleted")
433 if err != nil {
434 t.Fatalf("folderEmailCacheFile() failed: %v", err)
435 }
436 if _, err := os.Stat(onlyDeletedFolderPath); !os.IsNotExist(err) {
437 t.Fatalf("Expected folder email cache with only deleted account to be removed, stat err=%v", err)
438 }
439
440 draftsCache, err := LoadDraftsCache()
441 if err != nil {
442 t.Fatalf("LoadDraftsCache() failed: %v", err)
443 }
444 if len(draftsCache.Drafts) != 1 || draftsCache.Drafts[0].AccountID != "account-2" {
445 t.Fatalf("Unexpected drafts after cleanup: %+v", draftsCache.Drafts)
446 }
447
448 contactsCache, err := LoadContactsCache()
449 if err != nil {
450 t.Fatalf("LoadContactsCache() failed: %v", err)
451 }
452 if len(contactsCache.Contacts) != 1 || contactsCache.Contacts[0].Email != "shared@example.com" {
453 t.Fatalf("Unexpected contacts after cleanup: %+v", contactsCache.Contacts)
454 }
455 if _, ok := contactsCache.Contacts[0].Usage["account-1"]; ok {
456 t.Fatal("Deleted account usage should be removed from shared contact")
457 }
458 if _, ok := contactsCache.Contacts[0].Usage["account-2"]; !ok {
459 t.Fatal("Remaining account usage should stay on shared contact")
460 }
461
462 bodyCache, err := LoadEmailBodyCache("INBOX")
463 if err != nil {
464 t.Fatalf("LoadEmailBodyCache(INBOX) failed: %v", err)
465 }
466 if len(bodyCache.Bodies) != 1 || bodyCache.Bodies[0].AccountID != "account-2" {
467 t.Fatalf("Unexpected body cache after cleanup: %+v", bodyCache.Bodies)
468 }
469 onlyDeletedBodyPath, err := bodyCacheFile("OnlyDeleted")
470 if err != nil {
471 t.Fatalf("bodyCacheFile() failed: %v", err)
472 }
473 if _, err := os.Stat(onlyDeletedBodyPath); !os.IsNotExist(err) {
474 t.Fatalf("Expected body cache with only deleted account to be removed, stat err=%v", err)
475 }
476}
477
478// TestConfigHasAccounts tests the HasAccounts method.
479func TestConfigHasAccounts(t *testing.T) {
480 cfg := &Config{}
481 if cfg.HasAccounts() {
482 t.Error("Expected HasAccounts to return false for empty config")
483 }
484
485 cfg.AddAccount(Account{Email: "test@example.com"})
486 if !cfg.HasAccounts() {
487 t.Error("Expected HasAccounts to return true after adding account")
488 }
489}
490
491// TestAccountGetPorts tests the port retrieval methods.
492func TestAccountGetPorts(t *testing.T) {
493 // Gmail account should use default ports
494 gmailAccount := Account{ServiceProvider: "gmail"}
495 if gmailAccount.GetIMAPPort() != 993 {
496 t.Errorf("Expected Gmail IMAP port 993, got %d", gmailAccount.GetIMAPPort())
497 }
498 if gmailAccount.GetSMTPPort() != 587 {
499 t.Errorf("Expected Gmail SMTP port 587, got %d", gmailAccount.GetSMTPPort())
500 }
501
502 // Custom account with custom ports
503 customAccount := Account{
504 ServiceProvider: "custom",
505 IMAPPort: 1993,
506 SMTPPort: 1587,
507 }
508 if customAccount.GetIMAPPort() != 1993 {
509 t.Errorf("Expected custom IMAP port 1993, got %d", customAccount.GetIMAPPort())
510 }
511 if customAccount.GetSMTPPort() != 1587 {
512 t.Errorf("Expected custom SMTP port 1587, got %d", customAccount.GetSMTPPort())
513 }
514
515 // Custom account with default ports (0 means use default)
516 customDefaultAccount := Account{ServiceProvider: "custom"}
517 if customDefaultAccount.GetIMAPPort() != 993 {
518 t.Errorf("Expected default IMAP port 993 for custom with no port, got %d", customDefaultAccount.GetIMAPPort())
519 }
520 if customDefaultAccount.GetSMTPPort() != 587 {
521 t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort())
522 }
523}
524
525func TestAccountSendIdentityHelpers(t *testing.T) {
526 t.Run("send as takes precedence", func(t *testing.T) {
527 account := Account{
528 Name: "Alias User",
529 Email: "login@gmail.com",
530 FetchEmail: "inbox@gmail.com",
531 SendAsEmail: "alias@example.com",
532 }
533
534 if got := account.GetFetchEmail(); got != "inbox@gmail.com" {
535 t.Fatalf("GetFetchEmail() = %q, want %q", got, "inbox@gmail.com")
536 }
537 if got := account.GetSendAsEmail(); got != "alias@example.com" {
538 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "alias@example.com")
539 }
540 if got := account.FormatFromHeader(); got != "Alias User <alias@example.com>" {
541 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User <alias@example.com>")
542 }
543 })
544
545 t.Run("send as falls back to fetch then login", func(t *testing.T) {
546 account := Account{
547 Name: "Fallback User",
548 Email: "login@gmail.com",
549 FetchEmail: "inbox@gmail.com",
550 }
551 if got := account.GetSendAsEmail(); got != "inbox@gmail.com" {
552 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "inbox@gmail.com")
553 }
554
555 account.FetchEmail = ""
556 if got := account.GetSendAsEmail(); got != "login@gmail.com" {
557 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com")
558 }
559 })
560
561 t.Run("format from header avoids double wrapping", func(t *testing.T) {
562 account := Account{
563 Name: "Account Name",
564 SendAsEmail: "Custom Name <custom@example.com>",
565 }
566 if got := account.FormatFromHeader(); got != "Custom Name <custom@example.com>" {
567 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Custom Name <custom@example.com>")
568 }
569 })
570}
571
572func TestTranslateDateFormat(t *testing.T) {
573 testCases := []struct {
574 name string
575 input string
576 want string
577 sample string // expected output of time.Format for a fixed sample instant
578 }{
579 {"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
580 {"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
581 {"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
582 {"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
583 {"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
584 {"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
585 {"literal passthrough", "some text", "some text", "some text"},
586 }
587
588 sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
589 for _, tc := range testCases {
590 t.Run(tc.name, func(t *testing.T) {
591 got := translateDateFormat(tc.input)
592 if got != tc.want {
593 t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
594 }
595 if rendered := sample.Format(got); rendered != tc.sample {
596 t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
597 }
598 })
599 }
600}
601
602func TestConfigGetDateFormatDefault(t *testing.T) {
603 c := &Config{}
604 got := c.GetDateFormat()
605 want := translateDateFormat(DateFormatEU)
606 if got != want {
607 t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
608 }
609}
610
611func TestConfigGetDateFormatCustom(t *testing.T) {
612 c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
613 if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
614 t.Fatalf("GetDateFormat() = %q, want %q", got, want)
615 }
616}
617
618// TestPassCmd verifies that pass_cmd is persisted to JSON, that the password is resolved
619// from the command at load time, and that no password is written to the keyring.
620func TestPassCmd(t *testing.T) {
621 keyring.MockInit()
622 t.Setenv("HOME", t.TempDir())
623
624 cfg := &Config{
625 Accounts: []Account{
626 {
627 ID: "pass-id-1",
628 Name: "PassCmd User",
629 Email: "pass@example.com",
630 PassCmd: "echo supersecret",
631 ServiceProvider: "custom",
632 SC: &SessionCache{},
633 },
634 },
635 }
636
637 if err := SaveConfig(cfg); err != nil {
638 t.Fatalf("SaveConfig() failed: %v", err)
639 }
640
641 // The JSON on disk must contain pass_cmd and must NOT contain a password field.
642 cfgPath, err := configFile()
643 if err != nil {
644 t.Fatalf("configFile() failed: %v", err)
645 }
646 raw, err := os.ReadFile(cfgPath)
647 if err != nil {
648 t.Fatalf("ReadFile() failed: %v", err)
649 }
650 var disk map[string]interface{}
651 if err := json.Unmarshal(raw, &disk); err != nil {
652 t.Fatalf("Unmarshal() failed: %v", err)
653 }
654 accounts := disk["accounts"].([]interface{})
655 diskAcc := accounts[0].(map[string]interface{})
656 if diskAcc["pass_cmd"] != "echo supersecret" {
657 t.Errorf("expected pass_cmd in JSON, got %v", diskAcc["pass_cmd"])
658 }
659 if _, ok := diskAcc["password"]; ok {
660 t.Error("password must not appear in JSON when pass_cmd is set")
661 }
662
663 // Keyring must not have been written for this account.
664 if _, err := keyring.Get(keyringServiceName, "pass@example.com"); err == nil {
665 t.Error("keyring entry must not be created when pass_cmd is set")
666 }
667
668 // On reload, Password must be populated by running the command.
669 loaded, err := LoadConfig()
670 if err != nil {
671 t.Fatalf("LoadConfig() failed: %v", err)
672 }
673 acc := loaded.Accounts[0]
674 if acc.PassCmd != "echo supersecret" {
675 t.Errorf("PassCmd not preserved: got %q", acc.PassCmd)
676 }
677 if acc.Password != "supersecret" {
678 t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password)
679 }
680}