package baidu import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path" "reflect" "strconv" "strings" "sync" "testing" "time" "gitea.dddbg.com/youbin/bdrclone/internal/config" ) func TestRemotePathWithRoot(t *testing.T) { client := New(testConfig("token")) for input, want := range map[string]string{ "/": "/apps/bdrclone", "docs/a.txt": "/apps/bdrclone/docs/a.txt", "/../a": "/apps/bdrclone/a", } { if got := client.RemotePath(input); got != want { t.Errorf("RemotePath(%q) = %q, want %q", input, got, want) } } if got := client.APIPath("/apps/bdrclone/docs/a.txt"); got != "/docs/a.txt" { t.Fatalf("APIPath = %q", got) } } func TestExpiredTokenRefreshesAndPersists(t *testing.T) { var saved Token var listCalls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/oauth/2.0/token": if r.URL.Query().Get("refresh_token") != "refresh-old" { t.Errorf("unexpected refresh token: %q", r.URL.Query().Get("refresh_token")) } fmt.Fprint(w, `{"access_token":"access-new","refresh_token":"refresh-new","expires_in":3600}`) case "/rest/2.0/xpan/file": listCalls++ if r.URL.Query().Get("access_token") != "access-new" { t.Errorf("unexpected access token: %q", r.URL.Query().Get("access_token")) } if got := r.Header.Get("User-Agent"); got != "pan.baidu.com" { t.Errorf("User-Agent = %q", got) } fmt.Fprint(w, `{"errno":0,"list":[]}`) default: http.NotFound(w, r) } })) defer server.Close() cfg := testConfig("expired") cfg.RefreshToken = "refresh-old" cfg.ExpiresAt = time.Now().Add(-time.Hour) client := New(cfg, WithEndpoints(server.URL, server.URL, server.URL), WithTokenSaver(func(token Token, _ time.Time) error { saved = token return nil })) if _, err := client.List(context.Background(), "/"); err != nil { t.Fatal(err) } if saved.AccessToken != "access-new" || saved.RefreshToken != "refresh-new" { t.Fatalf("saved token = %+v", saved) } if listCalls != 1 { t.Fatalf("list calls = %d", listCalls) } } func TestOpenUsesOfficialUserAgentAndRange(t *testing.T) { var server *httptest.Server metadataCalls := 0 server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/rest/2.0/xpan/multimedia": metadataCalls++ fmt.Fprintf(w, `{"errno":0,"list":[{"fs_id":42,"dlink":%q}]}`, server.URL+"/download?x=1") case "/download": if got := r.Header.Get("User-Agent"); got != "pan.baidu.com" { t.Errorf("User-Agent = %q", got) } if got := r.Header.Get("Range"); got != "bytes=2-5" { t.Errorf("Range = %q", got) } if got := r.URL.Query().Get("access_token"); got != "token" { t.Errorf("access_token = %q", got) } w.WriteHeader(http.StatusPartialContent) fmt.Fprint(w, "2345") default: http.NotFound(w, r) } })) defer server.Close() client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL)) body, err := client.Open(context.Background(), File{FSID: 42, Path: "/a"}, 2, 4) if err != nil { t.Fatal(err) } defer body.Close() b, err := io.ReadAll(body) if err != nil { t.Fatal(err) } if string(b) != "2345" { t.Fatalf("body = %q", b) } body, err = client.Open(context.Background(), File{FSID: 42, Path: "/a"}, 2, 4) if err != nil { t.Fatal(err) } body.Close() if metadataCalls != 1 { t.Fatalf("download metadata calls = %d, want cached URL to be reused", metadataCalls) } } func TestUploadMultipartFlow(t *testing.T) { content := append(bytes.Repeat([]byte("a"), int(defaultPartSize)), []byte("tail")...) file, err := os.CreateTemp(t.TempDir(), "upload-*") if err != nil { t.Fatal(err) } if _, err := file.Write(content); err != nil { t.Fatal(err) } var mu sync.Mutex parts := map[int][]byte{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/rest/2.0/xpan/file": if err := r.ParseForm(); err != nil { t.Error(err) } switch r.URL.Query().Get("method") { case "precreate": if r.Form.Get("path") != "/apps/bdrclone/remote.bin" { t.Errorf("precreate path = %q", r.Form.Get("path")) } var blocks []string if err := json.Unmarshal([]byte(r.Form.Get("block_list")), &blocks); err != nil || len(blocks) != 2 { t.Errorf("block list = %q, err=%v", r.Form.Get("block_list"), err) } fmt.Fprint(w, `{"errno":0,"return_type":1,"uploadid":"upload-1","block_list":[0,1]}`) case "create": if r.Form.Get("uploadid") != "upload-1" { t.Errorf("create uploadid = %q", r.Form.Get("uploadid")) } fmt.Fprint(w, `{"errno":0,"fs_id":99,"path":"/apps/bdrclone/remote.bin","server_filename":"remote.bin","size":4194308}`) default: http.Error(w, "unexpected method", http.StatusBadRequest) } case "/rest/2.0/pcs/superfile2": part, _ := strconv.Atoi(r.URL.Query().Get("partseq")) if r.URL.Query().Get("uploadid") != "upload-1" { t.Errorf("part uploadid = %q", r.URL.Query().Get("uploadid")) } if err := r.ParseMultipartForm(defaultPartSize + 1024); err != nil { t.Error(err) return } partFile, _, err := r.FormFile("file") if err != nil { t.Error(err) return } b, err := io.ReadAll(partFile) partFile.Close() if err != nil { t.Error(err) } mu.Lock() parts[part] = b mu.Unlock() fmt.Fprint(w, `{"md5":"ok"}`) default: http.NotFound(w, r) } })) defer server.Close() cfg := testConfig("token") cfg.PartSize = defaultPartSize cfg.UploadParts = 2 client := New(cfg, WithEndpoints(server.URL, server.URL, server.URL)) entry, err := client.Upload(context.Background(), file, int64(len(content)), "/remote.bin", time.Unix(1_700_000_000, 0), nil) if err != nil { t.Fatal(err) } if entry.FSID != 99 { t.Fatalf("entry = %+v", entry) } mu.Lock() defer mu.Unlock() if !bytes.Equal(parts[0], content[:defaultPartSize]) || !bytes.Equal(parts[1], content[defaultPartSize:]) { t.Fatalf("uploaded parts do not match input: sizes %d, %d", len(parts[0]), len(parts[1])) } } func TestUploadPartRetriesGatewayTimeout(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "retry-upload-*") if err != nil { t.Fatal(err) } defer file.Close() content := []byte("retry payload") if _, err := file.Write(content); err != nil { t.Fatal(err) } attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/rest/2.0/pcs/superfile2" { http.NotFound(w, r) return } attempts++ _, _ = io.Copy(io.Discard, r.Body) if attempts < 4 { http.Error(w, "gateway timeout", http.StatusGatewayTimeout) return } fmt.Fprint(w, `{"md5":"ok"}`) })) defer server.Close() client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL)) client.uploadRetryDelay = func(int) time.Duration { return 0 } if err := client.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-1", 0, 0, int64(len(content))); err != nil { t.Fatal(err) } if attempts != 4 { t.Fatalf("attempts = %d, want 4", attempts) } failureAttempts := 0 failureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { failureAttempts++ _, _ = io.Copy(io.Discard, r.Body) http.Error(w, "gateway timeout", http.StatusGatewayTimeout) })) defer failureServer.Close() failureClient := New(testConfig("token"), WithEndpoints(failureServer.URL, failureServer.URL, failureServer.URL)) failureClient.uploadRetryDelay = func(int) time.Duration { return 0 } err = failureClient.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-2", 0, 0, int64(len(content))) if err == nil || !strings.Contains(err.Error(), "after 6 attempts") { t.Fatalf("error = %v", err) } if failureAttempts != maxUploadAttempts { t.Fatalf("attempts = %d, want %d", failureAttempts, maxUploadAttempts) } } func TestMkdirAllCreatesMissingRemoteDirectories(t *testing.T) { directories := map[string]bool{"/apps/bdrclone": true} var created []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/rest/2.0/xpan/file" { http.NotFound(w, r) return } switch r.URL.Query().Get("method") { case "list": parent := r.URL.Query().Get("dir") entries := make([]File, 0) for directory := range directories { if directory != parent && path.Dir(directory) == parent { entries = append(entries, File{Path: directory, ServerFilename: path.Base(directory), IsDir: 1}) } } _ = json.NewEncoder(w).Encode(map[string]any{"errno": 0, "list": entries}) case "create": if err := r.ParseForm(); err != nil { t.Error(err) return } directory := r.Form.Get("path") if r.Form.Get("isdir") != "1" { t.Errorf("isdir = %q", r.Form.Get("isdir")) } directories[directory] = true created = append(created, directory) _ = json.NewEncoder(w).Encode(File{Path: directory, ServerFilename: path.Base(directory), IsDir: 1}) default: http.Error(w, "unexpected method", http.StatusBadRequest) } })) defer server.Close() client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL)) if err := client.MkdirAll(context.Background(), "/backup/mac/photos"); err != nil { t.Fatal(err) } if err := client.MkdirAll(context.Background(), "/backup/mac/photos"); err != nil { t.Fatal(err) } want := []string{"/apps/bdrclone/backup", "/apps/bdrclone/backup/mac", "/apps/bdrclone/backup/mac/photos"} if !reflect.DeepEqual(created, want) { t.Fatalf("created = %q, want %q", created, want) } } func testConfig(token string) *config.Config { return &config.Config{ ClientID: "client", ClientSecret: "secret", AccessToken: token, Root: "/apps/bdrclone", UserAgent: "pan.baidu.com", UploadParts: 1, } }