307 lines
12 KiB
Go
307 lines
12 KiB
Go
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|