package web import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "testing" "time" "github.com/coder/websocket" embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/jackc/pgx/v5" "git.t5de.dev/t5de/t5de-hub/internal/imvu" "git.t5de.dev/t5de/t5de-hub/internal/realtime" "git.t5de.dev/t5de/t5de-hub/internal/store" "git.t5de.dev/t5de/t5de-hub/internal/token" ) // The tests run the whole hub against a real Postgres (embedded, downloaded once) and a fake IMVU API. var dbURL string func TestMain(m *testing.M) { // CI gives a Postgres of its own (embedded Postgres won't run as root, as CI jobs do). if url := os.Getenv("T5DE_TEST_DB"); url != "" { dbURL = url os.Exit(m.Run()) } dir, _ := os.MkdirTemp("", "t5de-hub-test") cache := os.Getenv("T5DE_PG_CACHE") if cache == "" { home, _ := os.UserCacheDir() cache = filepath.Join(home, "t5de-hub-test-postgres") } const port = 54339 pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port). DataPath(filepath.Join(dir, "data")).RuntimePath(filepath.Join(dir, "rt")).CachePath(cache). Logger(io.Discard).StartTimeout(2 * time.Minute)) if err := pg.Start(); err != nil { fmt.Println("starting Postgres:", err) os.Exit(1) } dbURL = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) code := m.Run() pg.Stop() os.RemoveAll(dir) os.Exit(code) } // fakeIMVU is IMVU's public user API, with accounts the test controls. type fakeIMVU struct { mu sync.Mutex users map[int64]*imvu.User } func (f *fakeIMVU) set(u imvu.User) { f.mu.Lock() defer f.mu.Unlock() f.users[u.ID] = &u } func (f *fakeIMVU) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var found *imvu.User for _, u := range f.users { if r.URL.Path == fmt.Sprintf("/user/user-%d", u.ID) || r.URL.Path == "/user" && strings.EqualFold(r.URL.Query().Get("username"), u.Username) { found = u } } den := map[string]any{} if found != nil { den[fmt.Sprintf("https://api.imvu.com/user/user-%d", found.ID)] = map[string]any{"data": map[string]any{ "legacy_cid": found.ID, "username": found.Username, "tagline": found.Tagline, "is_ageverified": found.AgeVerified, "is_ap": found.AccessPass}} } json.NewEncoder(w).Encode(map[string]any{"status": "success", "id": r.URL.String(), "denormalized": den}) } type hub struct { t *testing.T server *Server http *httptest.Server imvu *fakeIMVU } // newHub runs a hub on a fresh database. func newHub(t *testing.T) *hub { ctx := context.Background() admin, err := pgx.Connect(ctx, dbURL) if err != nil { t.Fatal(err) } db := "t" + strings.ToLower(token.Random(6)) db = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(db, "x") if _, err := admin.Exec(ctx, "create database "+db); err != nil { t.Fatal(err) } admin.Close(ctx) u, _ := url.Parse(dbURL) u.Path = "/" + db st, err := store.Open(ctx, u.String()) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) fake := &fakeIMVU{users: map[int64]*imvu.User{}} imvuServer := httptest.NewServer(fake) t.Cleanup(imvuServer.Close) keys, err := token.LoadOrCreate(filepath.Join(t.TempDir(), "key"), "http://hub.test") if err != nil { t.Fatal(err) } s := &Server{Store: st, IMVU: &imvu.Client{Base: imvuServer.URL, HTTP: imvuServer.Client()}, Keys: keys, Log: log.New(io.Discard, "", 0)} s.Realtime = realtime.New(Owners{Store: st}, s.Log) handler, err := s.Handler() if err != nil { t.Fatal(err) } srv := httptest.NewServer(handler) t.Cleanup(srv.Close) s.PublicURL = srv.URL return &hub{t: t, server: s, http: srv, imvu: fake} } // browser is a person on the website. type browser struct { h *hub client *http.Client csrf string } func (h *hub) browser() *browser { jar, _ := cookiejar.New(nil) return &browser{h: h, client: &http.Client{Jar: jar}} } var csrfPattern = regexp.MustCompile(`name="csrf" value="([^"]+)"`) func (b *browser) get(path string) (int, string) { b.h.t.Helper() resp, err := b.client.Get(b.h.http.URL + path) if err != nil { b.h.t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if m := csrfPattern.FindSubmatch(body); m != nil { b.csrf = string(m[1]) } return resp.StatusCode, string(body) } func (b *browser) post(path string, form url.Values) (int, string) { b.h.t.Helper() if form == nil { form = url.Values{} } if b.csrf != "" && form.Get("csrf") == "" { form.Set("csrf", b.csrf) } req, _ := http.NewRequest("POST", b.h.http.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Sec-Fetch-Site", "same-origin") resp, err := b.client.Do(req) if err != nil { b.h.t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if m := csrfPattern.FindSubmatch(body); m != nil { b.csrf = string(m[1]) } return resp.StatusCode, string(body) } func (b *browser) signup(username string) { b.h.t.Helper() status, body := b.post("/signup", url.Values{"username": {username}, "password": {"a long password"}, "password2": {"a long password"}}) if status != 200 || !strings.Contains(body, "Signed in as "+username) { b.h.t.Fatalf("signup %s: %d %s", username, status, body) } } var codePattern = regexp.MustCompile(`t5de-[A-Z0-9]{4}-[A-Z0-9]{4}`) // link links an IMVU account through the website, putting the code in its fake tagline. func (b *browser) link(u imvu.User) string { b.h.t.Helper() b.h.imvu.set(u) _, body := b.post("/account/link", url.Values{"username": {u.Username}}) code := codePattern.FindString(body) if code == "" { b.h.t.Fatalf("no code: %s", body) } u.Tagline = "hello " + strings.ToLower(code) b.h.imvu.set(u) _, body = b.post(fmt.Sprintf("/account/link/%d/check", u.ID), nil) return body } func TestSignupSigninAndCSRF(t *testing.T) { h := newHub(t) a := h.browser() a.signup("alice") // The first account is the admin. if _, body := a.get("/account"); !strings.Contains(body, ">admin<") { t.Fatal("first account isn't admin") } b := h.browser() b.signup("bob") if status, _ := b.get("/console"); status != http.StatusForbidden { t.Fatalf("bob sees the console: %d", status) } // Taken usernames (any case), bad passwords. c := h.browser() if status, body := c.post("/signup", url.Values{"username": {"ALICE"}, "password": {"a long password"}, "password2": {"a long password"}}); status != 400 || !strings.Contains(body, "taken") { t.Fatalf("%d %s", status, body) } if status, _ := c.post("/signup", url.Values{"username": {"carol"}, "password": {"short"}, "password2": {"short"}}); status != 400 { t.Fatalf("short password: %d", status) } // Signing in, with the right and wrong password. if status, body := c.post("/signin", url.Values{"username": {"bob"}, "password": {"wrong"}}); status != 400 || !strings.Contains(body, "Wrong") { t.Fatalf("%d %s", status, body) } if _, body := c.post("/signin", url.Values{"username": {"Bob"}, "password": {"a long password"}}); !strings.Contains(body, "Signed in as bob") { t.Fatal("sign-in failed") } // A form without the session's CSRF token is refused. if status, _ := c.post("/account/link", url.Values{"username": {"x"}, "csrf": {"forged"}}); status != http.StatusForbidden { t.Fatalf("forged CSRF: %d", status) } } func TestLinkingByTagline(t *testing.T) { h := newHub(t) a := h.browser() a.signup("alice") // Without the code in the tagline, nothing is linked. h.imvu.set(imvu.User{ID: 42, Username: "AliceAv"}) a.post("/account/link", url.Values{"username": {"aliceav"}}) if _, body := a.post("/account/link/42/check", nil); !strings.Contains(body, "doesn't have the code") { t.Fatalf("linked without the code: %s", body) } body := a.link(imvu.User{ID: 42, Username: "AliceAv", AgeVerified: true}) if !strings.Contains(body, "Linked AliceAv") || !strings.Contains(body, "age verified") { t.Fatalf("%s", body) } if _, body := a.post("/account/link", url.Values{"username": {"nobody"}}); !strings.Contains(body, "IMVU has no account") { t.Fatalf("%s", body) } // Bob proves the same account is his (it was sold, say): it moves to him, with a new pairwise ID. before, _ := h.server.Store.LinkByIMVUID(context.Background(), 42) b := h.browser() b.signup("bob") b.link(imvu.User{ID: 42, Username: "AliceAv"}) after, _ := h.server.Store.LinkByIMVUID(context.Background(), 42) if after.PersonID == before.PersonID || after.PairwiseID == before.PairwiseID { t.Fatalf("not moved: %+v -> %+v", before, after) } if _, body := a.get("/account"); strings.Contains(body, "AliceAv") { t.Fatal("alice still has the account") } // The link cap: bob may link only one more after a moderator lowers it. bob, _ := h.server.Store.PersonByUsername(context.Background(), "bob") a.get("/console/people/" + fmt.Sprint(bob.ID)) if status, _ := a.post(fmt.Sprintf("/console/people/%d/cap", bob.ID), url.Values{"cap": {"1"}}); status != 200 { t.Fatalf("setting the cap: %d", status) } if body := b.link(imvu.User{ID: 43, Username: "BobAlt"}); !strings.Contains(body, "link up to 1") { t.Fatalf("over the cap: %s", body) } } func TestProxySignInAndPresence(t *testing.T) { h := newHub(t) a := h.browser() a.signup("alice") a.link(imvu.User{ID: 42, Username: "AliceAv"}) // The proxy asks for a code, alice enters it, the proxy gets tokens. resp, err := http.Post(h.http.URL+"/api/v1/device/code", "", nil) if err != nil { t.Fatal(err) } var device struct{ DeviceCode, UserCode string } var raw map[string]any json.NewDecoder(resp.Body).Decode(&raw) resp.Body.Close() device.DeviceCode, device.UserCode = raw["device_code"].(string), raw["user_code"].(string) poll := func() map[string]any { resp, err := http.PostForm(h.http.URL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out map[string]any json.NewDecoder(resp.Body).Decode(&out) return out } if out := poll(); out["error"] != "authorization_pending" { t.Fatalf("%v", out) } a.get("/device") if _, body := a.post("/device", url.Values{"code": {strings.ToLower(strings.ReplaceAll(device.UserCode, "-", ""))}}); !strings.Contains(body, "T5DE is signed in") { t.Fatalf("%s", body) } tokens := poll() access, _ := tokens["access_token"].(string) if access == "" || tokens["refresh_token"] == "" { t.Fatalf("%v", tokens) } if out := poll(); out["error"] != "expired_token" { t.Fatalf("a code worked twice: %v", out) } // The refresh token rotates: the old one stops working. refresh := func(tok string) map[string]any { resp, err := http.PostForm(h.http.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {tok}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out map[string]any json.NewDecoder(resp.Body).Decode(&out) return out } next := refresh(tokens["refresh_token"].(string)) if next["access_token"] == nil || refresh(tokens["refresh_token"].(string))["error"] != "invalid_grant" { t.Fatalf("rotation: %v", next) } // The live connection: an account that isn't alice's is refused; hers shows as online (to moderators, and to // everyone once she opts in) with her community outfit. ctx := context.Background() ws, _, err := websocket.Dial(ctx, strings.Replace(h.http.URL, "http", "ws", 1)+"/api/v1/realtime", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": {"Bearer " + access}}}) if err != nil { t.Fatal(err) } defer ws.CloseNow() read := func() map[string]any { _, data, err := ws.Read(ctx) if err != nil { t.Fatal(err) } var m map[string]any json.Unmarshal(data, &m) return m } if m := read(); m["type"] != "welcome" { t.Fatalf("%v", m) } ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":99}`)) if m := read(); m["message"] != "not_linked" { t.Fatalf("%v", m) } ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":42}`)) ws.Write(ctx, websocket.MessageText, []byte(`{"type":"outfit","items":[{"id":"blue-coat","name":"Blue Coat"}]}`)) waitFor(t, func() bool { o := h.server.Realtime.Online(); return len(o) == 1 && len(o[0].Outfit) == 1 }) visitor := h.browser() if _, body := visitor.get("/online"); strings.Contains(body, "AliceAv") { t.Fatal("online shown without opting in") } if _, body := a.get("/console"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") { t.Fatal("the console doesn't show her") } a.post("/account/links/42/settings", url.Values{"presence": {"on"}, "badge": {"on"}}) if _, body := visitor.get("/online"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") { t.Fatal("online not shown after opting in") } // A ban ends the connection and the sign-in. bob := h.browser() bob.signup("bob") alice, _ := h.server.Store.PersonByUsername(ctx, "alice") h.server.Store.SetRole(ctx, alice.ID, "user") bobP, _ := h.server.Store.PersonByUsername(ctx, "bob") h.server.Store.SetRole(ctx, bobP.ID, "admin") bob.get("/account") if status, _ := bob.post(fmt.Sprintf("/console/people/%d/ban", alice.ID), url.Values{"reason": {"testing"}}); status != 200 { t.Fatalf("ban: %d", status) } waitFor(t, func() bool { return len(h.server.Realtime.Online()) == 0 }) if out := refresh(next["refresh_token"].(string)); out["access_token"] != nil { t.Fatal("a banned person's proxy can still sign in") } } func waitFor(t *testing.T, ok func() bool) { t.Helper() for i := 0; i < 200; i++ { if ok() { return } time.Sleep(10 * time.Millisecond) } t.Fatal("timed out") }