565 lines
27 KiB
Go
565 lines
27 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"math"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/textproto"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"infinite-canvas/backend/internal/database"
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) {
|
|
svc, _, _, _ := newAppearanceTestService(t)
|
|
|
|
appearance, err := svc.Appearance()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || !appearance.AuthVideoAutoplay || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled {
|
|
t.Fatalf("Appearance() = %#v", appearance)
|
|
}
|
|
if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" {
|
|
t.Fatalf("Appearance() configured state = %#v", appearance)
|
|
}
|
|
if appearance.ActiveSkin.ID != defaultAppearanceSkinID || !appearance.ActiveSkin.Locked {
|
|
t.Fatalf("Appearance() active skin = %#v", appearance.ActiveSkin)
|
|
}
|
|
if appearance.ActiveSkin.Tokens.Light.SwitchChecked == appearance.ActiveSkin.Tokens.Light.SwitchUnchecked || appearance.ActiveSkin.Tokens.Dark.SwitchChecked == appearance.ActiveSkin.Tokens.Dark.SwitchUnchecked || appearance.ActiveSkin.Tokens.Light.DangerForeground == "" {
|
|
t.Fatalf("Appearance() state colors = %#v", appearance.ActiveSkin.Tokens)
|
|
}
|
|
adminAppearance, err := svc.AdminAppearance(&model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(adminAppearance.SkinThemes) != 4 || !adminAppearance.SkinThemes[0].Locked {
|
|
t.Fatalf("AdminAppearance() skin library = %#v", adminAppearance.SkinThemes)
|
|
}
|
|
}
|
|
|
|
func TestBuiltInAppearanceSkinAuthPalettesFollowMode(t *testing.T) {
|
|
for _, skin := range defaultAppearanceSkinThemes() {
|
|
if skin.Tokens.Light.AuthBackground == skin.Tokens.Dark.AuthBackground || skin.Tokens.Light.AuthCard == skin.Tokens.Dark.AuthCard {
|
|
t.Errorf("skin %s reuses dark auth surfaces in light mode", skin.ID)
|
|
}
|
|
for _, mode := range []struct {
|
|
name string
|
|
tokens AppearanceSkinModeTokens
|
|
}{
|
|
{name: "light", tokens: skin.Tokens.Light},
|
|
{name: "dark", tokens: skin.Tokens.Dark},
|
|
} {
|
|
for label, foreground := range map[string]string{"text": mode.tokens.Text, "muted": mode.tokens.AuthMuted, "accent": mode.tokens.AuthAccent} {
|
|
if ratio := appearanceColorContrastRatio(t, foreground, mode.tokens.AuthCard); ratio < 4.5 {
|
|
t.Errorf("skin %s %s auth %s contrast = %.2f, want at least 4.5", skin.ID, mode.name, label, ratio)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuiltInAppearanceSkinTooltipPairsMeetContrast(t *testing.T) {
|
|
for _, skin := range defaultAppearanceSkinThemes() {
|
|
modes := []struct {
|
|
name string
|
|
tokens AppearanceSkinModeTokens
|
|
}{
|
|
{name: "light", tokens: skin.Tokens.Light},
|
|
{name: "dark", tokens: skin.Tokens.Dark},
|
|
}
|
|
for _, mode := range modes {
|
|
ratio := appearanceColorContrastRatio(t, mode.tokens.Overlay, mode.tokens.Text)
|
|
if ratio < 4.5 {
|
|
t.Errorf("skin %s %s tooltip contrast = %.2f, want at least 4.5", skin.ID, mode.name, ratio)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func appearanceColorContrastRatio(t *testing.T, foreground, background string) float64 {
|
|
t.Helper()
|
|
contrastLuminance := func(color string) float64 {
|
|
if len(color) != 7 || color[0] != '#' {
|
|
t.Fatalf("unsupported test color %q", color)
|
|
}
|
|
channels := make([]float64, 3)
|
|
for index := range channels {
|
|
value, err := strconv.ParseUint(color[1+index*2:3+index*2], 16, 8)
|
|
if err != nil {
|
|
t.Fatalf("parse test color %q: %v", color, err)
|
|
}
|
|
srgb := float64(value) / 255
|
|
if srgb <= 0.04045 {
|
|
channels[index] = srgb / 12.92
|
|
} else {
|
|
channels[index] = math.Pow((srgb+0.055)/1.055, 2.4)
|
|
}
|
|
}
|
|
return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2]
|
|
}
|
|
foregroundLuminance := contrastLuminance(foreground)
|
|
backgroundLuminance := contrastLuminance(background)
|
|
lighter := math.Max(foregroundLuminance, backgroundLuminance)
|
|
darker := math.Min(foregroundLuminance, backgroundLuminance)
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
}
|
|
|
|
func TestAppearanceBackfillsVersionSevenFieldsForExistingSetting(t *testing.T) {
|
|
svc, db, _, _ := newAppearanceTestService(t)
|
|
legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`}
|
|
if err := db.Create(&legacy).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
appearance, err := svc.Appearance()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || !appearance.AuthVideoAutoplay || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" {
|
|
t.Fatalf("legacy appearance = %#v", appearance)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) {
|
|
svc, db, dataDir, admin := newAppearanceTestService(t)
|
|
resources := []model.Resource{
|
|
{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"},
|
|
{ID: "brand-logo-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo-dark.png", MimeType: "image/png"},
|
|
{ID: "brand-video", UserID: admin.ID, Kind: "video", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/video.mp4", MimeType: "video/mp4"},
|
|
{ID: "brand-poster", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/poster.jpg", MimeType: "image/jpeg"},
|
|
}
|
|
if err := db.Create(&resources).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeAppearanceResourceFixtures(t, dataDir, resources)
|
|
|
|
updated, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
|
BrandName: "HIMA Studio",
|
|
BrandSlug: "hima-studio",
|
|
AuthHeroTitle: "把灵感,\n变成可见的故事。",
|
|
AuthHeroDescription: "从画布开始,持续推进你的创作。",
|
|
LogoResourceID: "brand-logo",
|
|
DarkLogoResourceID: "brand-logo-dark",
|
|
LogoFrameEnabled: false,
|
|
AuthVideoResourceID: "brand-video",
|
|
AuthVideoPosterResourceID: "brand-poster",
|
|
AuthVideoAutoplay: false,
|
|
SkinID: "studio-indigo",
|
|
SEOTitle: "HIMA Studio - AI 影视工作台",
|
|
SEODescription: "面向 Agent、图片、视频、画布与短剧生产的一体化 AI 创作工作台。",
|
|
SEOKeywords: "AI 影视,短剧,画布",
|
|
FooterCopyright: "© 2026 HIMA Studio. All rights reserved.",
|
|
ICPFilingEnabled: true,
|
|
ICPFilingNumber: "蜀ICP备2026000000号-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.AuthVideoAutoplay || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" {
|
|
t.Fatalf("UpdateAppearance() = %#v", updated)
|
|
}
|
|
for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} {
|
|
if !strings.HasPrefix(assetURL, "/api/public/appearance/assets/") || !strings.Contains(assetURL, "?v=") {
|
|
t.Fatalf("versioned asset URL = %q", assetURL)
|
|
}
|
|
}
|
|
encodedPublic, err := json.Marshal(updated.Public)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(encodedPublic), "brand-logo") || strings.Contains(string(encodedPublic), "brand-logo-dark") || strings.Contains(string(encodedPublic), "brand-video") || strings.Contains(string(encodedPublic), "brand-poster") {
|
|
t.Fatalf("public appearance leaked resource IDs: %s", encodedPublic)
|
|
}
|
|
if strings.Contains(string(encodedPublic), "skinThemes") {
|
|
t.Fatalf("public appearance leaked the complete skin library: %s", encodedPublic)
|
|
}
|
|
var auditCount int64
|
|
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.update").Count(&auditCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if auditCount != 1 {
|
|
t.Fatalf("audit count = %d, want 1", auditCount)
|
|
}
|
|
}
|
|
|
|
func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) {
|
|
svc, db, dataDir, admin := newAppearanceTestService(t)
|
|
resources := []model.Resource{
|
|
{ID: "only-light", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/light.png", MimeType: "image/png"},
|
|
{ID: "only-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/dark.png", MimeType: "image/png"},
|
|
}
|
|
if err := db.Create(&resources).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeAppearanceResourceFixtures(t, dataDir, resources)
|
|
|
|
lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if lightOnly.Public.LogoURL != lightOnly.Public.DarkLogoURL || !lightOnly.Public.LogoConfigured || lightOnly.Public.DarkLogoConfigured {
|
|
t.Fatalf("light-only appearance = %#v", lightOnly.Public)
|
|
}
|
|
|
|
darkOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, DarkLogoResourceID: "only-dark", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if darkOnly.Public.LogoURL != darkOnly.Public.DarkLogoURL || !darkOnly.Public.LogoConfigured || !darkOnly.Public.DarkLogoConfigured {
|
|
t.Fatalf("dark-only appearance = %#v", darkOnly.Public)
|
|
}
|
|
}
|
|
|
|
func TestResetAppearanceRestoresBuiltInBrandWithoutDeletingResources(t *testing.T) {
|
|
svc, db, _, admin := newAppearanceTestService(t)
|
|
resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
|
if err := db.Create(&resource).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
|
BrandName: "HIMA Studio",
|
|
BrandSlug: "hima-studio",
|
|
AuthHeroTitle: "把灵感变成故事",
|
|
LogoResourceID: resource.ID,
|
|
SkinID: defaultAppearanceSkinID,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reset, err := svc.ResetAppearance(admin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reset.Configured || reset.BrandName != defaultAppearanceBrandName || reset.BrandSlug != defaultAppearanceBrandSlug || reset.AuthHeroTitle != defaultAppearanceHeroTitle || reset.LogoResourceID != "" || reset.DarkLogoResourceID != "" || !reset.LogoFrameEnabled || reset.Public.Revision != "builtin" || reset.Public.LogoConfigured || reset.Public.DarkLogoConfigured {
|
|
t.Fatalf("ResetAppearance() = %#v", reset)
|
|
}
|
|
var resourceCount int64
|
|
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resourceCount != 1 {
|
|
t.Fatalf("resource count = %d, want 1", resourceCount)
|
|
}
|
|
var auditCount int64
|
|
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.reset").Count(&auditCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if auditCount != 1 {
|
|
t.Fatalf("reset audit count = %d, want 1", auditCount)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAppearanceRejectsAnotherUsersNewResource(t *testing.T) {
|
|
svc, db, _, admin := newAppearanceTestService(t)
|
|
resource := model.Resource{ID: "foreign-logo", UserID: "another-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
|
if err := db.Create(&resource).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID})
|
|
if err == nil || !strings.Contains(err.Error(), "当前管理员上传") {
|
|
t.Fatalf("UpdateAppearance() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) {
|
|
svc, db, dataDir, admin := newAppearanceTestService(t)
|
|
resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
|
if err := db.Create(&resource).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
|
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filePath, []byte("brand-bytes"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stream, err := svc.OpenAppearanceAsset(AppearanceAssetLogo, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stream.Body.Close()
|
|
if stream.Resource.ID != resource.ID {
|
|
t.Fatalf("served resource = %q", stream.Resource.ID)
|
|
}
|
|
if _, err := svc.OpenAppearanceAsset(AppearanceAssetVideo, ""); err == nil {
|
|
t.Fatal("unconfigured video slot unexpectedly served a resource")
|
|
}
|
|
}
|
|
|
|
func TestAppearanceFallsBackWhenConfiguredLogoResourceIsMissing(t *testing.T) {
|
|
svc, db, _, admin := newAppearanceTestService(t)
|
|
value := defaultAppearanceSetting()
|
|
value.BrandName = "HIMA Studio"
|
|
value.BrandSlug = "hima-studio"
|
|
value.LogoResourceID = "missing-logo"
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
appearance, err := svc.Appearance()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL {
|
|
t.Fatalf("missing logo public appearance = %#v", appearance)
|
|
}
|
|
adminAppearance, err := svc.AdminAppearance(admin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if adminAppearance.LogoResourceID != "" || adminAppearance.Public.LogoConfigured {
|
|
t.Fatalf("missing logo admin appearance = %#v", adminAppearance)
|
|
}
|
|
}
|
|
|
|
func TestAppearanceFallsBackWhenLocalLogoFileIsMissing(t *testing.T) {
|
|
svc, db, _, _ := newAppearanceTestService(t)
|
|
resource := model.Resource{ID: "missing-local-logo", UserID: "appearance-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/missing.png", MimeType: "image/png"}
|
|
if err := db.Create(&resource).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
value := defaultAppearanceSetting()
|
|
value.LogoResourceID = resource.ID
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
appearance, err := svc.Appearance()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL {
|
|
t.Fatalf("missing local logo public appearance = %#v", appearance)
|
|
}
|
|
}
|
|
|
|
func TestUploadAppearanceLogoAlwaysUsesServerLocalStorage(t *testing.T) {
|
|
svc, db, dataDir, admin := newAppearanceTestService(t)
|
|
requests := make(chan struct{}, 2)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
requests <- struct{}{}
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
seedOSSEnabled(t, db, admin.ID, server.URL)
|
|
pngBytes := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)
|
|
|
|
for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo} {
|
|
resource, err := svc.UploadAppearanceAsset(admin, slot, multipartFileHeader(t, slot+".png", "image/png", pngBytes))
|
|
if err != nil {
|
|
t.Fatalf("UploadAppearanceAsset(%s): %v", slot, err)
|
|
}
|
|
if resource.Provider != "local" || resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" {
|
|
t.Fatalf("UploadAppearanceAsset(%s) storage = %#v", slot, resource)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))); err != nil {
|
|
t.Fatalf("UploadAppearanceAsset(%s) local file: %v", slot, err)
|
|
}
|
|
}
|
|
if len(requests) != 0 {
|
|
t.Fatalf("logo upload unexpectedly contacted object storage %d time(s)", len(requests))
|
|
}
|
|
}
|
|
|
|
func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) {
|
|
svc, _, _, admin := newAppearanceTestService(t)
|
|
|
|
_, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "", SkinID: defaultAppearanceSkinID})
|
|
if err == nil || !strings.Contains(err.Error(), "主标题") {
|
|
t.Fatalf("empty title error = %v", err)
|
|
}
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, AuthHeroDescription: strings.Repeat("文", 161), SkinID: defaultAppearanceSkinID})
|
|
if err == nil || !strings.Contains(err.Error(), "160") {
|
|
t.Fatalf("long description error = %v", err)
|
|
}
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "标题\t注入", SkinID: defaultAppearanceSkinID})
|
|
if err == nil || !strings.Contains(err.Error(), "控制字符") {
|
|
t.Fatalf("control character error = %v", err)
|
|
}
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "HIMA Studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: defaultAppearanceSkinID})
|
|
if err == nil || !strings.Contains(err.Error(), "英文品牌标识") {
|
|
t.Fatalf("invalid slug error = %v", err)
|
|
}
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: "unknown-skin"})
|
|
if err == nil || !strings.Contains(err.Error(), "皮肤主题") {
|
|
t.Fatalf("invalid skin error = %v", err)
|
|
}
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: defaultAppearanceSkinID, ICPFilingEnabled: true})
|
|
if err == nil || !strings.Contains(err.Error(), "备案号") {
|
|
t.Fatalf("missing ICP number error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppearanceSkinLibrarySupportsEditableCopiesAndProtectsClassic(t *testing.T) {
|
|
svc, _, _, admin := newAppearanceTestService(t)
|
|
themes := defaultAppearanceSkinThemes()
|
|
custom := cloneAppearanceSkin(themes[0], "custom-editorial", "片场夜蓝", "自定义控件与明暗色")
|
|
custom.Tokens.Light.Primary = "#123456"
|
|
custom.Tokens.Light.PrimaryHover = "#234567"
|
|
custom.Tokens.Dark.Primary = "#abcdef"
|
|
custom.Tokens.Components.ButtonRadius = 14
|
|
themes = append(themes, custom)
|
|
legacyCustom := custom
|
|
legacyCustom.Tokens.Light.SwitchChecked = ""
|
|
legacyCustom.Tokens.Light.SwitchCheckedHover = ""
|
|
legacyCustom.Tokens.Light.SwitchCheckedHandle = ""
|
|
legacyCustom.Tokens.Light.SwitchUnchecked = ""
|
|
legacyCustom.Tokens.Light.SwitchUncheckedHover = ""
|
|
legacyCustom.Tokens.Light.SwitchUncheckedHandle = ""
|
|
legacyCustom.Tokens.Light.DangerHover = ""
|
|
legacyCustom.Tokens.Light.DangerActive = ""
|
|
legacyCustom.Tokens.Light.DangerForeground = ""
|
|
backfilled := normalizeAppearanceSkinThemes([]AppearanceSkinTheme{legacyCustom})[0]
|
|
if backfilled.Tokens.Light.SwitchChecked != "#123456" || backfilled.Tokens.Light.SwitchCheckedHover != "#234567" || backfilled.Tokens.Light.SwitchUnchecked != legacyCustom.Tokens.Light.ControlBorder || backfilled.Tokens.Light.DangerHover != legacyCustom.Tokens.Light.Danger {
|
|
t.Fatalf("legacy skin state backfill = %#v", backfilled.Tokens.Light)
|
|
}
|
|
|
|
updated, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
|
BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle,
|
|
SkinID: custom.ID, SkinThemes: themes,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.Public.ActiveSkin.ID != custom.ID || updated.Public.ActiveSkin.Tokens.Light.Primary != "#123456" || updated.Public.ActiveSkin.Tokens.Components.ButtonRadius != 14 || len(updated.SkinThemes) != 5 {
|
|
t.Fatalf("custom skin round trip = %#v", updated)
|
|
}
|
|
|
|
classic := defaultClassicAppearanceSkin()
|
|
withoutClassic := append([]AppearanceSkinTheme(nil), themes[1:]...)
|
|
restored, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: withoutClassic})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restored.SkinThemes[0] != classic {
|
|
t.Fatalf("missing classic was not restored = %#v", restored.SkinThemes[0])
|
|
}
|
|
|
|
mutatedClassic := append([]AppearanceSkinTheme(nil), themes...)
|
|
mutatedClassic[0].Name = "改名"
|
|
mutatedClassic[0].Tokens.Light.Primary = "#000000"
|
|
overwritten, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: mutatedClassic})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if overwritten.SkinThemes[0] != classic || overwritten.Public.ActiveSkin.ID != custom.ID {
|
|
t.Fatalf("mutated classic was not overwritten = %#v", overwritten.SkinThemes[0])
|
|
}
|
|
|
|
invalidColor := append([]AppearanceSkinTheme(nil), themes...)
|
|
invalidColor[len(invalidColor)-1].Tokens.Light.Primary = "red; background:url(x)"
|
|
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: invalidColor})
|
|
if err == nil || !strings.Contains(err.Error(), "十六进制") {
|
|
t.Fatalf("invalid color error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAppearanceUploadSniffsBytesInsteadOfDeclaredMIME(t *testing.T) {
|
|
pngHeader := multipartFileHeader(t, "logo.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...))
|
|
if mimeType, err := validateAppearanceUpload(AppearanceAssetLogo, pngHeader); err != nil || mimeType != "image/png" {
|
|
t.Fatalf("validateAppearanceUpload(png) = %q, %v", mimeType, err)
|
|
}
|
|
darkPNGHeader := multipartFileHeader(t, "logo-dark.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...))
|
|
if mimeType, err := validateAppearanceUpload(AppearanceAssetDarkLogo, darkPNGHeader); err != nil || mimeType != "image/png" {
|
|
t.Fatalf("validateAppearanceUpload(dark png) = %q, %v", mimeType, err)
|
|
}
|
|
fakeImage := multipartFileHeader(t, "fake.png", "image/png", []byte("not an image"))
|
|
if _, err := validateAppearanceUpload(AppearanceAssetLogo, fakeImage); err == nil {
|
|
t.Fatal("declared image MIME accepted non-image bytes")
|
|
}
|
|
mp4Bytes := append([]byte{0x00, 0x00, 0x00, 0x14}, []byte("ftypisom\x00\x00\x00\x00isom")...)
|
|
mp4Header := multipartFileHeader(t, "hero.mp4", "application/octet-stream", mp4Bytes)
|
|
if mimeType, err := validateAppearanceUpload(AppearanceAssetVideo, mp4Header); err != nil || mimeType != "video/mp4" {
|
|
t.Fatalf("validateAppearanceUpload(mp4) = %q, %v", mimeType, err)
|
|
}
|
|
fakeMP4 := multipartFileHeader(t, "fake.mp4", "video/mp4", append([]byte{0x00, 0x00, 0x10, 0x00}, []byte("ftypisom")...))
|
|
if _, err := validateAppearanceUpload(AppearanceAssetVideo, fakeMP4); err == nil {
|
|
t.Fatal("invalid MP4 box size accepted")
|
|
}
|
|
}
|
|
|
|
func multipartFileHeader(t *testing.T, name string, contentType string, data []byte) *multipart.FileHeader {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
header := make(textproto.MIMEHeader)
|
|
header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`"`)
|
|
header.Set("Content-Type", contentType)
|
|
part, err := writer.CreatePart(header)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
if err := req.ParseMultipartForm(int64(body.Len()) + 1024); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req.MultipartForm.File["file"][0]
|
|
}
|
|
|
|
func writeAppearanceResourceFixtures(t *testing.T, dataDir string, resources []model.Resource) {
|
|
t.Helper()
|
|
for _, resource := range resources {
|
|
if resource.Provider != "local" {
|
|
continue
|
|
}
|
|
filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
|
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filePath, []byte(resource.ID), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newAppearanceTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin := &model.User{ID: "appearance-admin", Username: "appearance-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
|
if err := db.Create(admin).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dataDir := t.TempDir()
|
|
return New(repository.New(db), dataDir), db, dataDir, admin
|
|
}
|