Files
t5de-proxy/internal/products/products_test.go
T
dhkatzandClaude Opus 5.5 0be2cd8f4e
CI / build (push) Successful in 44s
T5 furniture and poses in IMVU rooms (decorations)
Community products are taken out of rooms their owners save and broadcast, kept on the hub per room,
and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor
notice resends the room with the new ones. Placements are stored with the hub's product ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:03:21 -07:00

449 lines
18 KiB
Go

package products
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.t5de.dev/t5de/t5de-api/cfl"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
const (
user = "7"
key = "0123abcd"
parent = 500 // the IMVU product the test pack derives from
token = "tok"
)
// writePack makes a pack deriving from IMVU product 500.
func writePack(t *testing.T, root, id string, revision int) string {
dir := filepath.Join(root, id)
os.MkdirAll(filepath.Join(dir, "files"), 0o755)
manifest := fmt.Sprintf(`{"format": 1, "id": %q, "revision": %d, "name": "Blue <Coat>", "creator": "Tester", "rating": "general"}`, id, revision)
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
index := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><Template><ProductRating>GA</ProductRating><__DATAIMPORT>product://%d/index.xml</__DATAIMPORT><ProductName>Old Name</ProductName></Template>`, parent)
os.WriteFile(filepath.Join(dir, "files", "index.xml"), []byte(index), 0o644)
os.WriteFile(filepath.Join(dir, "files", "tex.png"), []byte("not really a png"), 0o644)
return dir
}
// fakeIMVU answers the calls the client makes, checking signatures, and records what it was asked.
type fakeIMVU struct {
t *testing.T
mu sync.Mutex
asked map[string][]string // method or path -> bodies
look []int64
}
func (f *fakeIMVU) record(name string, body []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.asked[name] = append(f.asked[name], string(body))
}
func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
var body []byte
if r.Body != nil {
body, _ = io.ReadAll(r.Body)
}
reply := func(s string) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(s)), Request: r}, nil
}
checkSigned := func(withQuery bool) {
if r.Header.Get("X-imvu-auth") != sign(user, key, body, r.URL.RawQuery, withQuery) {
f.t.Errorf("%s: bad signature", r.URL)
}
}
if participantPath.MatchString(r.URL.Path) {
checkSigned(false)
f.record("participant", body)
return reply(`{"status": "success"}`)
}
switch r.URL.Path {
case "/api/outfit_products.php":
return reply(`{"result": {"80": {"pid": 80, "name": "Body", "is_bundleable": true}}, "bundle_id": 0}`)
case "/api/find_bundle.php":
checkSigned(true)
f.record("find bundle", []byte(r.URL.RawQuery))
return reply(`{"result": 0}`)
case "/api/recently_added_inventory.php":
return reply(`[31162856, 108363]`)
case "/api/shop/product.php":
checkSigned(false)
f.record("product info", body)
return reply(`{"products": [{"id": "12345", "name": "IMVU's"}]}`)
case "/api/shop/search_st.php":
f.record("search", body)
return reply(`{"keys": [], "result": {}}`)
case "/api/service/shop/svc_catalog_purchase.php":
checkSigned(false)
f.record("purchase", body)
return reply(`{"status": 1, "balance_credits": 10, "balance_promo": 0}`)
case "/api/outfits.php":
if len(body) > 0 {
checkSigned(false)
f.record("save outfit", body)
return reply(`{"result": {"outfit_id": 555}}`)
}
return reply(`{"result": [{"user_outfits_id": "555", "pids": [80, 1], "restricted": "0"}, {"user_outfits_id": "9", "pids": [80]}]}`)
case "/api/xmlrpc/chat.php":
f.record("chat", body)
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
{Name: "chatId", Value: int64(555)}, {Name: "participantUrl", Value: "https://api.imvu.com/chat/chat-555/participants/user-7"}}}}).Marshal()))
case "/api/base_look.php":
if len(body) > 0 {
checkSigned(true)
f.record("save look", body)
var save struct{ Pids []int64 }
json.Unmarshal(body, &save)
f.look = save.Pids
return reply(`{"success": true}`)
}
b, _ := json.Marshal(map[string]any{"result": f.look})
return reply(string(b))
case "/api/xmlrpc/client.php":
call, err := xmlrpc.ParseCall(body)
if err != nil {
f.t.Fatalf("bad call: %v", err)
}
f.record(call.Method, body)
switch call.Method {
case "test.avatarInfoForLogin2":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
{Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key},
{Name: "look", Value: []xmlrpc.Value{int64(80), int64(1)}}}}}).Marshal()))
case "test.setRoomState":
checkSigned(false)
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "state_changed", Value: true}, {Name: "room_hash", Value: "h"}}}}).Marshal()))
case "test.getRoomState":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_state", Value: xmlrpc.Struct{
{Name: "room_info", Value: xmlrpc.Struct{{Name: "customers_id", Value: "7"}, {Name: "room_instance_id", Value: "7-1"}, {Name: "room_pid", Value: "300"}}},
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(10), int64(301), "2-1"}}}}},
{Name: "room_hash", Value: "h"}}}}).Marshal()))
case "test.showInventory":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).Marshal()))
case "test.inventoryProductBatch":
checkSigned(false)
var out []xmlrpc.Value
for _, v := range call.Params[0].([]xmlrpc.Value) {
out = append(out, xmlrpc.Struct{{Name: "products_id", Value: v}, {Name: "products_name", Value: "IMVU's"},
{Name: "cPath", Value: "106_40"}, {Name: "manufacturers_name", Value: "IMVU Creator"}})
}
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{out}}).Marshal()))
case "test.authorizeProducts2":
checkSigned(false)
cols := []xmlrpc.Value{}
for _, c := range authColumns {
cols = append(cols, c)
}
rows := []xmlrpc.Value{cols}
auths, _ := call.Params[0].(xmlrpc.Struct).Get("auths")
for _, a := range auths.([]xmlrpc.Value)[1:] {
pid := a.([]xmlrpc.Value)[1].(int64)
rows = append(rows, []xmlrpc.Value{int64(7), int64(400), pid, "sum", fmt.Sprintf("http://x/productdata/%d/1", pid), "", false, false, int64(1), true, int64(40), true, ""},
[]xmlrpc.Value{int64(7), int64(0), int64(400), "sum2", "http://x/productdata/400/1", "", false, false, int64(1), true, int64(40), true, ""})
}
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{rows}}).Marshal()))
}
}
f.t.Errorf("unexpected request to IMVU: %s", r.URL)
return reply("")
}
type harness struct {
t *testing.T
p *Products
imvu *fakeIMVU
proxy *httptest.Server
}
func newHarness(t *testing.T) (*harness, string) {
packs := t.TempDir()
writePack(t, packs, "blue-coat", 1)
p, err := New([]string{packs}, t.TempDir(), t.Logf)
if err != nil {
t.Fatal(err)
}
f := &fakeIMVU{t: t, asked: map[string][]string{}, look: []int64{80, 1}}
srv := httptest.NewServer(&proxy.Server{Token: token, Transport: f, Interceptor: p, IMQFilter: p})
t.Cleanup(srv.Close)
return &harness{t: t, p: p, imvu: f, proxy: srv}, packs
}
// do sends a request through the proxy as the client does: signed (if signed), in absolute form.
func (h *harness) do(method, url string, body []byte, signed, withQuery bool) (int, []byte) {
h.t.Helper()
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("t5de:"+token)))
if signed {
req.Header.Set("X-imvu-userid", user)
req.Header.Set("X-imvu-auth", sign(user, key, body, req.URL.RawQuery, withQuery))
}
conn, err := net.Dial("tcp", h.proxy.Listener.Addr().String())
if err != nil {
h.t.Fatal(err)
}
defer conn.Close()
req.WriteProxy(conn)
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
if err != nil {
h.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func call(method string, params ...xmlrpc.Value) []byte {
return (&xmlrpc.Call{Method: method, Params: params}).Marshal()
}
func TestCommunityProductEndToEnd(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
if local != FirstLocalID {
t.Fatalf("local ID %d", local)
}
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
// Logging in teaches the proxy the session key.
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
// Product info: the community product's from its parent's, with its own name; IMVU is asked for the parent.
_, body := h.do("POST", api, call("test.inventoryProductBatch", []xmlrpc.Value{int64(1), local}), true, false)
r, err := xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err, string(body))
}
list := r.Params[0].([]xmlrpc.Value)
if len(list) != 2 {
t.Fatalf("%d entries: %s", len(list), body)
}
ours := list[1].(xmlrpc.Struct)
if id, _ := ours.Get("products_id"); id != local {
t.Fatalf("entry %v", ours)
}
// Names lose characters the client's pages could take as HTML.
if name, _ := ours.Get("products_name"); name != "Blue Coat" {
t.Fatalf("name %v", name)
}
if path, _ := ours.Get("cPath"); path != "106_40" {
t.Fatalf("cPath %v", path)
}
if asked := h.imvu.asked["test.inventoryProductBatch"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(local)) || !strings.Contains(asked[0], "<int>500</int>") {
t.Fatalf("IMVU was asked %v", asked)
}
// Permission: our row (with the CFL's address and checksum) and IMVU's rows for the parent's chain.
auth := xmlrpc.Struct{{Name: "auths", Value: []xmlrpc.Value{[]xmlrpc.Value{"userId", "productId"}, []xmlrpc.Value{int64(7), local}}},
{Name: "use_extracted_assets_if_available", Value: true}, {Name: "max_supported_format", Value: int64(9)}}
_, body = h.do("POST", api, call("test.authorizeProducts2", auth), true, false)
r, err = xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err)
}
rows := r.Params[0].([]xmlrpc.Value)
var cflAddress string
for _, row := range rows[1:] {
values := row.([]xmlrpc.Value)
if values[2] == local {
cflAddress = values[4].(string)
if values[1] != int64(parent) || values[3] != h.p.byProductID("blue-coat").checksum || values[6] != true {
t.Fatalf("our row %v", values)
}
}
}
if cflAddress == "" || len(rows) != 4 {
t.Fatalf("rows %v", rows)
}
// The CFL: the pack's files, with the pack's name in index.xml. IMVU is never asked.
status, data := h.do("GET", cflAddress, nil, false, false)
entries, err := cfl.Read(data)
if status != 200 || err != nil || len(entries) != 2 {
t.Fatalf("%d %v %v", status, err, entries)
}
if !bytes.Contains(entries[0].Data, []byte("<ProductName>Blue &lt;Coat&gt;</ProductName>")) {
t.Fatalf("index.xml %s", entries[0].Data)
}
if status, _ := h.do("GET", strings.Replace(cflAddress, "/1", "/2", 1), nil, false, false); status != 404 {
t.Fatalf("another revision: %d", status)
}
// Saving an outfit with it: IMVU gets the outfit without it; loading the outfit adds it back.
h.do("POST", "http://client-dynamic.imvu.com/api/base_look.php", []byte(fmt.Sprintf(`{"pids": [80, 1, %d]}`, local)), true, true)
if saved := h.imvu.asked["save look"]; len(saved) != 1 || saved[0] != `{"pids":[80,1]}` {
t.Fatalf("IMVU saved %v", saved)
}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != fmt.Sprintf(`{"result":[80,1,%d]}`, local) {
t.Fatalf("look %s", body)
}
_, body = h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
if !bytes.Contains(body, []byte(fmt.Sprintf("<int>%d</int>", local))) {
t.Fatalf("login look %s", body)
}
// If the outfit IMVU has changes (saved elsewhere), it comes back without community products.
h.imvu.look = []int64{80, 2}
_, body = h.do("GET", "http://client-dynamic.imvu.com/api/base_look.php", nil, true, true)
if string(body) != `{"result":[80,2]}` {
t.Fatalf("changed look %s", body)
}
// The shop's bundle lookup is answered without IMVU.
if _, body := h.do("GET", fmt.Sprintf("http://client-dynamic.imvu.com/api/shop/product.php?pid=%d", local), nil, true, true); string(body) != `{"products":[]}` {
t.Fatalf("product.php %s", body)
}
}
func TestOutgoingChat(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
msg := func(text string) []byte {
return []byte(fmt.Sprintf(`{"to": 0, "message": %q, "userId": 7, "chatId": 9}`, text))
}
out, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use 80 1 %d", local)))
if drop || !bytes.Contains(out, []byte(`"message":"*use 80 1"`)) {
t.Fatalf("%s %v", out, drop)
}
if got := h.p.wornLocal(user, []int64{1, 80}); len(got) != 1 || got[0] != local {
t.Fatalf("worn %v", got)
}
// With a different IMVU outfit (changed elsewhere), community products aren't added to it.
if got := h.p.wornLocal(user, []int64{80, 2}); len(got) != 0 {
t.Fatalf("worn with another outfit %v", got)
}
if _, drop := h.p.Outgoing("/chat/9", "messages", msg(fmt.Sprintf("*use %d", local))); !drop {
t.Fatal("a command with only community products was sent")
}
if out, _ := h.p.Outgoing("/chat/9", "messages", msg("hello")); !bytes.Equal(out, msg("hello")) {
t.Fatal("plain chat changed")
}
if out, _ := h.p.Outgoing("room_occupancy/1", "occupants", []byte("x")); string(out) != "x" {
t.Fatal("another queue changed")
}
}
func TestPackChangesAreServed(t *testing.T) {
h, packs := newHarness(t)
before := h.p.byProductID("blue-coat")
os.WriteFile(filepath.Join(packs, "blue-coat", "files", "tex.png"), []byte("a new texture, longer"), 0o644)
h.p.refresh(true)
after := h.p.byProductID("blue-coat")
if after.checksum == before.checksum || after.local != before.local {
t.Fatalf("checksum %s -> %s, local %d -> %d", before.checksum, after.checksum, before.local, after.local)
}
os.RemoveAll(filepath.Join(packs, "blue-coat"))
h.p.refresh(true)
if h.p.byProductID("blue-coat") != nil {
t.Fatal("a removed pack is still served")
}
}
func TestInventory(t *testing.T) {
h, packs := newHarness(t)
hubDir := t.TempDir()
writePack(t, hubDir, "abcdefgh23", 1) // a hub product, kept by the fetcher
writePack(t, hubDir, "zzzzzzzz22", 1) // one someone in a room wore, not in the library
h.p.dirs = append(h.p.dirs, hubDir)
h.p.HubDir = hubDir
h.p.Owned = func(id string) bool { return id == "abcdefgh23" }
var hidden []string
h.p.OnHide = func(id string) { hidden = append(hidden, id) }
h.p.refresh(true)
local, hubLocal := h.p.byProductID("blue-coat").local, h.p.byProductID("abcdefgh23").local
api := "http://client-dynamic.imvu.com/api/xmlrpc/client.php"
_, body := h.do("POST", api, call("test.showInventory", int64(7), []xmlrpc.Value{int64(495)}, false), true, false)
r, err := xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err)
}
got := fmt.Sprint(r.Params[0])
if want := fmt.Sprint([]xmlrpc.Value{int64(1), int64(2), local, hubLocal}); got != want {
t.Fatalf("inventory %s, want %s", got, want)
}
// Hiding a community product is answered here (and for hub products, takes them out of the library).
status, body := h.do("POST", "http://client-dynamic.imvu.com/api/service/inventory/inventory_delete.php",
[]byte(fmt.Sprintf("action=delete&cid=7&pid=%d", hubLocal)), true, false)
if status != 200 || !strings.Contains(string(body), `"success": true`) || len(hidden) != 1 || hidden[0] != "abcdefgh23" {
t.Fatalf("hide: %d %s %v", status, body, hidden)
}
// A product whose info changes gets a new local ID, so the client's product info cache shows the new info.
dir := filepath.Join(packs, "blue-coat")
manifest := `{"format": 1, "id": "blue-coat", "revision": 1, "name": "Red Coat", "creator": "Tester", "rating": "general"}`
os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644)
h.p.refresh(true)
if again := h.p.byProductID("blue-coat").local; again == local {
t.Fatal("same local ID after a rename")
} else if id, _ := h.p.state.productID(again); id != "blue-coat" {
t.Fatalf("product of the new local ID: %q", id)
}
}
func TestSavedOutfits(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
// Saving: IMVU gets the outfit without the community product.
// (2100000000 is a community ID the proxy doesn't know: it's kept from IMVU all the same.)
save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d,2100000000", "privacy": 1}`, local))
if status, _ := h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false); status != 200 {
t.Fatalf("status %d", status)
}
if got := h.imvu.asked["save outfit"]; len(got) != 1 || !strings.Contains(got[0], `"products":"80,1"`) {
t.Fatalf("IMVU got %q", got)
}
// Loading saved outfits: it's back in that outfit only.
_, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfits.php?batch=555,9", nil, false, false)
var r struct {
Result []struct {
ID string `json:"user_outfits_id"`
Pids []int64 `json:"pids"`
} `json:"result"`
}
if err := json.Unmarshal(body, &r); err != nil {
t.Fatal(err, string(body))
}
if fmt.Sprint(r.Result[0].Pids) != fmt.Sprint([]int64{80, 1, local}) || fmt.Sprint(r.Result[1].Pids) != "[80]" {
t.Fatalf("outfits %s", body)
}
}
func TestHostedRoomProducts(t *testing.T) {
h, _ := newHarness(t)
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
h.p.HostedOwner = func() int64 { return 7 } // the fake IMVU answers as user 7
auths := xmlrpc.Struct{{Name: "auths", Value: []xmlrpc.Value{
[]xmlrpc.Value{"userId", "productId"}, []xmlrpc.Value{int64(HostedOwnerID), int64(500)}}}}
_, body := h.do("POST", "http://client-dynamic.imvu.com/api/xmlrpc/client.php", call("test.authorizeProducts2", auths), true, false)
asked := h.imvu.asked["test.authorizeProducts2"]
if len(asked) != 1 || strings.Contains(asked[0], "2100000000") {
t.Fatalf("IMVU was asked %q", asked)
}
r, err := xmlrpc.ParseResponse(body)
if err != nil {
t.Fatal(err)
}
rows := r.Params[0].([]xmlrpc.Value)
if first := rows[1].([]xmlrpc.Value); first[0] != int64(HostedOwnerID) || first[2] != int64(500) {
t.Fatalf("answer %v", rows)
}
}