// encrypted_file_tokenstore_test.go — AB7 encrypted file token store 測試。 // // 覆蓋路徑: // - 基本 Save → Load roundtrip // - 多種 token 格式 + 超長內容 // - Load 空檔案(檔案不存在)→ ("", nil) // - 篡改 ciphertext / salt / nonce → ErrTokenCorrupted // - Delete 後 Load → ("", nil) // - Save("") == Delete // - 多次 Save 覆寫 // - fallback salt 在兩次建構時穩定(同 dataDir → 同 passphrase → 可 decrypt) // // Machine ID 取不取得到是平台相關;為了讓測試在每個平台都走 fallback salt 的分支, // 用獨立的 `newStoreWithSalt` helper 強制帶固定 passphrase,避免依賴 readMachineID。 // Roundtrip 測試則走正常建構路徑,確保實際 runtime 行為也被覆蓋。 package tunnel import ( "bytes" "errors" "os" "path/filepath" "testing" ) // newTestStore 建立一個 passphrase 固定的 store,不走 readMachineID。 // 這讓不同平台的測試結果一致。 func newTestStore(t *testing.T, dir string, passphrase string) *EncryptedFileTokenStore { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("mkdir: %v", err) } return &EncryptedFileTokenStore{ path: filepath.Join(dir, tokenFileName), fallbackSaltPath: filepath.Join(dir, saltFallbackFileName), passphrase: []byte(passphrase), } } func TestEncryptedFileTokenStore_SaveLoadRoundtrip(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test-machine-id-abc") want := "vAs_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" if err := store.Save(want); err != nil { t.Fatalf("Save: %v", err) } got, err := store.Load() if err != nil { t.Fatalf("Load: %v", err) } if got != want { t.Errorf("Load = %q; want %q", got, want) } } func TestEncryptedFileTokenStore_LoadEmptyReturnsNoError(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") got, err := store.Load() if err != nil { t.Fatalf("Load on missing file: %v; want nil", err) } if got != "" { t.Errorf("Load on missing file = %q; want empty", got) } } func TestEncryptedFileTokenStore_DeleteThenLoadEmpty(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") if err := store.Save("vAs_abc"); err != nil { t.Fatalf("Save: %v", err) } if err := store.Delete(); err != nil { t.Fatalf("Delete: %v", err) } got, err := store.Load() if err != nil { t.Fatalf("Load after Delete: %v", err) } if got != "" { t.Errorf("Load after Delete = %q; want empty", got) } } func TestEncryptedFileTokenStore_SaveEmptyEqualsDelete(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") if err := store.Save("vAs_abc"); err != nil { t.Fatalf("Save: %v", err) } // Save("") 應等同 Delete if err := store.Save(""); err != nil { t.Fatalf("Save empty: %v", err) } // 檔案應該被移除 if _, err := os.Stat(store.path); !errors.Is(err, os.ErrNotExist) { t.Errorf("Save(\"\") should remove file; stat err = %v", err) } got, err := store.Load() if err != nil || got != "" { t.Errorf("Load after Save(\"\") = (%q, %v); want (\"\", nil)", got, err) } } func TestEncryptedFileTokenStore_SaveOverwrite(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") for _, tok := range []string{"first", "second-longer", "third"} { if err := store.Save(tok); err != nil { t.Fatalf("Save %q: %v", tok, err) } got, err := store.Load() if err != nil { t.Fatalf("Load %q: %v", tok, err) } if got != tok { t.Errorf("Load = %q; want %q", got, tok) } } } func TestEncryptedFileTokenStore_DeleteIdempotent(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") // 未存在 → Delete 不報錯 if err := store.Delete(); err != nil { t.Errorf("Delete on missing: %v; want nil (idempotent)", err) } // 連續兩次 Delete 也 OK _ = store.Save("abc") if err := store.Delete(); err != nil { t.Fatalf("1st Delete: %v", err) } if err := store.Delete(); err != nil { t.Errorf("2nd Delete: %v; want nil (idempotent)", err) } } func TestEncryptedFileTokenStore_TamperDetectedAsCorrupted(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") if err := store.Save("vAs_original"); err != nil { t.Fatalf("Save: %v", err) } data, err := os.ReadFile(store.path) if err != nil { t.Fatalf("read token file: %v", err) } // 翻轉最後一個 byte(ciphertext/tag 區域) data[len(data)-1] ^= 0xFF if err := os.WriteFile(store.path, data, 0o600); err != nil { t.Fatalf("write tampered: %v", err) } got, err := store.Load() if !errors.Is(err, ErrTokenCorrupted) { t.Errorf("Load tampered: err = %v; want ErrTokenCorrupted", err) } if got != "" { t.Errorf("Load tampered: got = %q; want empty", got) } } func TestEncryptedFileTokenStore_WrongPassphraseCorrupted(t *testing.T) { dir := t.TempDir() storeA := newTestStore(t, dir, "machine-A") if err := storeA.Save("vAs_token"); err != nil { t.Fatalf("Save: %v", err) } // 模擬「另一台機器拿走檔案」:用不同 passphrase 但同樣的檔案路徑 storeB := newTestStore(t, dir, "machine-B") got, err := storeB.Load() if !errors.Is(err, ErrTokenCorrupted) { t.Errorf("Load with wrong passphrase: err = %v; want ErrTokenCorrupted", err) } if got != "" { t.Errorf("Load with wrong passphrase: got = %q; want empty", got) } } func TestEncryptedFileTokenStore_TooShortFileCorrupted(t *testing.T) { dir := t.TempDir() store := newTestStore(t, dir, "test") // 寫一個明顯不夠長的垃圾檔 if err := os.WriteFile(store.path, []byte("junk"), 0o600); err != nil { t.Fatalf("write junk: %v", err) } got, err := store.Load() if !errors.Is(err, ErrTokenCorrupted) { t.Errorf("Load short file: err = %v; want ErrTokenCorrupted", err) } if got != "" { t.Errorf("Load short file: got = %q; want empty", got) } } func TestEncryptedFileTokenStore_DistinctCipherTextForSameToken(t *testing.T) { // 每次 Save 都應抽新 salt + nonce,所以兩次相同 token 的檔案內容必不同。 dir := t.TempDir() store := newTestStore(t, dir, "test") tok := "vAs_same_token_twice" if err := store.Save(tok); err != nil { t.Fatalf("1st Save: %v", err) } first, err := os.ReadFile(store.path) if err != nil { t.Fatalf("read: %v", err) } if err := store.Save(tok); err != nil { t.Fatalf("2nd Save: %v", err) } second, err := os.ReadFile(store.path) if err != nil { t.Fatalf("read: %v", err) } if bytes.Equal(first, second) { t.Error("two Saves of same token produced identical ciphertext; salt/nonce not randomised") } } func TestNewEncryptedFileTokenStore_FallbackSaltStable(t *testing.T) { // 兩次建構(同 dataDir)在 machineID 取不到時應共用 fallback salt, // 才能讓 Save 後下次啟動仍能 Load。 // // 這個測試不直接戳 readMachineID(平台相關),改用直接建構 store + 手動 // 觸發 fallback,然後驗證兩次建構的 passphrase 一致。 dir := t.TempDir() storeA := &EncryptedFileTokenStore{ path: filepath.Join(dir, tokenFileName), fallbackSaltPath: filepath.Join(dir, saltFallbackFileName), } saltA, err := storeA.ensureFallbackSalt() if err != nil { t.Fatalf("ensureFallbackSalt A: %v", err) } storeA.passphrase = saltA storeB := &EncryptedFileTokenStore{ path: filepath.Join(dir, tokenFileName), fallbackSaltPath: filepath.Join(dir, saltFallbackFileName), } saltB, err := storeB.ensureFallbackSalt() if err != nil { t.Fatalf("ensureFallbackSalt B: %v", err) } storeB.passphrase = saltB if !bytes.Equal(saltA, saltB) { t.Fatal("fallback salt changed across reopens; tokens would become unreadable") } // Cross-decrypt:A Save → B Load 應該能成功 want := "vAs_crossload" if err := storeA.Save(want); err != nil { t.Fatalf("Save: %v", err) } got, err := storeB.Load() if err != nil { t.Fatalf("Cross-store Load: %v", err) } if got != want { t.Errorf("Cross-store Load = %q; want %q", got, want) } } func TestNewEncryptedFileTokenStore_RequiresDataDir(t *testing.T) { _, err := NewEncryptedFileTokenStore("", nil) if err == nil { t.Error("NewEncryptedFileTokenStore(\"\") should error") } } func TestNewEncryptedFileTokenStore_CreatesDataDir(t *testing.T) { // 帶一個不存在的子目錄,建構時會 MkdirAll dir := filepath.Join(t.TempDir(), "nested", "agent") store, err := NewEncryptedFileTokenStore(dir, nil) if err != nil { t.Fatalf("NewEncryptedFileTokenStore: %v", err) } if _, err := os.Stat(dir); err != nil { t.Errorf("dataDir not created: %v", err) } // Roundtrip(用真正的 constructor;passphrase 由實際 machineID 或 fallback 決定) if err := store.Save("vAs_real"); err != nil { t.Fatalf("Save: %v", err) } got, err := store.Load() if err != nil { t.Fatalf("Load: %v", err) } if got != "vAs_real" { t.Errorf("Load = %q; want %q", got, "vAs_real") } } // 確認 Interface 相容:EncryptedFileTokenStore 可以當 TokenStore 用。 func TestEncryptedFileTokenStore_ImplementsTokenStore(t *testing.T) { dir := t.TempDir() var ts TokenStore = newTestStore(t, dir, "test") if err := ts.Save("x"); err != nil { t.Fatalf("interface Save: %v", err) } got, err := ts.Load() if err != nil || got != "x" { t.Errorf("interface Load = (%q, %v); want (\"x\", nil)", got, err) } if err := ts.Delete(); err != nil { t.Errorf("interface Delete: %v", err) } }