Files
ttstd-infinite-canvas/backend/internal/app/appearance_live2d_test.go

253 lines
8.8 KiB
Go

package app
import (
"archive/zip"
"bytes"
"image"
"image/png"
"os"
"testing"
"infinite-canvas/backend/internal/model"
)
func live2DTestFiles(t *testing.T) map[string][]byte {
t.Helper()
var texture bytes.Buffer
if err := png.Encode(&texture, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
return map[string][]byte{
"avatar/avatar.model3.json": []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"]}}`),
"avatar/avatar.moc3": []byte("MOC3-test-validation-only"),
"avatar/texture.png": texture.Bytes(),
}
}
func live2DTestZIP(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var data bytes.Buffer
w := zip.NewWriter(&data)
for name, content := range files {
f, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(content); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return data.Bytes()
}
func TestLive2DArchiveAccepts50MiB(t *testing.T) {
files := live2DTestFiles(t)
// Structural fixture only, not a playable model. Store preserves the ZIP size.
moc := make([]byte, 50<<20)
copy(moc, "MOC3")
files["avatar/avatar.moc3"] = moc
var data bytes.Buffer
w := zip.NewWriter(&data)
for name, content := range files {
f, err := w.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store})
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(content); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if data.Len() <= 50<<20 {
t.Fatal("fixture must exercise a ZIP larger than 50 MiB")
}
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err != nil {
t.Fatal(err)
}
}
func TestLive2DArchiveValidation(t *testing.T) {
tests := []struct {
name string
mutate func(map[string][]byte)
valid bool
}{
{"valid nested model", func(map[string][]byte) {}, true},
{"traversal", func(f map[string][]byte) { f["../escape.json"] = []byte(`{}`) }, false},
{"script", func(f map[string][]byte) { f["run.js"] = []byte(`alert(1)`) }, false},
{"case collision", func(f map[string][]byte) { f["avatar/TEXTURE.png"] = f["avatar/texture.png"] }, false},
{"missing moc", func(f map[string][]byte) { delete(f, "avatar/avatar.moc3") }, false},
{"fake moc", func(f map[string][]byte) { f["avatar/avatar.moc3"] = []byte("fake") }, false},
{"invalid png", func(f map[string][]byte) { f["avatar/texture.png"] = []byte("not png") }, false},
{"external texture", func(f map[string][]byte) {
f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["https://example.com/texture.png"]}}`)
}, false},
{"audio reference", func(f map[string][]byte) {
f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"],"Motions":{"Idle":[{"File":"idle.motion3.json","Sound":"voice.wav"}]}}}`)
}, false},
{"multiple entries", func(f map[string][]byte) { f["other.model3.json"] = f["avatar/avatar.model3.json"] }, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files := live2DTestFiles(t)
tt.mutate(files)
data := live2DTestZIP(t, files)
entry, err := validateLive2DArchive(bytes.NewReader(data), int64(len(data)))
if (err == nil) != tt.valid {
t.Fatalf("entry=%q err=%v", entry, err)
}
if tt.valid && entry != "avatar/avatar.model3.json" {
t.Fatal(entry)
}
})
}
if _, err := validateLive2DArchive(bytes.NewReader(nil), Live2DMaxBytes+1); err == nil {
t.Fatal("oversized ZIP accepted")
}
for _, name := range []string{"", "../a", "a/../b", "/a", "a\\b", "a%2fb", "https://host/a", "a?b"} {
if live2DFileName(name) {
t.Fatalf("unsafe filename accepted: %q", name)
}
}
}
func TestLive2DArchiveRejectsSymlinksAndEntryFlood(t *testing.T) {
var data bytes.Buffer
w := zip.NewWriter(&data)
header := &zip.FileHeader{Name: "link.json", Method: zip.Store}
header.SetMode(os.ModeSymlink | 0o777)
f, err := w.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte("outside")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil {
t.Fatal("symlink accepted")
}
data.Reset()
w = zip.NewWriter(&data)
for i := 0; i < 257; i++ {
if _, err := w.Create("duplicate.json"); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil {
t.Fatal("entry flood accepted")
}
}
func TestLive2DAppearanceImportPublishAndDetach(t *testing.T) {
svc, _, _, admin := newAppearanceTestService(t)
if _, err := svc.UploadLive2D(nil, nil); err == nil {
t.Fatal("anonymous upload accepted")
}
data := live2DTestZIP(t, live2DTestFiles(t))
ordinary, err := svc.UploadResource(admin.ID, multipartFileHeader(t, "ordinary.zip", "application/zip", data), "live2d", 0, 0, 0)
if err != nil {
t.Fatal(err)
}
if ordinary.Kind == "live2d" {
t.Fatal("ordinary upload claimed the reserved model kind")
}
imported, err := svc.UploadLive2D(admin, multipartFileHeader(t, "avatar.zip", "application/zip", data))
if err != nil {
t.Fatal(err)
}
resource, err := svc.repo.Resource(imported.ResourceID)
if err != nil || resource.Kind != "live2d" || resource.Provider != "local" {
t.Fatalf("resource=%+v err=%v", resource, err)
}
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil {
t.Fatal("draft is publicly accessible")
}
if body, mime, err := svc.Live2DAsset(admin, imported.ResourceID, imported.Entry); err != nil || mime != "application/json" || len(body) == 0 {
t.Fatalf("preview failed: %s %v", mime, err)
}
other := &model.User{ID: "other-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
if _, _, err := svc.Live2DAsset(other, imported.ResourceID, imported.Entry); err == nil {
t.Fatal("other admin previewed draft")
}
value := defaultAppearanceSetting()
value.Canvas.AgentName = "小鱼"
value.Canvas.AvatarType = "live2d"
value.Canvas.Live2DResourceID = imported.ResourceID
value.Canvas.Live2DEntry = "client-cannot-choose.json"
if _, err := svc.UpdateAppearance(other, value); err == nil {
t.Fatal("other admin enabled draft")
}
saved, err := svc.UpdateAppearance(admin, value)
if err != nil {
t.Fatal(err)
}
if saved.Public.Canvas.AgentName != "小鱼" || saved.Public.BrandName != "影策" || saved.Public.Canvas.Live2DEntry != imported.Entry {
t.Fatalf("bad public setting: %+v", saved.Public)
}
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "avatar/texture.png"); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "../avatar/texture.png"); err == nil {
t.Fatal("traversal accepted")
}
refs := svc.appearanceResourceReferences([]string{imported.ResourceID})
if len(refs[imported.ResourceID]) != 1 {
t.Fatalf("missing deletion protection: %+v", refs)
}
if _, err := svc.AdminResourcePage(admin, AdminResourceQuery{Kind: "live2d", Page: 1, Limit: 20}); err != nil {
t.Fatal(err)
}
blocked, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}})
if err != nil || len(blocked.Blocked) != 1 || len(blocked.Deleted) != 0 {
t.Fatalf("referenced model deletion: %+v %v", blocked, err)
}
value.Canvas.AvatarType = "orb"
if _, err := svc.UpdateAppearance(admin, value); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil {
t.Fatal("disabled model still public")
}
if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 1 {
t.Fatal("orb selection lost resource reference")
}
value.Canvas.Live2DResourceID = ""
if _, err := svc.UpdateAppearance(admin, value); err != nil {
t.Fatal(err)
}
if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 0 {
t.Fatal("detached model still referenced")
}
deleted, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}})
if err != nil || len(deleted.Deleted) != 1 {
t.Fatalf("detached model deletion: %+v %v", deleted, err)
}
}
func TestCanvasAppearanceValidation(t *testing.T) {
for _, mutate := range []func(*CanvasAppearance){
func(v *CanvasAppearance) { v.AgentName = "\n" },
func(v *CanvasAppearance) { v.AgentName = "小\x00鱼" },
func(v *CanvasAppearance) { v.AvatarHeight = 1000 },
func(v *CanvasAppearance) { v.AvatarType = "invalid" },
func(v *CanvasAppearance) { v.AvatarType = "live2d" },
} {
value := defaultCanvasAppearance()
mutate(&value)
if _, err := normalizeCanvasAppearance(value); err == nil {
t.Fatalf("invalid value accepted: %+v", value)
}
}
}