CI / build (push) Successful in 34s
- The inventory's Recently Added puts T5 products added to the library in the last 30 days first. - The shop's T5 listing follows its sort menu: Best Match ranks by name, Newest by upload date (oldest first ascending); ratings and prices (T5 products have neither) sort by newest. - Outfit cards: outfit_products.php gets the outfit's community products added (not for sale), find_bundle.php is asked about IMVU's products only, and removing one in the card (remove_pids) keeps it from IMVU and forgets it in the outfit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
420 lines
16 KiB
Go
420 lines
16 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 <Coat></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)
|
|
}
|
|
}
|