Files
Commilitia-Drop/desktop/platform/session_test.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

198 lines
5.9 KiB
Go

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)
}
}