CI / build (push) Successful in 29s
POST http://t5de-proxy.local/badges {imvuIds} -> {badges}: which IMVU accounts show the T5 badge, asked of the hub (POST /api/v1/badges, in batches of 200) and remembered for 10 minutes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
package control
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
|
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
|
)
|
|
|
|
var badgeAsks atomic.Int32
|
|
|
|
// fakeHub does device sign-in (approved on the second poll), /me and sign-out.
|
|
func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server {
|
|
var polls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/auth/device/code":
|
|
json.NewEncoder(w).Encode(map[string]any{"device_code": "dev", "user_code": "ABCD1234",
|
|
"verification_uri_complete": "http://hub.test/device?code=ABCD1234", "interval": 1, "expires_in": 60})
|
|
case "/api/auth/device/token":
|
|
if polls.Add(1) < 2 {
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"})
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"access_token": "session-token"})
|
|
case "/api/v1/me":
|
|
if r.Header.Get("Authorization") != "Bearer session-token" {
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
io.WriteString(w, `{"person": {"username": "erin"}}`)
|
|
case "/api/v1/badges":
|
|
var req struct{ ImvuIds []int64 }
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
badgeAsks.Add(1)
|
|
var even []int64
|
|
for _, id := range req.ImvuIds {
|
|
if id%2 == 0 {
|
|
even = append(even, id)
|
|
}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"badges": even})
|
|
case "/api/auth/sign-out":
|
|
signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token")
|
|
io.WriteString(w, `{"success": true}`)
|
|
default:
|
|
w.WriteHeader(404) // the realtime connection: not needed here
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func ask(t *testing.T, m *Manager, method, path, body string) status {
|
|
t.Helper()
|
|
u, _ := url.Parse("http://" + Host + path)
|
|
req, _ := http.NewRequest(method, u.String(), strings.NewReader(body))
|
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
|
if err := m.Intercept(x); err != nil || x.Response == nil {
|
|
t.Fatalf("%s %s: %v %v", method, path, err, x.Response)
|
|
}
|
|
var s status
|
|
b, _ := io.ReadAll(x.Response.Body)
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
t.Fatalf("%s: %s", path, b)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func waitFor(t *testing.T, ok func() bool) {
|
|
t.Helper()
|
|
for i := 0; i < 100 && !ok(); i++ {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if !ok() {
|
|
t.Fatal("timed out")
|
|
}
|
|
}
|
|
|
|
func TestSignInAndOut(t *testing.T) {
|
|
var signedOut atomic.Bool
|
|
srv := fakeHub(t, &signedOut)
|
|
path := filepath.Join(t.TempDir(), "hub.json")
|
|
var opened []string
|
|
attached := 0
|
|
m := &Manager{Path: path, DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0),
|
|
OpenBrowser: func(p string) { opened = append(opened, p) }, Attach: func(*hub.Client) { attached++ }}
|
|
m.Start()
|
|
if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || s.Hub != srv.URL {
|
|
t.Fatalf("status %+v", s)
|
|
}
|
|
|
|
// Signing in: the code shows, the browser opens the page, and once approved the proxy is connected.
|
|
ask(t, m, "POST", "/hub/signin", "")
|
|
waitFor(t, func() bool { return ask(t, m, "GET", "/hub", "").Code == "ABCD1234" })
|
|
waitFor(t, func() bool { s := ask(t, m, "GET", "/hub", ""); return s.SignedIn && s.Username == "erin" })
|
|
if len(opened) != 1 || attached != 1 {
|
|
t.Fatalf("opened %v, attached %d", opened, attached)
|
|
}
|
|
if saved, err := hub.Load(path); err != nil || saved.Token != "session-token" {
|
|
t.Fatalf("saved %+v %v", saved, err)
|
|
}
|
|
|
|
// Signing out ends the session on the hub and forgets the sign-in.
|
|
if s := ask(t, m, "POST", "/hub/signout", ""); s.SignedIn {
|
|
t.Fatalf("still signed in: %+v", s)
|
|
}
|
|
if !signedOut.Load() {
|
|
t.Fatal("the hub wasn't told")
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("hub.json: %v", err)
|
|
}
|
|
// Signing in again goes to the same hub.
|
|
m.DefaultHub = "https://elsewhere.test"
|
|
if s := ask(t, m, "GET", "/hub", ""); s.Hub != srv.URL {
|
|
t.Fatalf("hub after signing out: %q", s.Hub)
|
|
}
|
|
|
|
// Only https hubs (or this computer's); other requests to the proxy's address are refused.
|
|
u, _ := url.Parse("http://" + Host + "/hub/signin")
|
|
req, _ := http.NewRequest("POST", u.String(), nil)
|
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte("hub=http://evil.test")}
|
|
m.Intercept(x)
|
|
if x.Response.StatusCode != 400 {
|
|
t.Fatalf("plain http hub: %d", x.Response.StatusCode)
|
|
}
|
|
u, _ = url.Parse("http://" + Host + "/other")
|
|
x = &proxy.Exchange{URL: u, Request: req}
|
|
m.Intercept(x)
|
|
if x.Response.StatusCode != 404 {
|
|
t.Fatalf("other path: %d", x.Response.StatusCode)
|
|
}
|
|
// Requests to other hosts are left alone.
|
|
u, _ = url.Parse("http://api.imvu.com/hub")
|
|
x = &proxy.Exchange{URL: u, Request: req}
|
|
m.Intercept(x)
|
|
if x.Response != nil {
|
|
t.Fatal("answered another host")
|
|
}
|
|
}
|
|
|
|
func TestBadges(t *testing.T) {
|
|
var signedOut atomic.Bool
|
|
srv := fakeHub(t, &signedOut)
|
|
m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0)}
|
|
badges := func(body string) string {
|
|
t.Helper()
|
|
u, _ := url.Parse("http://" + Host + "/badges")
|
|
req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body))
|
|
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
|
|
m.Intercept(x)
|
|
b, _ := io.ReadAll(x.Response.Body)
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
// Signed out: nobody.
|
|
if got := badges(`{"imvuIds": [2, 3]}`); got != `{"badges":[]}` {
|
|
t.Fatalf("signed out: %s", got)
|
|
}
|
|
saved := &hub.Saved{URL: srv.URL, Token: "session-token"}
|
|
m.run(saved)
|
|
badgeAsks.Store(0)
|
|
if got := badges(`{"imvuIds": [2, 3, 4, 3]}`); got != `{"badges":[2,4]}` {
|
|
t.Fatalf("badges %s", got)
|
|
}
|
|
// Answers are remembered: only new IDs are asked about.
|
|
if got := badges(`{"imvuIds": [4, 3, 6]}`); got != `{"badges":[4,6]}` || badgeAsks.Load() != 2 {
|
|
t.Fatalf("badges %s after %d asks", got, badgeAsks.Load())
|
|
}
|
|
if got := badges(`{"imvuIds": "x"}`); !strings.Contains(got, "error") {
|
|
t.Fatalf("bad request: %s", got)
|
|
}
|
|
}
|