253 lines
8.8 KiB
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)
|
|
}
|
|
}
|
|
}
|