feat(saas): Phase 4 端到端完善 — 设备注册、离线支持、配置迁移、集成测试

- 后端: devices 表 + register/heartbeat/list 端点 (UPSERT 语义)
- 桌面端: 设备 ID 持久化 + 5 分钟心跳 + 离线状态指示
- saas-client: 重试逻辑 (2 次指数退避) + isServerReachable 跟踪
- ConfigMigrationWizard: 3 步向导 (方向选择→冲突解决→结果)
- SaaSSettings: 修改密码折叠面板 + 迁移向导入口
- 集成测试: 21 个测试全部通过 (含设备注册/UPSERT/心跳、密码修改、E2E 生命周期)
- 修复 ConfigMigrationWizard merge 分支变量遮蔽 bug
This commit is contained in:
iven
2026-03-27 15:07:03 +08:00
parent 8cce2283f7
commit bc12f6899a
9 changed files with 1007 additions and 39 deletions

View File

@@ -419,6 +419,356 @@ async fn test_config_seed_and_list() {
assert_eq!(body.as_array().unwrap().len(), 0);
}
// ============ Phase 4: 设备注册 + 密码修改 ============
#[tokio::test]
async fn test_device_register_and_list() {
let app = build_test_app().await;
let token = register_and_login(&app, "devuser", "devuser@example.com").await;
// 注册设备
let reg_req = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "desktop-test-001",
"device_name": "My Desktop",
"platform": "windows",
"app_version": "0.1.0"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(reg_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 列出设备 — 应该有 1 台
let list_req = Request::builder()
.method("GET")
.uri("/api/v1/devices")
.header("Authorization", auth_header(&token))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(body.is_array());
assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["device_id"], "desktop-test-001");
assert_eq!(body[0]["platform"], "windows");
}
#[tokio::test]
async fn test_device_upsert_on_reregister() {
let app = build_test_app().await;
let token = register_and_login(&app, "upsertdev", "upsertdev@example.com").await;
// 第一次注册
let req = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "device-upsert-01",
"device_name": "Old Name",
"platform": "linux"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 同一 device_id 再次注册 (UPSERT → 更新 device_name 和 last_seen_at)
let req2 = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "device-upsert-01",
"device_name": "New Name",
"platform": "linux"
})).unwrap()))
.unwrap();
let resp2 = app.clone().oneshot(req2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
// 仍然只有 1 台设备,名称已更新
let list_req = Request::builder()
.method("GET")
.uri("/api/v1/devices")
.header("Authorization", auth_header(&token))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["device_name"], "New Name");
}
#[tokio::test]
async fn test_device_heartbeat() {
let app = build_test_app().await;
let token = register_and_login(&app, "hbuser", "hbuser@example.com").await;
// 先注册设备
let reg_req = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "hb-device-01",
"device_name": "Heartbeat Device"
})).unwrap()))
.unwrap();
app.clone().oneshot(reg_req).await.unwrap();
// 发送心跳
let hb_req = Request::builder()
.method("POST")
.uri("/api/v1/devices/heartbeat")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "hb-device-01"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(hb_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 心跳到未注册的设备 → 404
let bad_hb = Request::builder()
.method("POST")
.uri("/api/v1/devices/heartbeat")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "nonexistent-device"
})).unwrap()))
.unwrap();
let resp = app.oneshot(bad_hb).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_device_register_missing_id() {
let app = build_test_app().await;
let token = register_and_login(&app, "baddev", "baddev@example.com").await;
// 缺少 device_id → 400
let req = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_name": "No ID Device"
})).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_change_password() {
let app = build_test_app().await;
let token = register_and_login(&app, "pwduser", "pwduser@example.com").await;
// 修改密码
let change_req = Request::builder()
.method("PUT")
.uri("/api/v1/auth/password")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"old_password": "password123",
"new_password": "newpassword456"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(change_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 用新密码重新登录
let login_req = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&json!({
"username": "pwduser",
"password": "newpassword456"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(login_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 用旧密码登录 → 401
let old_login = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&json!({
"username": "pwduser",
"password": "password123"
})).unwrap()))
.unwrap();
let resp = app.oneshot(old_login).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_change_password_wrong_old() {
let app = build_test_app().await;
let token = register_and_login(&app, "wrongold", "wrongold@example.com").await;
// 旧密码错误 → 400
let change_req = Request::builder()
.method("PUT")
.uri("/api/v1/auth/password")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"old_password": "wrong-old-password",
"new_password": "newpassword456"
})).unwrap()))
.unwrap();
let resp = app.oneshot(change_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ============ Phase 4 E2E: 完整生命周期 ============
#[tokio::test]
async fn test_e2e_full_lifecycle() {
let app = build_test_app().await;
// 1. 注册
let token = register_and_login(&app, "e2euser", "e2e@example.com").await;
assert!(!token.is_empty());
// 2. 查看自己的账号信息
let me_req = Request::builder()
.method("GET")
.uri("/api/v1/auth/me")
.header("Authorization", auth_header(&token))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(me_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let me: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(me["username"], "e2euser");
// 3. 注册设备
let dev_req = Request::builder()
.method("POST")
.uri("/api/v1/devices/register")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"device_id": "e2e-device-001",
"device_name": "E2E Test Machine",
"platform": "windows",
"app_version": "1.0.0"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(dev_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 4. 创建 API Token
let tok_req = Request::builder()
.method("POST")
.uri("/api/v1/tokens")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"name": "e2e-token",
"permissions": ["model:read"]
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(tok_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let tok_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(!tok_body["token"].is_null());
// 5. 修改密码
let pwd_req = Request::builder()
.method("PUT")
.uri("/api/v1/auth/password")
.header("Content-Type", "application/json")
.header("Authorization", auth_header(&token))
.body(Body::from(serde_json::to_string(&json!({
"old_password": "password123",
"new_password": "e2e-new-password"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(pwd_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 6. 用新密码登录验证
let relogin_req = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&json!({
"username": "e2euser",
"password": "e2e-new-password"
})).unwrap()))
.unwrap();
let resp = app.clone().oneshot(relogin_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let login_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let new_token = login_body["token"].as_str().unwrap();
assert!(!new_token.is_empty());
// 7. 用新 token 列出设备
let list_req = Request::builder()
.method("GET")
.uri("/api/v1/devices")
.header("Authorization", auth_header(new_token))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
let devs: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(devs.as_array().unwrap().len(), 1);
// 8. 用旧 token 应该仍可用 (JWT 未撤销)
let old_tok_list = Request::builder()
.method("GET")
.uri("/api/v1/devices")
.header("Authorization", auth_header(&token))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(old_tok_list).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_config_sync() {
let app = build_test_app().await;