Files
t5de-hub/internal/web/web_test.go
T
2026-10-29 03:48:32 -07:00

417 lines
13 KiB
Go

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&#39;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")
}