更新若干功能
This commit is contained in:
306
sync_integration_test.go
Normal file
306
sync_integration_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步。
|
||||
// 需要本机 MySQL(root/root);不可达时自动跳过。测试使用一次性临时库,结束后删除。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var itDDL = []string{
|
||||
`CREATE TABLE users(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(100) NOT NULL,
|
||||
created_at VARCHAR(32) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE sync_todos(
|
||||
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL,
|
||||
title TEXT NOT NULL, content TEXT NOT NULL,
|
||||
project_name VARCHAR(255) NOT NULL DEFAULT '', due_at VARCHAR(32) NOT NULL DEFAULT '',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'medium', status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
history MEDIUMTEXT NOT NULL, team_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at VARCHAR(32) NOT NULL DEFAULT '', updated_at VARCHAR(32) NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE sync_tickets(
|
||||
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL,
|
||||
title TEXT NOT NULL, description TEXT NOT NULL, type VARCHAR(16) NOT NULL DEFAULT 'task',
|
||||
project_name VARCHAR(255) NOT NULL DEFAULT '', start_at VARCHAR(32) NOT NULL DEFAULT '',
|
||||
due_at VARCHAR(32) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'medium', history MEDIUMTEXT NOT NULL,
|
||||
team_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at VARCHAR(32) NOT NULL DEFAULT '', updated_at VARCHAR(32) NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE sync_notes(
|
||||
user_id BIGINT NOT NULL, uuid CHAR(36) NOT NULL, content MEDIUMTEXT NOT NULL,
|
||||
updated_at VARCHAR(32) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY(user_id, uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE sync_settings(
|
||||
user_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, value MEDIUMTEXT NOT NULL,
|
||||
updated_at VARCHAR(32) NOT NULL, PRIMARY KEY(user_id, name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE user_profiles(
|
||||
user_id BIGINT PRIMARY KEY, nickname VARCHAR(64) NOT NULL DEFAULT '', title VARCHAR(64) NOT NULL DEFAULT '',
|
||||
email VARCHAR(128) NOT NULL DEFAULT '', bio VARCHAR(500) NOT NULL DEFAULT '',
|
||||
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]', avatar_thumb MEDIUMTEXT NOT NULL, updated_at VARCHAR(32) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE teams(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, owner_id BIGINT NOT NULL,
|
||||
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00', created_at VARCHAR(32) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE team_members(
|
||||
team_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role VARCHAR(16) NOT NULL DEFAULT 'member',
|
||||
joined_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE team_tasks(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT, team_id BIGINT NOT NULL, kind VARCHAR(16) NOT NULL DEFAULT 'todo',
|
||||
title TEXT NOT NULL, description MEDIUMTEXT NOT NULL, priority VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open', creator_id BIGINT NOT NULL, assignee_id BIGINT NOT NULL DEFAULT 0,
|
||||
start_at VARCHAR(32) NOT NULL DEFAULT '', due_at VARCHAR(32) NOT NULL DEFAULT '',
|
||||
urged_at VARCHAR(32) NOT NULL DEFAULT '', history MEDIUMTEXT NOT NULL, updated_at VARCHAR(32) NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE team_reports(
|
||||
team_id BIGINT NOT NULL, user_id BIGINT NOT NULL, date CHAR(10) NOT NULL,
|
||||
content MEDIUMTEXT NOT NULL, submitted_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, user_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE team_digests(
|
||||
team_id BIGINT NOT NULL, date CHAR(10) NOT NULL, content MEDIUMTEXT NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT '', generated_at VARCHAR(32) NOT NULL, PRIMARY KEY(team_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE team_notices(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT, team_id BIGINT NOT NULL, to_user BIGINT NOT NULL, from_user BIGINT NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL, ref_id VARCHAR(64) NOT NULL DEFAULT '', content TEXT NOT NULL, created_at VARCHAR(32) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
// syncNowRetry 容忍登录后台首轮同步占用的 SYNC_IN_PROGRESS。
|
||||
func syncNowRetry(t *testing.T, a *App) SyncStatus {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
st, e := a.SyncNow()
|
||||
if e == nil {
|
||||
return st
|
||||
}
|
||||
if e.Error() != "SYNC_IN_PROGRESS" {
|
||||
t.Fatalf("SyncNow: %v", e)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("sync stayed busy")
|
||||
return SyncStatus{}
|
||||
}
|
||||
|
||||
func TestAccountFlowIntegration(t *testing.T) {
|
||||
boot, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/?timeout=2s")
|
||||
if e != nil {
|
||||
t.Skip("mysql driver: " + e.Error())
|
||||
}
|
||||
defer boot.Close()
|
||||
if e = boot.Ping(); e != nil {
|
||||
t.Skip("local MySQL not available: " + e.Error())
|
||||
}
|
||||
dbName := fmt.Sprintf("cc_it_%d", time.Now().UnixNano())
|
||||
if _, e = boot.Exec("CREATE DATABASE " + dbName + " DEFAULT CHARSET utf8mb4"); e != nil {
|
||||
t.Skip("cannot create scratch db: " + e.Error())
|
||||
}
|
||||
defer boot.Exec("DROP DATABASE " + dbName)
|
||||
remote, e := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/"+dbName+"?timeout=2s")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer remote.Close()
|
||||
for _, ddl := range itDDL {
|
||||
if _, e = remote.Exec(ddl); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
|
||||
s, e := OpenStore(filepath.Join(t.TempDir(), "it.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer s.db.Close()
|
||||
a := NewApp()
|
||||
a.store = s
|
||||
a.ctx = context.Background()
|
||||
if e = a.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
|
||||
// 注册:成功一次,重复注册报 SYNC_USER_EXISTS。
|
||||
if e := a.SyncRegister("ituser", "oldpass-1"); e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
if e := a.SyncRegister("ituser", "oldpass-1"); e == nil || e.Error() != "SYNC_USER_EXISTS" {
|
||||
t.Fatalf("want SYNC_USER_EXISTS, got %v", e)
|
||||
}
|
||||
|
||||
// 登录:错密码拒绝,正确密码成功并派生加密密钥。
|
||||
if _, e := a.SyncLogin("ituser", "wrong-pass"); e == nil || e.Error() != "SYNC_BAD_CREDENTIALS" {
|
||||
t.Fatalf("want SYNC_BAD_CREDENTIALS, got %v", e)
|
||||
}
|
||||
st, e := a.SyncLogin("ituser", "oldpass-1")
|
||||
if e != nil || !st.LoggedIn {
|
||||
t.Fatalf("login: %v %+v", e, st)
|
||||
}
|
||||
oldKey := a.encKey()
|
||||
if oldKey == nil {
|
||||
t.Fatal("enc key not derived after login")
|
||||
}
|
||||
|
||||
// 配置 API Key 并开启加密同步,推送到云端。
|
||||
set, _ := s.Settings()
|
||||
set.SyncAPIKeys, set.SparkKey, set.DeepSeekKey = true, "sk-spark-123", "dsk-456"
|
||||
if e = s.SaveSettings(set); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
syncNowRetry(t, a)
|
||||
var blob string
|
||||
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='api_keys'`).Scan(&blob); e != nil {
|
||||
t.Fatalf("api_keys row not pushed: %v", e)
|
||||
}
|
||||
if plain, e := decryptWithKey(oldKey, blob); e != nil || !strings.Contains(plain, "sk-spark-123") {
|
||||
t.Fatalf("old key should decrypt pushed blob: %v %q", e, plain)
|
||||
}
|
||||
|
||||
// 修改密码:旧密码错误被拒;正确旧密码成功。
|
||||
if e := a.SyncChangePassword("wrong-pass", "newpass-2"); e == nil || e.Error() != "SYNC_OLD_PASSWORD_WRONG" {
|
||||
t.Fatalf("want SYNC_OLD_PASSWORD_WRONG, got %v", e)
|
||||
}
|
||||
if e := a.SyncChangePassword("oldpass-1", "newpass-2"); e != nil {
|
||||
t.Fatalf("change password: %v", e)
|
||||
}
|
||||
|
||||
// 服务器密码哈希已更新为新密码。
|
||||
var hash string
|
||||
if e = remote.QueryRow(`SELECT password_hash FROM users WHERE username='ituser'`).Scan(&hash); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpass-2")) != nil {
|
||||
t.Fatal("password hash not rotated")
|
||||
}
|
||||
|
||||
// 云端密文已轮换:旧密钥解不开,新密钥解得开且内容不变。
|
||||
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='api_keys'`).Scan(&blob); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := decryptWithKey(oldKey, blob); e == nil {
|
||||
t.Fatal("old key still decrypts after rotation")
|
||||
}
|
||||
newKey := a.encKey()
|
||||
if plain, e := decryptWithKey(newKey, blob); e != nil || !strings.Contains(plain, "sk-spark-123") {
|
||||
t.Fatalf("new key should decrypt rotated blob: %v %q", e, plain)
|
||||
}
|
||||
|
||||
// 旧密码登录失败;新密码登录成功且派生出同一把新密钥。
|
||||
if _, e := a.SyncLogin("ituser", "oldpass-1"); e == nil || e.Error() != "SYNC_BAD_CREDENTIALS" {
|
||||
t.Fatalf("old password should be rejected, got %v", e)
|
||||
}
|
||||
if _, e := a.SyncLogin("ituser", "newpass-2"); e != nil {
|
||||
t.Fatalf("login with new password: %v", e)
|
||||
}
|
||||
if hex.EncodeToString(a.encKey()) != hex.EncodeToString(newKey) {
|
||||
t.Fatal("re-login derived a different key")
|
||||
}
|
||||
|
||||
// 改密码后整轮同步依旧正常(含 avatar/api_keys 行)。
|
||||
syncNowRetry(t, a)
|
||||
|
||||
// —— 文档同步 v2:设备 A 本地建项目并推送(身份 + 本机 paths 行分离),设备 B(同机器码)拉取 ——
|
||||
projPath := t.TempDir()
|
||||
if _, e := s.SaveProject(0, ProjectInput{Name: "Alpha", Path: projPath}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// 身份文档按名匹配补分组/收藏(模拟从旧客户端拉到的 v1 文档也能套用)
|
||||
if e := a.applyProjectsDoc(`[{"name":"Alpha","group":"Work","favorite":true}]`); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.AddRule("*.itbak", "custom"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
syncNowRetry(t, a)
|
||||
var doc string
|
||||
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name='projects'`).Scan(&doc); e != nil || !strings.Contains(doc, "Alpha") {
|
||||
t.Fatalf("projects doc not pushed: %v %q", e, doc)
|
||||
}
|
||||
if strings.Contains(doc, `"path"`) {
|
||||
t.Fatalf("v2 projects doc must not contain machine paths: %q", doc)
|
||||
}
|
||||
var pathsDoc string
|
||||
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name=?`, "paths:"+a.machineID()).Scan(&pathsDoc); e != nil || !strings.Contains(pathsDoc, "Alpha") {
|
||||
t.Fatalf("machine paths row not pushed: %v %q", e, pathsDoc)
|
||||
}
|
||||
|
||||
s2, e := OpenStore(filepath.Join(t.TempDir(), "it2.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer s2.db.Close()
|
||||
b := NewApp()
|
||||
b.store = s2
|
||||
b.ctx = context.Background()
|
||||
if e = b.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := b.SyncLogin("ituser", "newpass-2"); e != nil {
|
||||
t.Fatalf("device B login: %v", e)
|
||||
}
|
||||
syncNowRetry(t, b)
|
||||
// 设备 B 与 A 同一物理机(machineID 相同)→ paths 行直接补齐本机路径
|
||||
var n int
|
||||
_ = s2.db.QueryRow(`SELECT COUNT(*) FROM projects p JOIN project_groups g ON g.id=p.group_id JOIN favorites f ON f.project_id=p.id
|
||||
WHERE p.path=? AND g.name='Work'`, projPath).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("device B should pull project+group+favorite, got %d", n)
|
||||
}
|
||||
_ = s2.db.QueryRow(`SELECT COUNT(*) FROM exclusion_rules WHERE pattern='*.itbak' AND builtin=0`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatal("device B should pull custom rule")
|
||||
}
|
||||
|
||||
// —— 设备 C(不同机器码):只有身份没有路径 → 待绑定 → 绑定后回推本机 paths 行 ——
|
||||
s3, e := OpenStore(filepath.Join(t.TempDir(), "it3.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer s3.db.Close()
|
||||
_ = s3.SetMeta("machine_id", "beef00beef00beef") // 模拟另一台电脑
|
||||
c := NewApp()
|
||||
c.store = s3
|
||||
c.ctx = context.Background()
|
||||
if e = c.SaveSyncConfig(SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: dbName}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := c.SyncLogin("ituser", "newpass-2"); e != nil {
|
||||
t.Fatalf("device C login: %v", e)
|
||||
}
|
||||
syncNowRetry(t, c)
|
||||
_ = s3.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("device C must not adopt other machine's paths, got %d projects", n)
|
||||
}
|
||||
pending, e := c.ListCloudPendingProjects()
|
||||
if e != nil || len(pending) != 1 || pending[0].Name != "Alpha" {
|
||||
t.Fatalf("device C pending want [Alpha], got %#v err=%v", pending, e)
|
||||
}
|
||||
dirC := t.TempDir()
|
||||
if _, e := c.BindCloudProject("Alpha", dirC); e != nil {
|
||||
t.Fatalf("bind on device C: %v", e)
|
||||
}
|
||||
syncNowRetry(t, c)
|
||||
if e = remote.QueryRow(`SELECT value FROM sync_settings WHERE name=?`, "paths:beef00beef00beef").Scan(&pathsDoc); e != nil || !strings.Contains(pathsDoc, "Alpha") {
|
||||
t.Fatalf("device C paths row not pushed: %v %q", e, pathsDoc)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user