package cloud115 import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // mockTransport 用 httptest server 替换 API 基址。 func mockAPI(t *testing.T, handler http.HandlerFunc) func() { t.Helper() pro := httptest.NewServer(handler) passport := httptest.NewServer(handler) qr := httptest.NewServer(handler) oldPro, oldPassport, oldQR := ProAPIBase, PassportAPIBase, QRCodeAPIBase ProAPIBase, PassportAPIBase, QRCodeAPIBase = pro.URL, passport.URL, qr.URL t.Cleanup(func() { ProAPIBase, PassportAPIBase, QRCodeAPIBase = oldPro, oldPassport, oldQR pro.Close() passport.Close() qr.Close() }) return func() {} } func TestGetQrCode(t *testing.T) { var called bool mockAPI(t, func(w http.ResponseWriter, r *http.Request) { called = true if r.URL.Path != "/open/authDeviceCode" { t.Errorf("unexpected path %s", r.URL.Path) } if err := r.ParseForm(); err != nil { t.Fatal(err) } if r.PostFormValue("client_id") != "100195125" { t.Errorf("bad client_id %q", r.PostFormValue("client_id")) } if r.PostFormValue("code_challenge") == "" { t.Errorf("missing code_challenge") } w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`)) }) c := NewOpenClient("100195125", "", "") qr, err := c.GetQrCode() if err != nil { t.Fatalf("get qr: %v", err) } if !called { t.Fatal("request not hit") } if qr.Uid != "U1" || qr.Qrcode == "" || qr.CodeVerifier == "" || len(qr.CodeVerifier) != 64 { t.Fatalf("bad qr data: %#v", qr) } } func TestQrCodeScanStatusSequence(t *testing.T) { calls := 0 mockAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/get/status/" { t.Errorf("unexpected path %s", r.URL.Path) } calls++ switch calls { 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}}`)) } }) c := NewOpenClient("", "", "") code := &QrCodeData{Uid: "U1", Time: 1700, Sign: "S1"} want := []QrCodeScanStatus{QrCodeScanStatusNotScanned, QrCodeScanStatusScanned, QrCodeScanStatusConfirmed} for i, exp := range want { got, err := c.QrCodeScanStatus(code) if err != nil { t.Fatalf("status %d: %v", i, err) } if got != exp { t.Fatalf("status %d: got %v want %v", i, got, exp) } } } func TestGetTokenAndRefresh(t *testing.T) { mockAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/open/deviceCodeToToken": w.Write([]byte(`{"state":true,"data":{"access_token":"at1","refresh_token":"rt1","expires_in":7200}}`)) case "/open/refreshToken": if err := r.ParseForm(); err != nil { t.Fatal(err) } if r.PostFormValue("refresh_token") != "rt2" { t.Errorf("bad refresh_token %q", r.PostFormValue("refresh_token")) } w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`)) default: t.Errorf("unexpected path %s", r.URL.Path) } }) c := NewOpenClient("100195125", "", "") token, err := c.GetToken(&QrCodeDataReturn{QrCodeData: QrCodeData{Uid: "U1"}, CodeVerifier: "v"}) if err != nil { t.Fatalf("get token: %v", err) } if token.AccessToken != "at1" || c.AccessToken != "at1" { t.Fatalf("bad token: %#v", token) } token, err = c.RefreshToken("rt2") if err != nil { t.Fatalf("refresh: %v", err) } if token.AccessToken != "at2" || c.RefreshTokenStr != "rt2" { t.Fatalf("bad refresh result: %#v", token) } } func TestRefreshTokenDead(t *testing.T) { mockAPI(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"state":false,"code":40140119,"message":"refresh_token 已过期"}`)) }) c := NewOpenClient("100195125", "at", "rt-dead") _, err := c.RefreshToken("rt-dead") if err == nil { t.Fatal("expected error") } if !IsRefreshTokenDead(err) { t.Fatalf("expected dead refresh token error, got %v", err) } if c.AccessToken != "" { t.Fatalf("dead token should clear access token") } } func TestFsListAndDownload(t *testing.T) { mockAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/open/ufile/files": if r.Header.Get("Authorization") != "Bearer at1" { t.Errorf("missing auth header") } w.Write([]byte(`{"state":true,"path":[{"name":"根目录","cid":0}],"data":[ {"fid":"100","fc":"0","fn":"Movies","fs":0}, {"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`)) case "/open/ufile/downurl": if err := r.ParseForm(); err != nil { t.Fatal(err) } if r.PostFormValue("pick_code") != "pickA" { t.Errorf("bad pick_code") } w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"a.mkv","url":{"url":"https://cdn/x.mkv"}}}}`)) default: t.Errorf("unexpected path %s", r.URL.Path) } }) c := NewOpenClient("100195125", "at1", "rt1") files, pathStr, err := c.GetFsList(context.Background(), "0", 0, 100) if err != nil { t.Fatalf("list: %v", err) } if len(files) != 2 { t.Fatalf("want 2 files, got %d", len(files)) } if !files[0].IsDir() { t.Fatalf("first entry should be dir") } if pathStr != "根目录" { t.Fatalf("path_str = %q", pathStr) } url, err := c.GetDownloadURL(context.Background(), "pickA") if err != nil { t.Fatalf("downurl: %v", err) } if url != "https://cdn/x.mkv" { t.Fatalf("bad url %q", url) } } func (f RemoteFile) IsDir() bool { return f.Category == TypeDir } func TestGetFsDetailByCid(t *testing.T) { mockAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/open/folder/get_info" { t.Errorf("unexpected path %s", r.URL.Path) } w.Write([]byte(`{"state":true,"data":{"file_id":"100","file_name":"Movies","file_category":"0","size_byte":123}}`)) }) c := NewOpenClient("100195125", "at1", "rt1") detail, err := c.GetFsDetailByCid(context.Background(), "100") if err != nil { t.Fatalf("detail: %v", err) } if detail.FileId != "100" || detail.FileName != "Movies" { t.Fatalf("bad detail: %#v", detail) } } // TestRelayRoundTrip 中继加解密往返 + 回调解析。 func TestRelayRoundTrip(t *testing.T) { RelayEncryptionKey = "unit-test-shared-key" defer func() { RelayEncryptionKey = "" }() payload := `{"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}` encrypted, err := EncryptRelay(payload) if err != nil { t.Fatal(err) } decrypted, err := DecryptRelay(encrypted) if err != nil { t.Fatal(err) } if decrypted != payload { t.Fatalf("round trip mismatch: %q", decrypted) } provider, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"}) if err != nil { t.Fatalf("relay provider should be available when key configured: %v", err) } token, err := provider.Confirm(context.Background(), map[string]string{"data": encrypted}) if err != nil { t.Fatalf("confirm: %v", err) } if !token.Done || token.AccessToken != "at" || token.RefreshToken != "rt" { t.Fatalf("bad token: %#v", token) } } func TestRelayProviderRequiresKey(t *testing.T) { RelayEncryptionKey = "" if _, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"}); err == nil { t.Fatal("relay provider should fail without key") } if RelayAvailable() { t.Fatal("RelayAvailable should be false without key") } } func TestMoviePilotProvider(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/u115/auth_url": w.Write([]byte(`{"auth_url":"https://passport.115/auth?state=ST","state":"ST"}`)) case "/u115/token": if r.URL.Query().Get("state") != "ST" { t.Errorf("bad state") } w.Write([]byte(`{"state":true,"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`)) default: t.Errorf("unexpected path %s", r.URL.Path) } })) defer srv.Close() provider := moviePilotOAuthProvider{authServer: srv.URL} result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{}) if err != nil { t.Fatalf("build: %v", err) } if result.AuthURL == "" || result.State != "ST" || !result.Polling { t.Fatalf("bad result: %#v", result) } token, err := provider.Poll(context.Background(), "ST") if err != nil { t.Fatalf("poll: %v", err) } if !token.Done || token.AccessToken != "at" { t.Fatalf("bad token: %#v", token) } } func TestCloudDriveBuildAuth(t *testing.T) { provider := cloudDriveOAuthProvider{source: Source{SourceType: SourceTypeThirdPartyService, Provider: ProviderCloudDrive, AppID: "100195313"}} result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{ RedirectURL: "http://127.0.0.1:8080/api/strm/oauth/callback", AuthorizationID: "auth-123", }) if err != nil { t.Fatalf("build: %v", err) } if !strings.Contains(result.AuthURL, "client_id=100195313") { t.Fatalf("bad auth url: %s", result.AuthURL) } parsed, err := url.Parse(result.AuthURL) if err != nil { t.Fatalf("parse auth url: %v", err) } state, err := url.QueryUnescape(parsed.Query().Get("state")) if err != nil { t.Fatalf("unescape state: %v", err) } if !strings.Contains(state, "authorization_id=auth-123") { t.Fatalf("missing auth id in state: %s", result.AuthURL) } token, err := provider.Confirm(context.Background(), map[string]string{ "access_token": "at", "refresh_token": "rt", "expires_in": "7200", }) if err != nil { t.Fatalf("confirm: %v", err) } if !token.Done || token.AccessToken != "at" { t.Fatalf("bad token: %#v", token) } } func TestSourceCatalog(t *testing.T) { if len(BuiltInAppIDSources()) < 50 { t.Fatalf("built-in app catalog too small: %d", len(BuiltInAppIDSources())) } if _, ok := FindSource(SourceTypeBuiltInAppID, ProviderOfficialPKCE, "100195125"); !ok { t.Fatal("媒体播放器 app not found") } if len(BuiltInRelaySources()) == 0 || len(ThirdPartySources()) != 2 { t.Fatal("relay/thrid-party sources broken") } } func TestHTTP405AndThrottleRecovery(t *testing.T) { tm := NewThrottleManager(100 * time.Millisecond) qe := NewQueueExecutor(10, 200, 12000) qe.throttleManager = tm mockAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("Method Not Allowed")) }) c := NewOpenClient("100195125", "at1", "rt1") c.executor = qe _, err := c.GetDownloadURL(context.Background(), "pickTest") if err == nil { t.Fatal("expected 405 error") } if !tm.IsThrottled() { t.Fatal("405 should trigger throttle status") } // 验证熔断冷却后能正常恢复 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() if err := tm.WaitThrottleRecovery(ctx); err != nil { t.Fatalf("wait throttle recovery failed: %v", err) } if tm.IsThrottled() { t.Fatal("throttle status should be cleared after duration") } } func TestThrottleCodeHandling(t *testing.T) { tm := NewThrottleManager(100 * time.Millisecond) qe := NewQueueExecutor(10, 200, 12000) qe.throttleManager = tm mockAPI(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"state":false,"code":770004,"message":"访问频率过高"}`)) }) c := NewOpenClient("100195125", "at1", "rt1") c.executor = qe _, _, err := c.GetFsList(context.Background(), "0", 0, 100) if err == nil { t.Fatal("expected throttle error") } if !tm.IsThrottled() { t.Fatal("code 770004 should trigger throttle status") } } func TestRemoteFileDetailRelativePath(t *testing.T) { rootCID := "3238787832374488117" // 影视库 // 场景 1:115 目录 paths 中只有祖先目录链,不包含自身 d1 := &RemoteFileDetail{ FileId: "3251154147730910635", FileName: "出包王女", Paths: []struct { FileId string `json:"file_id"` Name string `json:"file_name"` }{ {FileId: "0", Name: "根目录"}, {FileId: "3238787832374488117", Name: "影视库"}, {FileId: "3238787913223892116", Name: "动漫"}, }, } if got := d1.RelativePath(rootCID); got != "动漫/出包王女" { t.Errorf("d1.RelativePath = %q, want %q", got, "动漫/出包王女") } // 场景 2:祖先中间目录,自身在 paths 末尾 d2 := &RemoteFileDetail{ FileId: "3238787913223892116", FileName: "动漫", Paths: []struct { FileId string `json:"file_id"` Name string `json:"file_name"` }{ {FileId: "0", Name: "根目录"}, {FileId: "3238787832374488117", Name: "影视库"}, {FileId: "3238787913223892116", Name: "动漫"}, }, } if got := d2.RelativePath(rootCID); got != "动漫" { t.Errorf("d2.RelativePath = %q, want %q", got, "动漫") } // 场景 3:根同步目录自身 d3 := &RemoteFileDetail{ FileId: rootCID, FileName: "影视库", Paths: []struct { FileId string `json:"file_id"` Name string `json:"file_name"` }{ {FileId: "0", Name: "根目录"}, {FileId: rootCID, Name: "影视库"}, }, } if got := d3.RelativePath(rootCID); got != "" { t.Errorf("d3.RelativePath = %q, want %q", got, "") } } // TestFsListRefreshContinue 验证 access_token 在请求中途过期(40140126)时: // 自动用 refresh_token 刷新得到新 token,然后对原请求重试成功(同步得以继续)。 func TestFsListRefreshContinue(t *testing.T) { var filesCalls int var refreshCalls int mockAPI(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/open/refreshToken": refreshCalls++ w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`)) case "/open/ufile/files": filesCalls++ switch filesCalls { case 1: // 第一次用旧 access_token,返回过期错误,应触发刷新 w.Write([]byte(`{"state":false,"code":40140126,"message":"access_token 校验失败"}`)) default: // 刷新后续请求应使用新 access_token if got := r.Header.Get("Authorization"); got != "Bearer at2" { t.Errorf("retried request auth = %q, want Bearer at2", got) } w.Write([]byte(`{"state":true,"path":[],"data":[{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`)) } default: t.Errorf("unexpected path %s", r.URL.Path) } }) c := NewOpenClient("100195125", "at1", "rt1") files, _, err := c.GetFsList(context.Background(), "0", 0, 100) if err != nil { t.Fatalf("expected sync to continue after refresh, got error: %v", err) } if filesCalls != 2 { t.Fatalf("want 2 files calls (original + retried), got %d", filesCalls) } if refreshCalls == 0 { t.Fatal("expected refresh_token to be used once") } if len(files) != 1 { t.Fatalf("want 1 file, got %d", len(files)) } }