從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
9.3 KiB
Go
321 lines
9.3 KiB
Go
// 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)
|
||
}
|
||
}
|