CI / build (push) Successful in 36s
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]>
260 lines
10 KiB
Go
260 lines
10 KiB
Go
package hosted
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
|
)
|
|
|
|
// fakeHub answers joins at once (through rooms.FromHub) and records what it's sent.
|
|
type fakeHub struct {
|
|
mu sync.Mutex
|
|
rooms *Rooms
|
|
sent []map[string]any
|
|
scene []any
|
|
}
|
|
|
|
func (h *fakeHub) Send(v any) bool {
|
|
m := v.(map[string]any)
|
|
h.mu.Lock()
|
|
h.sent = append(h.sent, m)
|
|
h.mu.Unlock()
|
|
if m["type"] == "room.join" {
|
|
n := m["room"].(int64)
|
|
var answer string
|
|
if n == 9 {
|
|
answer = `{"type": "room.error", "room": 9, "reason": "full", "message": "This room is full."}`
|
|
} else {
|
|
answer = fmt.Sprintf(`{"type": "room.joined", "room": %d, "chat": %d, "sequence": 4,
|
|
"participants": [{"imvu_id": 7, "seat": 1}, {"imvu_id": 88, "seat": 2}]}`, n, Base+n)
|
|
}
|
|
go h.rooms.FromHub([]byte(answer))
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any) error {
|
|
switch {
|
|
case method == "GET" && path == "/api/v1/hosted-rooms/3":
|
|
return json.Unmarshal([]byte(`{"room": {"n": 3, "name": "Lounge <b>", "ownerImvuId": 88, "ownerName": "Erin",
|
|
"roomPid": 50168116, "rating": "general", "maxUsers": 10, "occupancy": 2},
|
|
"scene": {"contents": [[1, 47477426, "37207-46"], [2, "coatcoat22", "37-11"], [3, "notherenn2", "37-12"]], "hash": "h1"}}`), out)
|
|
case method == "GET" && path == "/api/v1/hosted-rooms":
|
|
return json.Unmarshal([]byte(`{"rooms": [{"n": 3, "name": "Lounge", "ownerName": "Erin", "maxUsers": 10, "occupancy": 2}]}`), out)
|
|
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
|
|
h.mu.Lock()
|
|
h.scene = append(h.scene, body)
|
|
h.mu.Unlock()
|
|
return nil
|
|
}
|
|
return fmt.Errorf("unexpected %s %s", method, path)
|
|
}
|
|
|
|
func (h *fakeHub) sentOf(kind string) []map[string]any {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
var out []map[string]any
|
|
for _, m := range h.sent {
|
|
if m["type"] == kind {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func setup() (*Rooms, *fakeHub, *[]string) {
|
|
h := &fakeHub{}
|
|
var events []string
|
|
r := &Rooms{Hub: h, User: func() int64 { return 7 },
|
|
OnJoin: func(chat, room string) { events = append(events, "join "+chat+" "+room) },
|
|
OnLeave: func(chat string) { events = append(events, "leave "+chat) },
|
|
LocalProduct: func(id string) (int64, bool) { return 2000000005, id == "coatcoat22" },
|
|
GlobalProduct: func(local int64) (string, bool) { return "coatcoat22", local == 2000000005 }}
|
|
h.rooms = r
|
|
return r, h, &events
|
|
}
|
|
|
|
func xmlrpcCall(t *testing.T, r *Rooms, host, method string, args xmlrpc.Struct) (*xmlrpc.Response, bool) {
|
|
t.Helper()
|
|
body := (&xmlrpc.Call{Method: method, Params: []xmlrpc.Value{args}}).Marshal()
|
|
u, _ := url.Parse("https://" + host + "/api/xmlrpc/" + map[bool]string{true: "chat.php", false: "client.php"}[strings.HasPrefix(method, "chat.")])
|
|
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
|
|
x := &proxy.Exchange{URL: u, Request: req, Body: body}
|
|
r.Intercept(x)
|
|
if x.Response == nil {
|
|
return nil, false
|
|
}
|
|
b, _ := io.ReadAll(x.Response.Body)
|
|
resp, err := xmlrpc.ParseResponse(b)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v\n%s", method, err, b)
|
|
}
|
|
return resp, true
|
|
}
|
|
|
|
func get(v xmlrpc.Value, name string) xmlrpc.Value {
|
|
s, _ := v.(xmlrpc.Struct)
|
|
out, _ := s.Get(name)
|
|
return out
|
|
}
|
|
|
|
// frames reads the frames the proxy sent the client.
|
|
func frames(t *testing.T, b []byte) []imq.Frame {
|
|
var out []imq.Frame
|
|
for len(b) > 0 {
|
|
f, n, err := imq.Next(b)
|
|
if err != nil || n == 0 {
|
|
t.Fatalf("frames: %v", err)
|
|
}
|
|
out = append(out, f)
|
|
b = b[n:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestHostedRoom(t *testing.T) {
|
|
r, h, events := setup()
|
|
|
|
// IMVU's rooms are left alone.
|
|
if _, answered := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-170879926-5533"}}); answered {
|
|
t.Fatal("answered an IMVU room")
|
|
}
|
|
|
|
// Joining: the hub lets the client in; it sits in the first free seat.
|
|
resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-2100000000-3"}})
|
|
if get(resp.Params[0], "chatId") != int64(2100000003) || get(resp.Params[0], "seat") != int64(1) || get(resp.Params[0], "lastMessageId") != int64(4) {
|
|
t.Fatalf("join answer %v", resp.Params[0])
|
|
}
|
|
if len(*events) != 1 || (*events)[0] != "join 2100000003 2100000000-3" {
|
|
t.Fatalf("events %v", *events)
|
|
}
|
|
// A full room: the client is told why.
|
|
if resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-2100000000-9"}}); resp.Fault == nil || resp.Fault.String != "This room is full." {
|
|
t.Fatalf("full room: %+v", resp)
|
|
}
|
|
|
|
// Others in the room, without the client.
|
|
resp, _ = xmlrpcCall(t, r, "chat.imvu.com", "chat.getParticipants", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
|
|
others := resp.Params[0].([]xmlrpc.Value)
|
|
if len(others) != 1 || get(others[0], "userId") != int64(88) {
|
|
t.Fatalf("participants %v", others)
|
|
}
|
|
|
|
// IMQ: the room's queues are answered here; IMVU gets the rest.
|
|
var toClient bytes.Buffer
|
|
c := proxy.NewIMQConn(1, &toClient)
|
|
sub := imq.Subscription{Queues: []string{"/user/7"}, WithResults: []imq.QueueOp{{Queue: "/chat/2100000003", OpID: 5}, {Queue: "/chat/1385863547", OpID: 6}}}
|
|
rest := r.Route(c, false, sub)
|
|
if fmt.Sprint(rest.All()) != "[/user/7 /chat/1385863547]" {
|
|
t.Fatalf("for IMVU: %v", rest.All())
|
|
}
|
|
var types []string
|
|
for _, f := range frames(t, toClient.Bytes()) {
|
|
types = append(types, imq.TypeName(f.Type))
|
|
}
|
|
if got := strings.Join(types, " "); got != "G2cJoinedQueue G2cCreateMount G2cCreateMount G2cCreateMount G2cResult" {
|
|
t.Fatalf("frames %s", got)
|
|
}
|
|
|
|
// Chat goes to the hub; sitting down too.
|
|
if !r.Send("/chat/2100000003", "messages", []byte(`{"message": "*seat 2", "userId": 7}`)) || r.Send("/chat/1385863547", "messages", []byte(`{}`)) {
|
|
t.Fatal("Send's answer")
|
|
}
|
|
if seats, sends := h.sentOf("room.seat"), h.sentOf("room.send"); len(seats) != 1 || seats[0]["seat"] != 2 || len(sends) != 1 {
|
|
t.Fatalf("seats %v, sends %v", seats, sends)
|
|
}
|
|
|
|
// Others' chat comes back as the gateway would deliver it.
|
|
toClient.Reset()
|
|
r.FromHub([]byte(`{"type": "room.message", "room": 3, "from": 88, "mount": "messages", "message": "{\"message\":\"hi\",\"userId\":88}", "sequence": 5}`))
|
|
fs := frames(t, toClient.Bytes())
|
|
if len(fs) != 1 {
|
|
t.Fatalf("%d frames", len(fs))
|
|
}
|
|
d, _ := imq.ParseDelivered(fs[0].Data)
|
|
if d.UserID != "/user/88" || d.Queue != "/chat/2100000003" || d.Sequence != 5 || !strings.Contains(string(d.Message), "hi") {
|
|
t.Fatalf("delivered %+v", d)
|
|
}
|
|
|
|
// The room's card and scene come from the hub; the scene's community products as local IDs (missing ones left out).
|
|
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomCardInfo", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"}})
|
|
if get(resp.Params[0], "name") != "Lounge b" || get(resp.Params[0], "owner_cid") != int64(88) {
|
|
t.Fatalf("card %v", resp.Params[0])
|
|
}
|
|
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"}})
|
|
contents := get(get(resp.Params[0], "room_state"), "room_contents").([]xmlrpc.Value)
|
|
if len(contents) != 2 || fmt.Sprint(contents[1]) != "[2 2000000005 37-11]" || get(get(get(resp.Params[0], "room_state"), "room_info"), "room_pid") != "50168116" {
|
|
t.Fatalf("scene %v", resp.Params[0])
|
|
}
|
|
// Saving the scene: community products as the hub's IDs.
|
|
xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.setRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"},
|
|
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(47477426), "37-1"}, []xmlrpc.Value{int64(2), int64(2000000005), "37-2"}}}})
|
|
if b, _ := json.Marshal(h.scene); string(b) != `[{"contents":[[1,47477426,"37-1"],[2,"coatcoat22","37-2"]]}]` {
|
|
t.Fatalf("saved %s", b)
|
|
}
|
|
|
|
// The participant post for a hosted chat is answered here.
|
|
u, _ := url.Parse("https://api.imvu.com/chat/chat-2100000003/participants/user-7")
|
|
req, _ := http.NewRequest("POST", u.String(), nil)
|
|
x := &proxy.Exchange{URL: u, Request: req}
|
|
r.Intercept(x)
|
|
if x.Response == nil || x.Response.StatusCode != 200 {
|
|
t.Fatal("participant post not answered")
|
|
}
|
|
|
|
// Anything else naming a hosted room is answered here, not sent to IMVU.
|
|
for _, raw := range []string{"https://api.imvu.com/room/room-2100000000-3", "https://client-dynamic.imvu.com/api/rooms/room_rating.php?room_instance_id=2100000000-3",
|
|
"https://client-dynamic.imvu.com/api/whatever.php?chat=2100000003"} {
|
|
u, _ := url.Parse(raw)
|
|
req, _ := http.NewRequest("GET", u.String(), nil)
|
|
x := &proxy.Exchange{URL: u, Request: req}
|
|
r.Intercept(x)
|
|
if x.Response == nil {
|
|
t.Errorf("%s went to IMVU", raw)
|
|
}
|
|
}
|
|
|
|
// Leaving.
|
|
xmlrpcCall(t, r, "chat.imvu.com", "chat.terminateChat", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
|
|
if len(h.sentOf("room.leave")) != 1 || (*events)[len(*events)-1] != "leave 2100000003" {
|
|
t.Fatalf("leave: %v %v", h.sentOf("room.leave"), *events)
|
|
}
|
|
}
|
|
|
|
func TestRoomList(t *testing.T) {
|
|
r, _, _ := setup()
|
|
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=1")
|
|
req, _ := http.NewRequest("GET", u.String(), nil)
|
|
x := &proxy.Exchange{URL: u, Request: req}
|
|
r.Intercept(x)
|
|
body := []byte(`{"customers_room_id": ["644"], "customers_id": ["1"], "num_participants": [4], "name": ["A"], "participants": [[]], "is_ap": ["0"], "odd": [{"x": 1}], "number_of_rooms": 1}`)
|
|
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var l map[string]any
|
|
json.Unmarshal(out, &l)
|
|
if fmt.Sprint(l["customers_room_id"]) != "[644 3]" || fmt.Sprint(l["customers_id"]) != "[1 2100000000]" ||
|
|
fmt.Sprint(l["num_participants"]) != "[4 2]" || fmt.Sprint(l["odd"]) != "[map[x:1] map[]]" || l["number_of_rooms"] != float64(1) {
|
|
t.Fatalf("list %s", out)
|
|
}
|
|
// Other pages and searches are left alone.
|
|
u, _ = url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=2")
|
|
x = &proxy.Exchange{URL: u, Request: req}
|
|
r.Intercept(x)
|
|
if x.ModifyResponse != nil {
|
|
t.Fatal("changed page 2")
|
|
}
|
|
}
|