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 ", "creator": "Tester", "rating": "general"}`, id, revision) os.WriteFile(filepath.Join(dir, "t5de-product.json"), []byte(manifest), 0o644) index := fmt.Sprintf(``, 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], "500") { 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("Blue <Coat>")) { 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("%d", 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) } }