315 lines
14 KiB
Go
315 lines
14 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"image/png"
|
|
"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/identity"
|
|
"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 }
|
|
|
|
type signedAssetBlobs struct {
|
|
*assetBlobs
|
|
url string
|
|
}
|
|
|
|
func (b *signedAssetBlobs) SignReadURL(string, time.Duration) (string, error) { return b.url, nil }
|
|
|
|
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 TestAssetInlineReadRedirectsToShortLivedSignedURL(t *testing.T) {
|
|
cat := &assetCatalog{values: []assets.Asset{{
|
|
ID: "asset-private", OwnerID: "demo-merchant", Name: "private.png", URL: "https://private.test/private.png", StoragePath: "uploads/private.png",
|
|
}}}
|
|
blobs := &signedAssetBlobs{
|
|
assetBlobs: &assetBlobs{values: map[string][]byte{"uploads/private.png": []byte("png")}},
|
|
url: "https://private.test/private.png?OSSAccessKeyId=test&Expires=1&Signature=signed",
|
|
}
|
|
svc := assets.NewService(cat, blobs, nil, time.Now, nil)
|
|
platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil)
|
|
h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "a:k"}), httpapi.AssetsConfig{})
|
|
|
|
inline := request(t, h, http.MethodGet, "/api/assets/asset-private/download?inline=1", nil, nil)
|
|
if inline.Code != http.StatusTemporaryRedirect || inline.Header().Get("Location") != blobs.url || inline.Header().Get("Cache-Control") != "private, no-store" || inline.Header().Get("Content-Disposition") != "" {
|
|
t.Fatalf("inline response = %d %#v", inline.Code, inline.Header())
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
type imageImportRemote struct {
|
|
data []byte
|
|
err error
|
|
called bool
|
|
}
|
|
|
|
func (f *imageImportRemote) Fetch(context.Context, string) (assets.Blob, error) {
|
|
f.called = true
|
|
if f.err != nil {
|
|
return assets.Blob{}, f.err
|
|
}
|
|
return assets.Blob{Body: io.NopCloser(bytes.NewReader(f.data)), ContentType: "text/html", Size: int64(len(f.data))}, nil
|
|
}
|
|
|
|
type imageImportResolver struct{}
|
|
|
|
func (imageImportResolver) Resolve(context.Context, string) (identity.Session, error) {
|
|
return identity.Session{User: identity.User{ID: "owner-a", Role: "user"}}, nil
|
|
}
|
|
|
|
func TestImportImageHTTPAuthMethodAndStoredResult(t *testing.T) {
|
|
var encoded bytes.Buffer
|
|
if err := png.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
remote := &imageImportRemote{data: encoded.Bytes()}
|
|
cat, blobs := &assetCatalog{}, &assetBlobs{values: map[string][]byte{}}
|
|
svc := assets.NewService(cat, blobs, remote, time.Now, func(prefix string) string { return prefix + "-1" })
|
|
platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{Required: true, Configured: true}, imageImportResolver{})
|
|
h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent:secret"}), httpapi.AssetsConfig{})
|
|
path := "/api/assets/import-image"
|
|
unauth := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.png"}`), map[string]string{"Content-Type": "application/json", "Authorization": "Bearer secret"})
|
|
if unauth.Code != 401 || remote.called {
|
|
t.Fatalf("unauth=%d called=%v", unauth.Code, remote.called)
|
|
}
|
|
wrongMethod := request(t, h, http.MethodGet, path, nil, nil)
|
|
if wrongMethod.Code != 405 || wrongMethod.Header().Get("Allow") != "POST, OPTIONS" {
|
|
t.Fatalf("method=%d allow=%q", wrongMethod.Code, wrongMethod.Header().Get("Allow"))
|
|
}
|
|
options := request(t, h, http.MethodOptions, path, nil, nil)
|
|
if options.Code != 204 || options.Header().Get("Allow") != "POST, OPTIONS" {
|
|
t.Fatalf("options=%d allow=%q", options.Code, options.Header().Get("Allow"))
|
|
}
|
|
for _, headers := range []map[string]string{
|
|
{"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "text/plain"},
|
|
{"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site"},
|
|
} {
|
|
bad := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.png"}`), headers)
|
|
if bad.Code != 415 && bad.Code != 403 {
|
|
t.Fatalf("unsafe request=%d %s", bad.Code, bad.Body.String())
|
|
}
|
|
}
|
|
good := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.svg?Signature=sensitive"}`), map[string]string{"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "application/json", "Sec-Fetch-Site": "same-origin"})
|
|
if good.Code != 201 {
|
|
t.Fatalf("import=%d %s", good.Code, good.Body.String())
|
|
}
|
|
var response struct {
|
|
Asset assets.Asset `json:"asset"`
|
|
}
|
|
if err := json.Unmarshal(good.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Asset.OwnerID != "owner-a" || response.Asset.Source != assets.SourceUpload || response.Asset.Kind != assets.KindImage || response.Asset.Metadata["importedFrom"] != nil || !bytes.Equal(blobs.values[response.Asset.StoragePath], encoded.Bytes()) || strings.Contains(good.Body.String(), "sensitive") {
|
|
t.Fatalf("response=%s", good.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestImportImageHTTPErrorsNeverLeakRemoteURLOrCreateRecord(t *testing.T) {
|
|
secretURL := "https://image.test/private.png?Signature=secret-token"
|
|
for _, tc := range []struct {
|
|
name string
|
|
data []byte
|
|
err error
|
|
status int
|
|
message string
|
|
}{
|
|
{"invalid format", []byte("<html>access denied</html>"), nil, 415, "不支持的图片格式"},
|
|
{"unavailable", nil, errors.New("GET " + secretURL + ": timeout"), 502, "图片地址不可访问"},
|
|
{"too large", nil, assets.ErrRemoteTooLarge, 413, "图片过大"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cat, blobs := &assetCatalog{}, &assetBlobs{values: map[string][]byte{}}
|
|
remote := &imageImportRemote{data: tc.data, err: tc.err}
|
|
svc := assets.NewService(cat, blobs, remote, time.Now, nil)
|
|
platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil)
|
|
h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{}), httpapi.AssetsConfig{})
|
|
resp := request(t, h, http.MethodPost, "/api/assets/import-image", strings.NewReader(`{"url":"`+secretURL+`"}`), map[string]string{"Content-Type": "application/json"})
|
|
if resp.Code != tc.status || !strings.Contains(resp.Body.String(), tc.message) || strings.Contains(resp.Body.String(), "secret-token") || len(cat.values) != 0 || len(blobs.values) != 0 {
|
|
t.Fatalf("status=%d body=%s records=%d blobs=%d", resp.Code, resp.Body.String(), len(cat.values), len(blobs.values))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|