config_test.go

  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}