Files
91/backend/internal/drives/onedrive/driver_test.go
T
nianzhibai 7e5e67697e feat: add GuangYaPan drive support
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow.

Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation.

Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition.

Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
2026-06-14 15:44:50 +08:00

671 lines
19 KiB
Go

package onedrive
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/video-site/backend/internal/drives"
)
func TestInitRefreshesTokenThroughOpenListOnlineAPIAndPersistsUpdate(t *testing.T) {
var tokenRequestSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/renewapi" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
tokenRequestSeen = true
want := map[string]string{
"refresh_ui": "old-refresh",
"server_use": "true",
"driver_txt": "onedrive_pr",
}
for key, value := range want {
if got := r.URL.Query().Get(key); got != value {
t.Fatalf("query %s = %q, want %q", key, got, value)
}
}
writeJSON(t, w, map[string]any{
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 3600,
})
}))
defer srv.Close()
var persistedAccess, persistedRefresh string
d := New(Config{
ID: "od-main",
RefreshToken: "old-refresh",
RenewAPIURL: srv.URL + "/renewapi",
APIBaseURL: srv.URL,
OnTokenUpdate: func(access, refresh string) {
persistedAccess = access
persistedRefresh = refresh
},
})
if d.Kind() != "onedrive" {
t.Fatalf("kind = %q, want onedrive", d.Kind())
}
if d.ID() != "od-main" {
t.Fatalf("id = %q, want od-main", d.ID())
}
if d.RootID() != "root" {
t.Fatalf("root id = %q, want root", d.RootID())
}
if err := d.Init(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
if !tokenRequestSeen {
t.Fatal("OpenList renew API was not called")
}
if persistedAccess != "new-access" || persistedRefresh != "new-refresh" {
t.Fatalf("persisted tokens = %q/%q, want new-access/new-refresh", persistedAccess, persistedRefresh)
}
}
func TestListFollowsPaginationAndMapsEntries(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
switch r.URL.Path {
case "/v1.0/me/drive/items/root/children":
if r.URL.Query().Get("$top") != "1000" {
t.Fatalf("$top = %q, want 1000", r.URL.Query().Get("$top"))
}
writeJSON(t, w, map[string]any{
"value": []map[string]any{
{
"id": "folder-id",
"name": "Movies",
"size": 0,
"folder": map[string]any{
"childCount": 2,
},
"fileSystemInfo": map[string]any{
"lastModifiedDateTime": "2026-05-10T12:30:00Z",
},
"parentReference": map[string]any{
"id": "root",
},
},
{
"id": "file-id",
"name": "demo.mp4",
"size": 12345,
"file": map[string]any{
"mimeType": "video/mp4",
},
"fileSystemInfo": map[string]any{
"lastModifiedDateTime": "2026-05-10T13:30:00Z",
},
"thumbnails": []map[string]any{
{"medium": map[string]any{"url": "https://thumb.example/demo.jpg"}},
},
"parentReference": map[string]any{
"id": "root",
},
},
},
"@odata.nextLink": srv.URL + "/next-page",
})
case "/next-page":
writeJSON(t, w, map[string]any{
"value": []map[string]any{
{
"id": "file-2",
"name": "second.mkv",
"size": 77,
"file": map[string]any{
"mimeType": "video/x-matroska",
},
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
got, err := d.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 3 {
t.Fatalf("entries len = %d, want 3", len(got))
}
if !got[0].IsDir || got[0].ID != "folder-id" || got[0].ParentID != "root" {
t.Fatalf("folder entry = %#v", got[0])
}
if got[1].IsDir || got[1].MimeType != "video/mp4" || got[1].ThumbnailURL != "" {
t.Fatalf("file entry = %#v", got[1])
}
if got[1].ModTime.IsZero() {
t.Fatal("file mod time should be parsed")
}
if got[2].Name != "second.mkv" || got[2].Size != 77 {
t.Fatalf("paginated entry = %#v", got[2])
}
}
func TestGraphItemWithoutFolderFacetIsNotDirectory(t *testing.T) {
got := itemToEntry(graphItem{
ID: "special-id",
Name: "个人保管库",
}, "root")
if got.IsDir {
t.Fatalf("special Graph item without folder facet should not be treated as a directory: %#v", got)
}
}
func TestGraph429ReturnsRateLimitErrorWithRetryAfter(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "120")
w.WriteHeader(http.StatusTooManyRequests)
if err := json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "TooManyRequests",
"message": "throttled",
},
}); err != nil {
t.Fatalf("write json: %v", err)
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
_, err := d.StreamURL(context.Background(), "file-id")
if err == nil {
t.Fatal("list succeeded, want rate limit error")
}
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != 2*time.Minute {
t.Fatalf("retry after = %v, want 2m", rateLimit.RetryAfter)
}
}
func TestGraphThrottleMessageDoesNotReturnRateLimitError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
if err := json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "generalException",
"message": "The request has been throttled. Please try again later.",
},
}); err != nil {
t.Fatalf("write json: %v", err)
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
_, err := d.StreamURL(context.Background(), "file-id")
if err == nil {
t.Fatal("list succeeded, want graph error")
}
var rateLimit *drives.RateLimitError
if errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
}
}
func TestListCoolsDownAndRetriesOneDriveRateLimit(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1.0/me/drive/items/root/children" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
calls++
if calls == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
if err := json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "TooManyRequests",
"message": "throttled",
},
}); err != nil {
t.Fatalf("write json: %v", err)
}
return
}
writeJSON(t, w, map[string]any{
"value": []map[string]any{
{
"id": "file-id",
"name": "demo.mp4",
"size": 100,
"file": map[string]any{"mimeType": "video/mp4"},
},
},
})
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
d.listInterval = 0
d.listCooldown = time.Millisecond
got, err := d.List(context.Background(), "root")
if err != nil {
t.Fatalf("list: %v", err)
}
if calls != 2 {
t.Fatalf("calls = %d, want retry after rate limit", calls)
}
if len(got) != 1 || got[0].ID != "file-id" {
t.Fatalf("entries = %#v, want retried file", got)
}
}
func TestStatAndStreamURLUseDriveItemMetadata(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
if r.Method != http.MethodGet || r.URL.Path != "/v1.0/me/drive/items/file-id" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
writeJSON(t, w, map[string]any{
"id": "file-id",
"name": "movie.mov",
"size": 2048,
"file": map[string]any{
"mimeType": "video/quicktime",
},
"parentReference": map[string]any{
"id": "parent-id",
},
"@microsoft.graph.downloadUrl": "https://download.example/movie.mov",
})
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
entry, err := d.Stat(context.Background(), "file-id")
if err != nil {
t.Fatalf("stat: %v", err)
}
if entry.ID != "file-id" || entry.Name != "movie.mov" || entry.ParentID != "parent-id" {
t.Fatalf("entry = %#v", entry)
}
link, err := d.StreamURL(context.Background(), "file-id")
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != "https://download.example/movie.mov" {
t.Fatalf("stream url = %q, want download url", link.URL)
}
if len(link.Headers) != 0 {
t.Fatalf("headers = %#v, want none", link.Headers)
}
}
func TestEnsureDirCreatesMissingFolders(t *testing.T) {
var created []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1.0/me/drive/items/root/children":
writeJSON(t, w, map[string]any{
"value": []map[string]any{
{
"id": "existing-id",
"name": "existing",
"folder": map[string]any{},
},
},
})
case r.Method == http.MethodGet && r.URL.Path == "/v1.0/me/drive/items/existing-id/children":
writeJSON(t, w, map[string]any{"value": []map[string]any{}})
case r.Method == http.MethodPost && r.URL.Path == "/v1.0/me/drive/items/existing-id/children":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode mkdir body: %v", err)
}
created = append(created, body["name"].(string))
if body["@microsoft.graph.conflictBehavior"] != "rename" {
t.Fatalf("conflict behavior = %#v, want rename", body["@microsoft.graph.conflictBehavior"])
}
writeJSON(t, w, map[string]any{
"id": "created-id",
"name": body["name"],
"folder": map[string]any{},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
got, err := d.EnsureDir(context.Background(), "/existing/previews")
if err != nil {
t.Fatalf("ensure dir: %v", err)
}
if got != "created-id" {
t.Fatalf("dir id = %q, want created-id", got)
}
if len(created) != 1 || created[0] != "previews" {
t.Fatalf("created folders = %#v, want previews", created)
}
}
func TestRenamePatchesDriveItemName(t *testing.T) {
var body map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.EscapedPath() != "/v1.0/me/drive/items/file-id" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
writeJSON(t, w, map[string]any{"id": "file-id", "name": "new name.mp4"})
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
if err := d.Rename(context.Background(), "file-id", "new name.mp4"); err != nil {
t.Fatalf("rename: %v", err)
}
if body["name"] != "new name.mp4" {
t.Fatalf("rename body = %#v, want new name", body)
}
}
func TestUploadSmallFileReturnsNewItemID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
if r.Method != http.MethodPut || r.URL.EscapedPath() != "/v1.0/me/drive/items/parent-id:/preview%20file.mp4:/content" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read upload body: %v", err)
}
if string(data) != "preview-bytes" {
t.Fatalf("upload body = %q, want preview-bytes", string(data))
}
writeJSON(t, w, map[string]any{
"id": "uploaded-id",
"name": "preview file.mp4",
})
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
got, err := d.Upload(context.Background(), "parent-id", "preview file.mp4", strings.NewReader("preview-bytes"), int64(len("preview-bytes")))
if err != nil {
t.Fatalf("upload: %v", err)
}
if got != "uploaded-id" {
t.Fatalf("uploaded id = %q, want uploaded-id", got)
}
}
func TestUploadLargeFileUsesUploadSessionAndReportsHash(t *testing.T) {
oldThreshold := smallUploadThreshold
oldChunk := uploadSessionChunk
smallUploadThreshold = 8
uploadSessionChunk = 4
t.Cleanup(func() {
smallUploadThreshold = oldThreshold
uploadSessionChunk = oldChunk
})
body := "0123456789abc"
var ranges []string
var chunks []string
var createdSession bool
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.EscapedPath() == "/v1.0/me/drive/items/parent-id:/big.mp4:/createUploadSession":
createdSession = true
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("authorization = %q, want bearer token", got)
}
writeJSON(t, w, map[string]any{"uploadUrl": srv.URL + "/upload-session"})
case r.Method == http.MethodPut && r.URL.Path == "/upload-session":
ranges = append(ranges, r.Header.Get("Content-Range"))
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read chunk: %v", err)
}
chunks = append(chunks, string(data))
if len(ranges) < 4 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
if _, err := w.Write([]byte(`{"nextExpectedRanges":["0-"]}`)); err != nil {
t.Fatalf("write accepted: %v", err)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]any{"id": "uploaded-big-id"}); err != nil {
t.Fatalf("write final item: %v", err)
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "access-token",
RefreshToken: "refresh-token",
APIBaseURL: srv.URL,
})
got, err := d.UploadAndReportHash(context.Background(), "parent-id", "big.mp4", strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("upload: %v", err)
}
if !createdSession {
t.Fatal("createUploadSession was not called")
}
wantRanges := []string{
"bytes 0-3/13",
"bytes 4-7/13",
"bytes 8-11/13",
"bytes 12-12/13",
}
if strings.Join(ranges, "|") != strings.Join(wantRanges, "|") {
t.Fatalf("ranges = %#v, want %#v", ranges, wantRanges)
}
if strings.Join(chunks, "") != body {
t.Fatalf("uploaded chunks = %q, want %q", strings.Join(chunks, ""), body)
}
sum := sha1.Sum([]byte(body))
if got.FileID != "uploaded-big-id" || got.Size != int64(len(body)) || got.Hash != hex.EncodeToString(sum[:]) {
t.Fatalf("upload result = %#v, want file id/hash/size for body", got)
}
}
func TestUploadRefreshesExpiredTokenAndReplaysBody(t *testing.T) {
var uploadAttempts int
var tokenRefreshes int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && r.URL.EscapedPath() == "/v1.0/me/drive/items/parent-id:/preview.mp4:/content":
uploadAttempts++
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read upload body: %v", err)
}
if string(data) != "preview-bytes" {
t.Fatalf("upload attempt %d body = %q, want preview-bytes", uploadAttempts, string(data))
}
if uploadAttempts == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
if err := json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "InvalidAuthenticationToken",
"message": "token expired",
},
}); err != nil {
t.Fatalf("write json: %v", err)
}
return
}
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
t.Fatalf("retry authorization = %q, want new access token", got)
}
writeJSON(t, w, map[string]any{"id": "uploaded-id"})
case r.Method == http.MethodGet && r.URL.Path == "/renewapi":
tokenRefreshes++
if got := r.URL.Query().Get("refresh_ui"); got != "old-refresh" {
t.Fatalf("refresh_ui = %q, want old-refresh", got)
}
writeJSON(t, w, map[string]any{
"access_token": "new-access",
"refresh_token": "new-refresh",
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer srv.Close()
d := New(Config{
ID: "od-main",
AccessToken: "expired-access",
RefreshToken: "old-refresh",
RenewAPIURL: srv.URL + "/renewapi",
APIBaseURL: srv.URL,
})
got, err := d.Upload(context.Background(), "parent-id", "preview.mp4", strings.NewReader("preview-bytes"), int64(len("preview-bytes")))
if err != nil {
t.Fatalf("upload: %v", err)
}
if got != "uploaded-id" {
t.Fatalf("uploaded id = %q, want uploaded-id", got)
}
if uploadAttempts != 2 || tokenRefreshes != 1 {
t.Fatalf("attempts/refreshes = %d/%d, want 2/1", uploadAttempts, tokenRefreshes)
}
}
func TestSharePointUsesSiteDriveBase(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/renewapi":
if r.Method != http.MethodGet {
t.Fatalf("renew method = %s, want GET", r.Method)
}
writeJSON(t, w, map[string]any{
"access_token": "access-token",
"refresh_token": "refresh-token",
})
case "/v1.0/sites/site-123/drive/items/root/children":
writeJSON(t, w, map[string]any{"value": []map[string]any{}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer srv.Close()
d := New(Config{
ID: "od-sp",
RefreshToken: "old-refresh",
IsSharePoint: true,
SiteID: "site-123",
RenewAPIURL: srv.URL + "/renewapi",
APIBaseURL: srv.URL,
})
if err := d.Init(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
if _, err := d.List(context.Background(), "root"); err != nil {
t.Fatalf("list: %v", err)
}
}
func TestDriverImplementsInterface(t *testing.T) {
var _ drives.Drive = (*Driver)(nil)
}
func writeJSON(t *testing.T, w http.ResponseWriter, body any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("write json: %v", err)
}
}