Files
code-utils/sync_integration_test.go
2026-08-14 07:52:01 +08:00

307 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 账号全链路集成测试:注册 → 登录 → 加密 Key 推送 → 修改密码(密文轮换)→ 新密码登录 → 再同步。
// 需要本机 MySQLroot/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)
}
}