mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
b0fe40142a
* Rebrand MMTL to MeBox across codebase and assets Rename the project display name, Go module path, environment variable prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl to MeBox/mebox. Replace logo assets with the new MeBox icon and keep legacy SQLite migration support for existing mmtl.db deployments. Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix logo icons: use cube-only crop without truncated text Previous icon generation cropped too much of the source image, including partial MeBox wordmark text that was cut off in square icon containers. Regenerate logo-64/192/512, favicon, and SVG from cube-only region. Co-authored-by: truewhile <truewhile@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
229 lines
7.8 KiB
Go
229 lines
7.8 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||
)
|
||
|
||
// Test115OAuthQRFlow 用 mock 的 115 开放平台接口走完设备码扫码授权:
|
||
// 创建账号(空凭据)→ 发起授权(取二维码)→ 轮询(未扫码/已扫码/已确认)
|
||
// → 确认后 token 写入账号 → 驱动可用。
|
||
func Test115OAuthQRFlow(t *testing.T) {
|
||
svc := testStrmService(t)
|
||
|
||
var scanCalls int
|
||
pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/open/user/info":
|
||
w.Write([]byte(`{"state":true,"data":{"user_id":"115001","user_name":"测试用户"}}`))
|
||
default:
|
||
t.Errorf("unexpected pro path %s", r.URL.Path)
|
||
}
|
||
}))
|
||
defer pro.Close()
|
||
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/open/authDeviceCode":
|
||
w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`))
|
||
case "/open/deviceCodeToToken":
|
||
w.Write([]byte(`{"state":true,"data":{"access_token":"open-at","refresh_token":"open-rt","expires_in":7200}}`))
|
||
default:
|
||
t.Errorf("unexpected passport path %s", r.URL.Path)
|
||
}
|
||
}))
|
||
defer passport.Close()
|
||
qr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
scanCalls++
|
||
switch scanCalls {
|
||
case 1:
|
||
w.Write([]byte(`{"state":true,"data":{"status":0}}`))
|
||
case 2:
|
||
w.Write([]byte(`{"state":true,"data":{"status":1}}`))
|
||
default:
|
||
w.Write([]byte(`{"state":true,"data":{"status":2}}`))
|
||
}
|
||
}))
|
||
defer qr.Close()
|
||
|
||
oldPro, oldPassport, oldQR := cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase
|
||
cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase = pro.URL, passport.URL, qr.URL
|
||
defer func() {
|
||
cloud115.ProAPIBase, cloud115.PassportAPIBase, cloud115.QRCodeAPIBase = oldPro, oldPassport, oldQR
|
||
}()
|
||
|
||
// 1. 创建 115 账号(自定义 AppID,空凭据)
|
||
acct, err := svc.CreateStrmAccount(context.Background(), "我的115", model.StrmProvider115, map[string]string{})
|
||
if err != nil {
|
||
t.Fatalf("create account: %v", err)
|
||
}
|
||
if HasStrmAccountCredential(acct) {
|
||
t.Fatalf("new account should have no credential")
|
||
}
|
||
|
||
// 2. 发起设备码授权
|
||
result, err := svc.Start115OAuth(context.Background(), acct.ID,
|
||
string(cloud115.SourceTypeCustomAppID), "100195125", "", "")
|
||
if err != nil {
|
||
t.Fatalf("start oauth: %v", err)
|
||
}
|
||
if result.Mode != "qrcode" || result.QRCode == nil || result.QRCode.Qrcode == "" {
|
||
t.Fatalf("bad start result: %#v", result)
|
||
}
|
||
|
||
// 3. 轮询:waiting -> scanned
|
||
for _, want := range []string{"waiting", "scanned"} {
|
||
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
|
||
if err != nil {
|
||
t.Fatalf("poll: %v", err)
|
||
}
|
||
if status.Status != want {
|
||
t.Fatalf("poll status = %s, want %s", status.Status, want)
|
||
}
|
||
}
|
||
|
||
// 4. 确认
|
||
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
|
||
if err != nil {
|
||
t.Fatalf("poll confirm: %v", err)
|
||
}
|
||
if status.Status != "confirmed" {
|
||
t.Fatalf("final status = %s, want confirmed", status.Status)
|
||
}
|
||
|
||
// 5. 账号已保存 token 与用户信息
|
||
updated, err := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
|
||
if err != nil || updated == nil {
|
||
t.Fatal("account missing")
|
||
}
|
||
cfg, err := svc.strmAccountConfig(updated)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if cfg["access_token"] != "open-at" || cfg["refresh_token"] != "open-rt" {
|
||
t.Fatalf("token not saved: %#v", cfg)
|
||
}
|
||
if cfg["user_name"] != "测试用户" && !strings.Contains(updated.Name, "115") && updated.Name != "测试用户" {
|
||
t.Fatalf("user info not saved: %#v", cfg)
|
||
}
|
||
if !HasStrmAccountCredential(updated) {
|
||
t.Fatal("credential should be present after auth")
|
||
}
|
||
}
|
||
|
||
// Test115OAUTHCallbackRelay 中继回调链路:发起 → 回调(加密 payload)→ 轮询确认。
|
||
func Test115OAUTHCallbackRelay(t *testing.T) {
|
||
svc := testStrmService(t)
|
||
cloud115.RelayEncryptionKey = "callback-test-key"
|
||
defer func() { cloud115.RelayEncryptionKey = "" }()
|
||
|
||
acct, err := svc.CreateStrmAccount(context.Background(), "中继115", model.StrmProvider115, map[string]string{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
result, err := svc.Start115OAuth(context.Background(), acct.ID,
|
||
string(cloud115.SourceTypeBuiltInRelay), "QMediaSync", string(cloud115.ProviderQMediaSync),
|
||
"http://127.0.0.1/api/strm/oauth/callback")
|
||
if err != nil {
|
||
t.Fatalf("start relay oauth: %v", err)
|
||
}
|
||
if result.Mode != "url" || result.AuthURL == "" {
|
||
t.Fatalf("bad relay start: %#v", result)
|
||
}
|
||
if !strings.Contains(result.AuthURL, "oauth.qmediasync.cn") {
|
||
t.Fatalf("unexpected relay url: %s", result.AuthURL)
|
||
}
|
||
|
||
// 模拟中继服务器回调(加密 data + authorization_id)
|
||
payload := `{"data":{"access_token":"relay-at","refresh_token":"relay-rt","expires_in":7200}}`
|
||
encrypted, err := cloud115.EncryptRelay(payload)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cbErr := svc.Handle115OAuthCallback(context.Background(), map[string]string{
|
||
"authorization_id": result.SessionID,
|
||
"data": encrypted,
|
||
})
|
||
if cbErr != nil {
|
||
t.Fatalf("callback: %v", cbErr)
|
||
}
|
||
|
||
// 轮询确认
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
status, err := svc.Poll115OAuth(context.Background(), result.SessionID)
|
||
if err != nil {
|
||
t.Fatalf("poll: %v", err)
|
||
}
|
||
if status.Status == "confirmed" {
|
||
break
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
updated, err := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
|
||
if err != nil || updated == nil {
|
||
t.Fatal("account missing")
|
||
}
|
||
cfg, _ := svc.strmAccountConfig(updated)
|
||
if cfg["access_token"] != "relay-at" {
|
||
t.Fatalf("relay token not saved: %#v", cfg)
|
||
}
|
||
}
|
||
|
||
// Test115TokenRefreshLoop 令牌刷新与失效处理。
|
||
func Test115TokenRefreshLoop(t *testing.T) {
|
||
svc := testStrmService(t)
|
||
var refreshCalls int
|
||
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/open/refreshToken" {
|
||
t.Errorf("unexpected path %s", r.URL.Path)
|
||
}
|
||
refreshCalls++
|
||
if strings.Contains(r.PostFormValue("refresh_token"), "dead") {
|
||
w.Write([]byte(`{"state":false,"code":40140119,"message":"已过期"}`))
|
||
return
|
||
}
|
||
w.Write([]byte(`{"state":true,"data":{"access_token":"new-at","refresh_token":"new-rt","expires_in":7200}}`))
|
||
}))
|
||
defer passport.Close()
|
||
old := cloud115.PassportAPIBase
|
||
cloud115.PassportAPIBase = passport.URL
|
||
defer func() { cloud115.PassportAPIBase = old }()
|
||
|
||
cfg := map[string]string{
|
||
"app_id": "100195125", "access_token": "at1", "refresh_token": "rt1",
|
||
}
|
||
acct, err := svc.CreateStrmAccount(context.Background(), "刷新测试", model.StrmProvider115, cfg)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc.refresh115TokensOnce(context.Background())
|
||
updated, _ := svc.repo.StrmAccount.FindByID(context.Background(), acct.ID)
|
||
updatedCfg, _ := svc.strmAccountConfig(updated)
|
||
if updatedCfg["access_token"] != "new-at" || updatedCfg["refresh_token"] != "new-rt" {
|
||
t.Fatalf("token not refreshed: %#v", updatedCfg)
|
||
}
|
||
|
||
// 失效的 refresh_token:access_token 清空 + 标记失败
|
||
deadCfg := map[string]string{"app_id": "100195125", "access_token": "at-d", "refresh_token": "rt-dead"}
|
||
deadAcct, err := svc.CreateStrmAccount(context.Background(), "失效测试", model.StrmProvider115, deadCfg)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc.refresh115TokensOnce(context.Background())
|
||
deadUpdated, _ := svc.repo.StrmAccount.FindByID(context.Background(), deadAcct.ID)
|
||
deadUpdatedCfg, _ := svc.strmAccountConfig(deadUpdated)
|
||
if deadUpdatedCfg["access_token"] != "" {
|
||
t.Fatalf("dead account access_token should be cleared: %#v", deadUpdatedCfg)
|
||
}
|
||
if deadUpdated.LastTestOK {
|
||
t.Fatal("dead account should be marked failed")
|
||
}
|
||
}
|