Files
t5de-proxy/internal/products/products_test.go
T
dhkatzandClaude Opus 5.5 d8332b21a8
CI / build (push) Successful in 36s
Serve T5DE-hosted rooms; a second client instance's own data
Hosted rooms (internal/hosted): room calls for them answered here, their IMQ queues served (a new
proxy.IMQRouter hook takes them out of subscriptions to IMVU), chat relayed through the hub's
WebSocket (hub.Client gains Send, OnRoom and OnConnect), the room list extended, room products
checked against the real owner, and anything else naming a hosted room kept from IMVU.

T5DE_INSTANCE gives a second client's proxy its own data folder and log.

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

441 lines
17 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.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)
}
}