mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 07:45:30 +00:00
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
|
"github.com/GopeedLab/gopeed/pkg/base"
|
|
"github.com/GopeedLab/gopeed/pkg/download"
|
|
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
|
|
)
|
|
|
|
func TestResolveThenCreateWithDownloadOptions(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Disposition", `attachment; filename="payload.txt"`)
|
|
w.Header().Set("Content-Length", "4")
|
|
_, _ = w.Write([]byte("test"))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir()})
|
|
if err := d.Setup(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := d.Close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
// Resolving must work even when the default download directory is unusable.
|
|
blocked := filepath.Join(t.TempDir(), "not-a-directory")
|
|
if err := os.WriteFile(blocked, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
current, err := d.GetConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config := *current
|
|
config.DownloadDir = blocked
|
|
config.MaxRunning = 0 // Inspect the created task before it starts downloading.
|
|
if err := d.PutConfig(&config); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{
|
|
Req: &base.Request{URL: upstream.URL},
|
|
})
|
|
if resolved.ID == "" || resolved.Res.Size != 4 || resolved.Res.Files[0].Name != "payload.txt" {
|
|
t.Fatalf("unexpected resolved resource: %+v", resolved)
|
|
}
|
|
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
|
|
opts := &base.Options{Path: dir, Name: "renamed.txt", SelectFiles: []int{0}}
|
|
id := serviceTestRequest[string](t, service, http.MethodPost, "/api/v1/tasks", &restmodel.CreateTask{Rid: resolved.ID, Opts: opts})
|
|
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+id, nil)
|
|
if task.Meta.Opts.Path != dir || task.Meta.Opts.Name != opts.Name || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, opts.SelectFiles) {
|
|
t.Fatalf("creation lost submitted download options: %+v", task.Meta.Opts)
|
|
}
|
|
}
|
|
|
|
func serviceTestRequest[T any](t *testing.T, service *Service, method, path string, body any) T {
|
|
t.Helper()
|
|
encoded, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, response := service.JSON(method, path, nil, encoded, nil)
|
|
var result restmodel.Result[T]
|
|
if err := json.Unmarshal(response, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != http.StatusOK || result.Code != restmodel.CodeOk {
|
|
t.Fatalf("%s %s failed: status=%d response=%s", method, path, status, response)
|
|
}
|
|
return result.Data
|
|
}
|
|
|
|
func TestFullConfigDispatch(t *testing.T) {
|
|
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
|
if err := d.Setup(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.Close()
|
|
service, err := NewService(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, []byte(`{"protocolConfig":{"http":{"connections":3,"useServerCtime":false}}}`), nil)
|
|
if response.StatusCode != http.StatusOK {
|
|
t.Fatalf("%+v", response)
|
|
}
|
|
status, body := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
|
var result struct {
|
|
Data struct {
|
|
ProtocolConfig map[string]struct {
|
|
Connections int `json:"connections"`
|
|
} `json:"protocolConfig"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != http.StatusOK || result.Data.ProtocolConfig["http"].Connections != 3 {
|
|
t.Fatalf("%d %s", status, body)
|
|
}
|
|
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/http/jobs/missing/trigger", nil, nil, nil); response.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("%+v", response)
|
|
}
|
|
}
|
|
|
|
func TestNativePathParametersAreDecodedOnce(t *testing.T) {
|
|
route := newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", func(c *Context) *Response { return newOKResponse(c.PathParam("id")) })
|
|
for _, test := range []struct{ path, want string }{
|
|
{"/api/v1/tasks/%E5%BC%80%E5%8F%91%E8%80%85%40name/status", "开发者@name"},
|
|
{"/api/v1/tasks/literal%252Fname/status", "literal%2Fname"},
|
|
} {
|
|
response := (&Service{routes: []RouteSpec{route}}).Dispatch(http.MethodGet, test.path, nil, nil, nil)
|
|
raw, _ := json.Marshal(response.Body)
|
|
var body struct {
|
|
Data string `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(raw, &body)
|
|
if body.Data != test.want {
|
|
t.Fatalf("%s => %q", test.path, body.Data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMirrorCandidates(t *testing.T) {
|
|
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
|
if err := d.Setup(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.Close()
|
|
cfg, _ := d.GetConfig()
|
|
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
|
|
{Type: "jsdelivr", URL: "https://cdn.example/gh"},
|
|
{Type: "ghProxy", URL: "https://proxy.example"},
|
|
{Type: "ghProxy", URL: "https://proxy.example"},
|
|
}}
|
|
if err := d.PutConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, official := range []string{
|
|
"https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip",
|
|
"https://raw.githubusercontent.com/owner/repo/main/trackers.txt",
|
|
} {
|
|
request, _ := json.Marshal(map[string]string{"url": official})
|
|
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil)
|
|
var body struct {
|
|
Data struct {
|
|
URLs []string `json:"urls"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := 2
|
|
if official == "https://raw.githubusercontent.com/owner/repo/main/trackers.txt" {
|
|
want = 3
|
|
}
|
|
urls := body.Data.URLs
|
|
if status != http.StatusOK || len(urls) != want || urls[0] != official || urls[len(urls)-1] != "https://proxy.example/"+official {
|
|
t.Fatalf("%d %s", status, raw)
|
|
}
|
|
}
|
|
for _, invalid := range []string{"https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10", "", "https://example.com/x", "http://github.com/x", "https://github.com.evil.test/x", "https://[email protected]/x", "https://github.com:443/x", "https://github.com:/x", "https://github.com/x#fragment", "https://github.com/x#", "/relative", "https://github.com%2fevil.test/x"} {
|
|
request, _ := json.Marshal(map[string]string{"url": invalid})
|
|
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil); response.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("accepted %s: %+v", invalid, response)
|
|
}
|
|
}
|
|
cfg.GithubMirror.Enabled = false
|
|
if err := d.PutConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil)
|
|
var body struct {
|
|
Data struct {
|
|
URLs []string `json:"urls"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil || status != http.StatusOK || len(body.Data.URLs) != 1 || body.Data.URLs[0] != "https://github.com/owner/repo" {
|
|
t.Fatalf("%d %s", status, raw)
|
|
}
|
|
|
|
for _, provider := range []string{"unknown", "GitHub"} {
|
|
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/"+provider+"/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil); response.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("unknown provider accepted: %+v", response)
|
|
}
|
|
}
|
|
}
|
|
|
|
type apiJobState struct {
|
|
Runs int `json:"runs"`
|
|
}
|
|
type apiJobConfig struct {
|
|
fetcher.ConfigState[apiJobState]
|
|
Writable int `json:"writable"`
|
|
}
|
|
type apiStateJobManager struct{ httpProtocol.FetcherManager }
|
|
|
|
func (*apiStateJobManager) Name() string { return "state-api" }
|
|
func (*apiStateJobManager) DefaultConfig() any { return &apiJobConfig{Writable: 7} }
|
|
func (*apiStateJobManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
|
|
return []fetcher.CronJob{{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error {
|
|
if source == fetcher.TriggerScheduled {
|
|
return fetcher.ErrJobSkipped
|
|
}
|
|
return fetcher.UpdateState[apiJobConfig, apiJobState](host, func(cfg *apiJobConfig, state *apiJobState) error { cfg.Writable = 999; state.Runs++; return nil })
|
|
}}}
|
|
}
|
|
|
|
func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
|
|
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&apiStateJobManager{}}})
|
|
if err := d.Setup(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.Close()
|
|
service, err := NewService(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
put := []byte(`{"protocolConfig":{"state-api":{"writable":9,"state":"ignore forged state"}}}`)
|
|
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
|
|
t.Fatalf("%+v", response)
|
|
}
|
|
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/state-api/jobs/refresh/trigger", nil, nil, nil); response.StatusCode != http.StatusOK {
|
|
t.Fatalf("%+v", response)
|
|
}
|
|
status, raw := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
|
var response struct {
|
|
Data struct {
|
|
ProtocolConfig map[string]apiJobConfig `json:"protocolConfig"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := response.Data.ProtocolConfig["state-api"]
|
|
if status != http.StatusOK || cfg.Writable != 9 || cfg.State.Runs != 1 {
|
|
t.Fatalf("%d %s", status, raw)
|
|
}
|
|
// Replaying an old full PUT must not undo the manual job's state change.
|
|
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
|
|
t.Fatalf("%+v", response)
|
|
}
|
|
_, raw = service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
|
if err := json.Unmarshal(raw, &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Data.ProtocolConfig["state-api"].State.Runs != 1 {
|
|
t.Fatalf("job state overwritten: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestBatchResolvedAndDirectCreation(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "4")
|
|
_, _ = w.Write([]byte("test"))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
|
if err := d.Setup(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { d.Close() })
|
|
cfg, err := d.GetConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.MaxRunning = 0
|
|
if err := d.PutConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, grouped := range []bool{false, true} {
|
|
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{Req: &base.Request{URL: upstream.URL + "/resolved"}})
|
|
result := serviceTestRequest[map[string]json.RawMessage](t, service, http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
|
Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()},
|
|
Reqs: []*base.CreateTaskBatchItem{
|
|
{Rid: "invalid"},
|
|
{Rid: resolved.ID, Req: &base.Request{URL: "unsupported://ignored"}, Opts: &base.Options{SelectFiles: []int{0}}},
|
|
{Req: &base.Request{URL: upstream.URL + "/direct"}},
|
|
},
|
|
})
|
|
var ids []string
|
|
if err := json.Unmarshal(result["taskIds"], &ids); err != nil || len(ids) != 2 {
|
|
t.Fatalf("unexpected task IDs: %+v, %v", result, err)
|
|
}
|
|
wantFields := 1
|
|
if grouped {
|
|
wantFields = 2
|
|
}
|
|
if len(result) != wantFields {
|
|
t.Fatalf("unexpected response fields: %+v", result)
|
|
}
|
|
var parentID string
|
|
if grouped {
|
|
if err := json.Unmarshal(result["parentId"], &parentID); err != nil || parentID == "" {
|
|
t.Fatalf("missing parent ID: %+v", result)
|
|
}
|
|
}
|
|
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+ids[0], nil)
|
|
if task.ParentID != parentID || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{0}) {
|
|
t.Fatalf("resolved task lost membership or selection: %+v", task)
|
|
}
|
|
status, response := service.JSON(http.MethodPost, "/api/v1/tasks/batch", nil, []byte(`{"reqs":[{"rid":"missing"},{"req":{"url":"unsupported://bad"}}]}`), nil)
|
|
var failed restmodel.Result[any]
|
|
if err := json.Unmarshal(response, &failed); err != nil || status != http.StatusOK || failed.Code == restmodel.CodeOk {
|
|
t.Fatalf("all-failed batch succeeded: %d %s", status, response)
|
|
}
|
|
}
|
|
}
|