package platform import ( "encoding/json" "os" "path/filepath" "strings" "testing" keyring "github.com/zalando/go-keyring" ) // withTempSession isolates session storage: an in-memory keyring mock (so tests // never touch the real Keychain / Credential Manager) plus a temp HOME so // sessionPath resolves under t.TempDir(). func withTempSession(t *testing.T) { t.Helper() keyring.MockInit() dir := t.TempDir() // os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS. t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("HOME", dir) } // readSessionFile returns the parsed on-disk record plus its raw bytes. func readSessionFile(t *testing.T) (persistedSession, []byte) { t.Helper() p, err := sessionPath() if err != nil { t.Fatalf("sessionPath: %v", err) } data, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { return persistedSession{}, nil } t.Fatalf("read session file: %v", err) } var rec persistedSession if err := json.Unmarshal(data, &rec); err != nil { t.Fatalf("unmarshal session file: %v", err) } return rec, data } // The refresh_token must be encrypted at rest (R2): the plaintext never appears // in the file, the keyring holds only the small AES key, and the round-trip // returns the token in memory. func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) { withTempSession(t) res := LoginResult{ AccessToken: "access-abc", RefreshToken: "refresh-xyz-secret", ExpiresIn: 3600, User: UserInfo{ID: "u1", Name: "Tester"}, } if err := SaveSession(res); err != nil { t.Fatalf("save: %v", err) } rec, raw := readSessionFile(t) if rec.RefreshToken != "" { t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken) } if rec.RefreshTokenEnc == "" { t.Error("refresh_token_enc must be present") } if strings.Contains(string(raw), "refresh-xyz-secret") { t.Error("the plaintext token must not appear anywhere in the file bytes") } // The keyring holds the AES key, not the token. if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" { t.Errorf("keyring must hold the session key: %q err %v", k, err) } loaded, err := LoadSession() if err != nil || loaded == nil { t.Fatalf("load: %v (nil=%v)", err, loaded == nil) } if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" { t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken) } } // A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is // exactly the case go-keyring can't store directly (its per-item limit is a few // KB), and the reason the token is encrypted into the file instead. func TestSaveSession_LargeTokenRoundTrips(t *testing.T) { withTempSession(t) big := strings.Repeat("a", 16000) res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}} if err := SaveSession(res); err != nil { t.Fatalf("save large token: %v", err) } rec, raw := readSessionFile(t) if rec.RefreshToken != "" { t.Error("large token must not be stored in plaintext") } if strings.Contains(string(raw), big) { t.Error("large plaintext token must not appear in the file bytes") } loaded, err := LoadSession() if err != nil || loaded == nil || loaded.RefreshToken != big { t.Fatalf("large token did not round-trip: err %v", err) } } // A legacy file with a plaintext refresh_token (written before R2) must be // re-encrypted on first load and stripped of its plaintext. func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) { withTempSession(t) p, _ := sessionPath() if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { t.Fatalf("mkdir: %v", err) } // Seed a legacy file: plaintext refresh_token, no ciphertext field. data, _ := json.Marshal(persistedSession{ AccessToken: "access-abc", RefreshToken: "legacy-plain", ExpiresIn: 3600, User: UserInfo{ID: "u1"}, }) if err := os.WriteFile(p, data, 0o600); err != nil { t.Fatalf("seed legacy file: %v", err) } loaded, err := LoadSession() if err != nil || loaded == nil { t.Fatalf("load: %v", err) } if loaded.RefreshToken != "legacy-plain" { t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken) } rec, raw := readSessionFile(t) if rec.RefreshToken != "" { t.Error("plaintext must be stripped from disk after migration") } if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") { t.Error("migrated token must be encrypted in the file") } } func TestSessionRoundTripAndClear(t *testing.T) { withTempSession(t) if got, err := LoadSession(); err != nil || got != nil { t.Fatalf("absent session should be nil; got %+v err %v", got, err) } want := LoginResult{ AccessToken: "acc", RefreshToken: "ref", ExpiresIn: 3600, User: UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"}, } if err := SaveSession(want); err != nil { t.Fatalf("SaveSession: %v", err) } got, err := LoadSession() if err != nil { t.Fatalf("LoadSession: %v", err) } if got == nil || *got != want { t.Errorf("round-trip: got %+v, want %+v", got, want) } if err := ClearSession(); err != nil { t.Fatalf("ClearSession: %v", err) } if got, _ := LoadSession(); got != nil { t.Error("session should be gone after ClearSession") } // The key is dropped too — no orphaned secret-store entry. if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound { t.Errorf("session key should be gone after clear, got err %v", err) } if err := ClearSession(); err != nil { t.Errorf("ClearSession on absent should be no-op, got %v", err) } } func TestLoadSessionIgnoresEmptyToken(t *testing.T) { withTempSession(t) if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil { t.Fatalf("SaveSession: %v", err) } got, err := LoadSession() if err != nil { t.Fatalf("LoadSession: %v", err) } if got != nil { t.Errorf("empty-access-token session should load as nil, got %+v", got) } }