Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
CI / test (push) Failing after 42s
CI / test (push) Failing after 42s
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open, reporting the IMVU account in use and the community products it wears; /online shows those who opted in. Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the website and API against an embedded Postgres and a fake IMVU API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
54e569bea3
commit
b9dc3f0ec1
36 files changed
+3548
-5
No files matched your search
@@ -0,0 +1,409 @@
|
||||
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) {
|
||||
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)
|
||||
st, err := store.Open(ctx, strings.Replace(dbURL, "/postgres?", "/"+db+"?", 1))
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user