CI / build (push) Successful in 28s
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send. It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which had been sending them to IMVU. Co-Authored-By: Claude Opus 5.5 <[email protected]>
318 lines
12 KiB
Go
318 lines
12 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/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 <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")
|
|
}
|
|
}
|