Files
ttstd-infinite-canvas/backend/internal/app/provider_ark_private_assets_test.go
VicoHu bcfec0347d fix(canvas): 方舟可信素材 - 失败缓存支持安全重试并透传方舟原始错误
- 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、
  区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败
- 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组
- 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始
  Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败
- 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明
2026-09-11 21:40:47 +08:00

258 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestArkPrivateAssetUsesRegionalArkControlPlane(t *testing.T) {
value, err := arkPrivateAssetControlPlaneURL("test-region")
if err != nil || value != "https://ark.test-region.volcengineapi.com" {
t.Fatalf("arkPrivateAssetControlPlaneURL() = %q, %v", value, err)
}
}
func TestCallArkPrivateAssetAPISignsAndUsesAssetContract(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
t.Errorf("method = %s, want POST", request.Method)
}
if request.URL.Query().Get("Action") != "CreateAsset" || request.URL.Query().Get("Version") != arkPrivateAssetAPIVersion {
t.Errorf("query = %s", request.URL.RawQuery)
}
if !strings.HasPrefix(request.Header.Get("Authorization"), "HMAC-SHA256") {
t.Errorf("missing Volcengine request signature")
}
var body map[string]interface{}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["ProjectName"] != "project-test" || body["AssetType"] != "Image" || body["GroupId"] != "group-test" {
t.Errorf("body = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"Result":{"AssetId":"asset-test"}}`))
}))
defer server.Close()
previousBaseURL := arkPrivateAssetAPIBaseURLOverride
arkPrivateAssetAPIBaseURLOverride = server.URL
t.Cleanup(func() { arkPrivateAssetAPIBaseURLOverride = previousBaseURL })
response, err := callArkPrivateAssetAPI(context.Background(), arkPrivateAssetSettingValue{
Region: "test-region",
ProjectName: "project-test",
AccessKeyID: "test-access-key",
AccessKeySecret: "test-secret-key",
}, "CreateAsset", map[string]interface{}{
"GroupId": "group-test", "AssetType": "Image", "URL": "https://media.example.test/reference.png", "ProjectName": "project-test",
})
if err != nil {
t.Fatalf("callArkPrivateAssetAPI() error = %v", err)
}
if got := arkPrivateAssetResponseField(response, "AssetId"); got != "asset-test" {
t.Fatalf("asset ID = %q", got)
}
}
func TestCallArkPrivateAssetAPIUsesIDForGetAsset(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if request.URL.Query().Get("Action") != "GetAsset" || body["Id"] != "asset-test" || body["AssetId"] != nil {
t.Errorf("GetAsset request = %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"Result":{"Status":"Processing"}}`))
}))
defer server.Close()
previousBaseURL := arkPrivateAssetAPIBaseURLOverride
arkPrivateAssetAPIBaseURLOverride = server.URL
t.Cleanup(func() { arkPrivateAssetAPIBaseURLOverride = previousBaseURL })
_, err := callArkPrivateAssetAPI(context.Background(), arkPrivateAssetSettingValue{
Region: "test-region", AccessKeyID: "test-access-key", AccessKeySecret: "test-secret-key",
}, "GetAsset", map[string]interface{}{"Id": "asset-test", "ProjectName": "project-test"})
if err != nil {
t.Fatalf("callArkPrivateAssetAPI() error = %v", err)
}
}
func TestArkPrivateAssetControlPlaneDisablesGenerationAnalytics(t *testing.T) {
ctx := context.WithValue(context.Background(), providerAnalyticsKey{}, providerAnalyticsContext{Service: &Service{}, Capability: "video"})
ctx = withoutProviderAnalytics(ctx)
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service != nil || metadata.Capability != "" {
t.Fatalf("provider analytics = %#v, %v", metadata, ok)
}
}
func TestArkPrivateAssetAutomaticSyncIsOptionalWhenSettingDisabled(t *testing.T) {
enabled, err := arkPrivateAssetAutomaticSyncEnabled(arkPrivateAssetSettingValue{})
if err != nil || enabled {
t.Fatalf("arkPrivateAssetAutomaticSyncEnabled() = %v, %v; want false, nil", enabled, err)
}
_, err = arkPrivateAssetAutomaticSyncEnabled(arkPrivateAssetSettingValue{Enabled: true})
if err == nil || !strings.Contains(err.Error(), "尚未配置") {
t.Fatalf("enabled incomplete setting error = %v, want configuration error", err)
}
}
func TestArkPrivateAssetSettingsEncryptSecret(t *testing.T) {
svc := &Service{dataDir: t.TempDir()}
value, err := arkPrivateAssetSettingFromRequest(ArkPrivateAssetSettingRequest{
Enabled: true, Region: "test-region", ProjectName: "project-test", AccessKeyID: "test-access-key", AccessKeySecret: "test-secret-key",
}, defaultArkPrivateAssetSetting())
if err != nil {
t.Fatalf("arkPrivateAssetSettingFromRequest() error = %v", err)
}
ciphertext, err := svc.encryptSettingSecret(value.AccessKeySecret)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(ciphertext, encryptedSettingPrefix) || strings.Contains(ciphertext, value.AccessKeySecret) {
t.Fatalf("encrypted secret = %q", ciphertext)
}
plaintext, err := svc.decryptSettingSecret(ciphertext)
if err != nil || plaintext != value.AccessKeySecret {
t.Fatalf("decryptSettingSecret() = %q, %v", plaintext, err)
}
public := publicArkPrivateAssetSetting(nil, value)
if !public.HasAccessKeySecret || public.AccessKeyID != "test-access-key" {
t.Fatalf("public setting = %#v", public)
}
}
func TestArkPrivateAssetResponseFieldReadsNestedAssetStatus(t *testing.T) {
response := map[string]interface{}{
"Result": map[string]interface{}{
"Asset": map[string]interface{}{"Status": "Active", "AssetId": "asset-nested"},
},
}
if got := arkPrivateAssetResponseField(response, "Status"); got != "Active" {
t.Fatalf("status = %q", got)
}
if got := arkPrivateAssetResponseField(response, "AssetId"); got != "asset-nested" {
t.Fatalf("asset ID = %q", got)
}
}
func TestArkPrivateAssetResponseFieldReadsCreateGroupID(t *testing.T) {
response := map[string]interface{}{
"Result": map[string]interface{}{
"Group": map[string]interface{}{"Id": "group-created"},
},
}
if got := arkPrivateAssetResponseField(response, "GroupId", "Id"); got != "group-created" {
t.Fatalf("group ID = %q", got)
}
}
func TestShouldRetryArkPrivateAssetBinding(t *testing.T) {
cases := []struct {
name string
binding *model.ArkPrivateAssetBinding
want bool
}{
{
name: "素材组未建成时网络失败可重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, Error: `创建方舟素材组失败:Post "https://ark.cn-shanghai.volcengineapi.com/": EOF`},
want: true,
},
{
name: "素材组未建成时上游业务拒绝也可重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, Error: "创建方舟素材组失败:方舟素材库请求失败(HTTP 403):SubscriptionRequired"},
want: true,
},
{
name: "素材组已建时上传素材失败可重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "上传方舟可信素材失败:Post \"https://ark.cn-beijing.volcengineapi.com/\": EOF"},
want: true,
},
{
name: "素材组已建时素材 ID 解析失败可重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "方舟素材库没有返回素材 ID"},
want: true,
},
{
name: "审核拒绝保持终态不重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", ArkAssetID: "asset-1", Error: "FaceMismatch: Face consistency verification failed."},
want: false,
},
{
name: "素材组已建时的其他失败不重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "生成方舟素材临时地址失败:对象存储不可用"},
want: false,
},
{
name: "非失败状态不重试",
binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusLive, ArkAssetID: "asset-1"},
want: false,
},
}
for _, item := range cases {
if got := shouldRetryArkPrivateAssetBinding(item.binding); got != item.want {
t.Fatalf("%s: shouldRetryArkPrivateAssetBinding() = %v, want %v", item.name, got, item.want)
}
}
if shouldRetryArkPrivateAssetBinding(nil) {
t.Fatal("shouldRetryArkPrivateAssetBinding(nil) = true, want false")
}
}
func TestCallArkPrivateAssetAPIPassesThroughUpstreamHTTPErrors(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ResponseMetadata":{"Error":{"Code":"SubscriptionRequired","Message":"This API requires an active subscription."}}}`))
}))
defer server.Close()
previousBaseURL := arkPrivateAssetAPIBaseURLOverride
arkPrivateAssetAPIBaseURLOverride = server.URL
t.Cleanup(func() { arkPrivateAssetAPIBaseURLOverride = previousBaseURL })
_, err := callArkPrivateAssetAPI(context.Background(), arkPrivateAssetSettingValue{
Region: "test-region", AccessKeyID: "test-access-key", AccessKeySecret: "test-secret-key",
}, "CreateAssetGroup", map[string]interface{}{"ProjectName": "project-test"})
if err == nil {
t.Fatal("callArkPrivateAssetAPI() error = nil, want upstream passthrough error")
}
for _, want := range []string{"403", "SubscriptionRequired", "This API requires an active subscription."} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("callArkPrivateAssetAPI() error = %q, want contains %q", err.Error(), want)
}
}
}
func TestArkPrivateAssetUpstreamDetail(t *testing.T) {
if got := arkPrivateAssetUpstreamDetail(map[string]interface{}{}); got != "" {
t.Fatalf("empty response detail = %q, want empty", got)
}
response := map[string]interface{}{
"ResponseMetadata": map[string]interface{}{
"Error": map[string]interface{}{"Code": "FaceMismatch", "Message": "Face consistency verification failed."},
},
}
if got := arkPrivateAssetUpstreamDetail(response); got != "FaceMismatch Face consistency verification failed." {
t.Fatalf("detail = %q", got)
}
emptyError := map[string]interface{}{"ResponseMetadata": map[string]interface{}{"Error": map[string]interface{}{}}}
if got := arkPrivateAssetUpstreamDetail(emptyError); got != "方舟素材库请求失败" {
t.Fatalf("empty upstream error detail = %q", got)
}
}