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, Hub: srv.URL, Default: 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) } // Other requests to the proxy's address are refused. u, _ := url.Parse("http://" + Host + "/other") req, _ := http.NewRequest("POST", u.String(), nil) 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"), Hub: 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) } }