f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
198 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|