package httpapi_test import ( "bytes" "context" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" ) type assetCatalog struct{ values []assets.Asset } func (c *assetCatalog) ListOwner(_ context.Context, owner string) ([]assets.Asset, error) { var v []assets.Asset for _, a := range c.values { if a.OwnerID == owner { v = append(v, a) } } return v, nil } func (c *assetCatalog) GetOwner(_ context.Context, owner, id string) (assets.Asset, bool, error) { for _, a := range c.values { if a.OwnerID == owner && a.ID == id { return a, true, nil } } return assets.Asset{}, false, nil } func (c *assetCatalog) GetOwnerByStoragePath(_ context.Context, owner, key string) (assets.Asset, bool, error) { for _, a := range c.values { if a.OwnerID == owner && a.StoragePath == key { return a, true, nil } } return assets.Asset{}, false, nil } func (c *assetCatalog) ListPublic(ctx context.Context, owner, client string, _ int) ([]assets.Asset, error) { all, _ := c.ListOwner(ctx, owner) var v []assets.Asset for _, a := range all { for _, tag := range a.Tags { if tag == assets.ClientTag(client) { v = append(v, a) } } } return v, nil } func (c *assetCatalog) GetPublic(ctx context.Context, owner, client, id string, _ int) (assets.Asset, bool, error) { v, _ := c.ListPublic(ctx, owner, client, 0) for _, a := range v { if a.ID == id { return a, true, nil } } return assets.Asset{}, false, nil } func (c *assetCatalog) Create(_ context.Context, a assets.Asset) (assets.Asset, error) { c.values = append(c.values, a) return a, nil } func (c *assetCatalog) DeleteOwner(_ context.Context, owner, id string) (assets.Asset, bool, error) { for i, a := range c.values { if a.OwnerID == owner && a.ID == id { c.values = append(c.values[:i], c.values[i+1:]...) return a, true, nil } } return assets.Asset{}, false, nil } type assetBlobs struct{ values map[string][]byte } func (b *assetBlobs) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (assets.StoredObject, error) { p, _ := io.ReadAll(r) b.values[key] = p return assets.StoredObject{Key: key, URL: "https://cdn.test/" + key}, nil } func (b *assetBlobs) Read(_ context.Context, key string) (assets.Blob, error) { p, ok := b.values[key] if !ok { return assets.Blob{}, assets.ErrBlobNotFound } return assets.Blob{Body: io.NopCloser(bytes.NewReader(p)), ContentType: "image/png", Size: int64(len(p))}, nil } func (b *assetBlobs) Delete(_ context.Context, key string) error { delete(b.values, key); return nil } func TestAssetsPlatformAndPublicHTTP(t *testing.T) { cat := &assetCatalog{} blobs := &assetBlobs{values: map[string][]byte{}} svc := assets.NewService(cat, blobs, nil, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(prefix string) string { return prefix + "-1" }) platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil) h, err := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), httpapi.AssetsConfig{}) if err != nil { t.Fatal(err) } post := request(t, h, http.MethodPost, "/api/assets", strings.NewReader(`{"url":"https://example.test/a.png"}`), map[string]string{"Content-Type": "application/json"}) if post.Code != 201 || !strings.Contains(post.Body.String(), `"name":"外部图片"`) { t.Fatalf("post=%d %s", post.Code, post.Body.String()) } list := request(t, h, http.MethodGet, "/api/assets", nil, nil) if list.Code != 200 || !strings.Contains(list.Body.String(), `"assets"`) { t.Fatalf("list=%d %s", list.Code, list.Body.String()) } pubUnauthorized := request(t, h, http.MethodGet, "/api/v1/assets", nil, nil) if pubUnauthorized.Code != 401 { t.Fatalf("public unauth=%d %s", pubUnauthorized.Code, pubUnauthorized.Body.String()) } pub := request(t, h, http.MethodPost, "/api/v1/assets", strings.NewReader(`{"url":"https://example.test/p.png"}`), map[string]string{"Authorization": "Bearer secret", "Content-Type": "application/json"}) if pub.Code != 201 || !strings.Contains(pub.Body.String(), "api-client:agent-a") { t.Fatalf("public post=%d %s", pub.Code, pub.Body.String()) } } func TestAssetsMultipartDownloadServingAndMethods(t *testing.T) { cat := &assetCatalog{} blobs := &assetBlobs{values: map[string][]byte{}} svc := assets.NewService(cat, blobs, nil, time.Now, func(prefix string) string { return prefix + "-x" }) platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil) h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "a:k"}), httpapi.AssetsConfig{MaxUploadBytes: 1024}) var body bytes.Buffer mw := multipart.NewWriter(&body) part, _ := mw.CreateFormFile("files", "a.png") _, _ = part.Write([]byte("png")) _ = mw.Close() upload := request(t, h, http.MethodPost, "/api/assets/upload", &body, map[string]string{"Content-Type": mw.FormDataContentType()}) if upload.Code != 201 { t.Fatalf("upload=%d %s", upload.Code, upload.Body.String()) } var payload struct { Assets []assets.Asset `json:"assets"` } _ = json.Unmarshal(upload.Body.Bytes(), &payload) id := payload.Assets[0].ID dl := request(t, h, http.MethodGet, "/api/assets/"+id+"/download", nil, nil) if dl.Code != 200 || dl.Body.String() != "png" || dl.Header().Get("Cache-Control") != "private, no-store" || !strings.Contains(dl.Header().Get("Content-Disposition"), "attachment") { t.Fatalf("download=%d %#v %q", dl.Code, dl.Header(), dl.Body.String()) } served := request(t, h, http.MethodGet, "/uploads/"+strings.TrimPrefix(payload.Assets[0].StoragePath, "uploads/"), nil, nil) if served.Code != 200 || served.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { t.Fatalf("served=%d %#v", served.Code, served.Header()) } head := request(t, h, http.MethodHead, "/api/assets", nil, nil) if head.Code != 200 || head.Body.Len() != 0 { t.Fatalf("head=%d %q", head.Code, head.Body.String()) } options := request(t, h, http.MethodOptions, "/api/assets", nil, nil) if options.Code != 204 || options.Header().Get("Allow") != "GET, HEAD, POST, OPTIONS" { t.Fatalf("options=%d allow=%q", options.Code, options.Header().Get("Allow")) } bad := request(t, h, http.MethodPatch, "/api/assets", nil, nil) if bad.Code != 405 || bad.Body.Len() != 0 { t.Fatalf("bad=%d %q", bad.Code, bad.Body.String()) } } func TestAssetsLimitsAndInfrastructureErrorsDoNotLeak(t *testing.T) { cat := &assetCatalog{} svc := assets.NewService(cat, &assetBlobs{values: map[string][]byte{}}, nil, time.Now, nil) platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil) h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "a:k"}), httpapi.AssetsConfig{MaxJSONBytes: 8, MaxUploadBytes: 8}) r := request(t, h, http.MethodPost, "/api/assets", strings.NewReader(`{"url":"https://secret.example.test"}`), map[string]string{"Content-Type": "application/json"}) if r.Code != 413 || strings.Contains(r.Body.String(), "secret") { t.Fatalf("limit=%d %s", r.Code, r.Body.String()) } missing := request(t, h, http.MethodGet, "/api/assets/missing/download", nil, nil) if missing.Code != 404 || missing.Body.String() != "{\"error\":\"资产不存在\"}\n" { t.Fatalf("missing=%d %q", missing.Code, missing.Body.String()) } } func request(t *testing.T, h http.Handler, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(method, path, body) for k, v := range headers { r.Header.Set(k, v) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w }