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/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.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) } if name, _ := ours.Get("products_name"); name != "Blue " { 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") } }