Files
magpie/internal/provider/mimo_test.go
T
yetone 899ddf594c mimo: a Xiaomi MiMo account (the one MiMo's app signs in to) is signed in inside magpie and its models served through the gateway to any agent, and MiMo Code is an agent magpie configures (#249 thedavidweng) — magpie had neither. The subscription (id mimo-app, shown as Xiaomi MiMo) signs in with Xiaomi's long-poll login (account.xiaomi.com/longPolling/loginUrl, sid mimosgp) and keeps the MiMo server's cookie session (serviceToken, userId) in logins.json at 0600; the passToken is replayed to renew the session after a day or on a 401. Requests go to POST <base>/route/chat/completions on mimo-server-{sgp,ru,in}.xiaomimimo.com/api with X-Mimo-Source mimocode-cli-free and X-Client-Version; mimo-auto is sent as mimo-pro; the models are mimo-pro and mimo-flash. Usage from /user/usage and /user/xiaomi/subscription/self. Several accounts, switched, on/off and forgotten as the other subscriptions. The add list has a Xiaomi MiMo tile (Free · Starter · Plus · Pro · Ultra) that says Xiaomi may treat this as third-party use before anything is posted; en and zh. magpie accounts takes mimo. MiMo Code (agent mimocode, OpenCode-like) is configured in its config dir (MIMOCODE_HOME/config when set), reading mimocode.jsonc, mimocode.json or config.json, whichever is there. Tested with internal/provider/mimo_test.go against fake Xiaomi SSO and MiMo servers in a temp HOME, internal/agent tests for MiMo Code's config, and internal/gui/tests/mimo-sub.test.cjs, Chromium and WebKit, en and zh; they fail without the change. factory-sub, click-scroll, old-webkit, zed-sub and add-sheet pass; go vet, go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Xiaomi account: the long-poll's success payload, the SSO cookie chain, whether /route accepts magpie's requests, the UTC+8 times, and whether MiMo Code picks up the config; not tried in the real Wails window.
2026-09-30 07:43:58 +08:00

313 lines
11 KiB
Go

package provider
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// mimoAccountStub stands in for account.xiaomi.com: the long-poll sign-in
// (its ticket, and the poll answering with the passToken on its second ask)
// and serviceLogin, which sends a browser holding a good passToken back to
// the MiMo server's /sts and one without to its sign-in page.
type mimoAccountStub struct {
t *testing.T
url string
polls atomic.Int32
refuse atomic.Bool // the passToken no longer signs anything on
}
func (a *mimoAccountStub) serve(w http.ResponseWriter, r *http.Request) {
t := a.t
if !strings.HasPrefix(r.Header.Get("User-Agent"), "miNative PC/") {
t.Errorf("%s User-Agent %q", r.URL.Path, r.Header.Get("User-Agent"))
}
switch r.URL.Path {
case "/longPolling/loginUrl":
q := r.URL.Query()
if q.Get("sid") != "mimosgp" || !strings.HasSuffix(q.Get("callback"), "/api/sts") || q.Get("qs") != "%3Fsid%3Dmimosgp%26_json%3Dtrue" {
t.Errorf("loginUrl asked %s", r.URL.RawQuery)
}
if c, err := r.Cookie("deviceId"); err != nil || !strings.HasPrefix(c.Value, "pc_") {
t.Errorf("loginUrl without the device id")
}
fmt.Fprintf(w, `&&&START&&&{"code":0,"loginUrl":%q,"qr":%q,"lp":%q,"timeout":300}`,
a.url+"/pass/qr/login?ticket=t1", a.url+"/qr.png", a.url+"/lp/s?k=t1")
case "/lp/s":
if a.polls.Add(1) == 1 {
// held, then let go with nothing yet
_, _ = io.WriteString(w, `&&&START&&&{"code":700,"desc":"waiting"}`)
return
}
_, _ = io.WriteString(w, `&&&START&&&{"code":0,"userId":42,"cUserId":"cu-42","passToken":"pass-42","ssecurity":"s","location":"https://x/sts"}`)
case "/pass/serviceLogin":
c, err := r.Cookie("passToken")
if err != nil || c.Value != "pass-42" || a.refuse.Load() {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, "<html>Xiaomi account sign-in</html>")
return
}
if u, err := r.Cookie("userId"); err != nil || u.Value != "42" {
t.Errorf("serviceLogin without the account's id")
}
back, _ := url.Parse(r.URL.Query().Get("callback"))
q := back.Query()
q.Set("nonce", "n1")
q.Set("followup", r.URL.Query().Get("followup"))
back.RawQuery = q.Encode()
http.Redirect(w, r, back.String(), http.StatusFound)
default:
t.Errorf("account: unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}
// mimoServerStub stands in for one MiMo server: /user/xiaomi/me sends a
// request with no session to Xiaomi's serviceLogin, /sts sets a session,
// and the account's pages answer only to the latest session.
type mimoServerStub struct {
t *testing.T
acct *mimoAccountStub
base string
region string // what /user/xiaomi/me says the account's region is
mu sync.Mutex
token string
sessions atomic.Int32
}
func (m *mimoServerStub) signed(r *http.Request) bool {
m.mu.Lock()
defer m.mu.Unlock()
c, err := r.Cookie("serviceToken")
return err == nil && m.token != "" && c.Value == m.token
}
func (m *mimoServerStub) drop() {
m.mu.Lock()
m.token = "gone"
m.mu.Unlock()
}
func (m *mimoServerStub) serve(w http.ResponseWriter, r *http.Request) {
t := m.t
if r.Header.Get("X-Client-Version") != mimoAppVersion {
t.Errorf("%s X-Client-Version %q", r.URL.Path, r.Header.Get("X-Client-Version"))
}
switch r.URL.Path {
case "/api/user/xiaomi/me":
if !m.signed(r) {
q := url.Values{"callback": {m.base + "/sts"}, "followup": {m.base + "/user/xiaomi/me"}, "sid": {"mimosgp"}, "_group": {"DEFAULT"}}
http.Redirect(w, r, m.acct.url+"/pass/serviceLogin?"+q.Encode(), http.StatusFound)
return
}
fmt.Fprintf(w, `{"code":0,"data":{"userId":42,"nickname":"Mi Fan","region":%q,"country":"SG"}}`, m.region)
case "/api/sts":
if r.URL.Query().Get("nonce") != "n1" {
t.Errorf("/sts without Xiaomi's nonce")
}
n := m.sessions.Add(1)
m.mu.Lock()
m.token = fmt.Sprintf("st-%d", n)
tok := m.token
m.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "serviceToken", Value: tok, Path: "/"})
http.SetCookie(w, &http.Cookie{Name: "userId", Value: "42", Path: "/"})
http.Redirect(w, r, r.URL.Query().Get("followup"), http.StatusFound)
case "/api/user/usage", "/api/user/xiaomi/subscription/self":
if !m.signed(r) {
w.WriteHeader(401)
return
}
if r.URL.Path == "/api/user/usage" {
_, _ = io.WriteString(w, `{"code":0,"data":{"percent":70,"resetDate":"2026-10-05"}}`)
return
}
_, _ = io.WriteString(w, `{"code":0,"data":{"current":{"planCode":"mimo_pro_m","title":"MiMo 高阶","planTier":3,"status":"ACTIVE","renewalMode":"MONTHLY","endTime":"2026-11-01T00:00:00","source":"ORDER_SUB"}}}`)
default:
t.Errorf("mimo: unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}
// mimoStubs points magpie at a fake account.xiaomi.com (as localhost, so
// its cookies are kept apart from the servers') and fake MiMo servers, one
// per region given; the account says it is in the first region's.
func mimoStubs(t *testing.T, regions ...string) (*mimoAccountStub, map[string]*mimoServerStub) {
t.Helper()
acct := &mimoAccountStub{t: t}
as := httptest.NewServer(http.HandlerFunc(acct.serve))
t.Cleanup(as.Close)
acct.url = strings.Replace(as.URL, "127.0.0.1", "localhost", 1)
oldHosts, oldSite := mimoHosts, mimoAccountSite
t.Cleanup(func() { mimoHosts, mimoAccountSite = oldHosts, oldSite })
mimoAccountSite = acct.url
mimoHosts = map[string]string{}
servers := map[string]*mimoServerStub{}
for _, r := range regions {
m := &mimoServerStub{t: t, acct: acct, region: regions[0]}
s := httptest.NewServer(http.HandlerFunc(m.serve))
t.Cleanup(s.Close)
m.base = s.URL + "/api"
mimoHosts[r] = m.base
servers[r] = m
}
return acct, servers
}
func mimoSignInFor(t *testing.T) string {
t.Helper()
st, err := StartSignIn(MiMoID)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(st.URL, "/pass/qr/login?ticket=t1") {
t.Fatalf("sign-in opens %q, want Xiaomi's page for the ticket", st.URL)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
done, err := WaitSignIn(ctx, st.ID)
if err != nil {
t.Fatal(err)
}
if done.State != "done" || done.User != "42" {
t.Fatalf("sign-in ended %+v", done)
}
return done.User
}
func TestMiMoSignInRoutesAndMeters(t *testing.T) {
signIn(t)
acct, servers := mimoStubs(t, "SGP")
sgp := servers["SGP"]
user := mimoSignInFor(t)
if acct.polls.Load() != 2 {
t.Errorf("polled %d times, want 2 (waited past the first answer)", acct.polls.Load())
}
c, ok := mimoLookup(user)
if !ok || c.PassToken != "pass-42" || c.CUserID != "cu-42" || c.Base != sgp.base || c.Region != "SGP" || c.Name != "Mi Fan" || c.Cookies["serviceToken"] != "st-1" {
t.Fatalf("saved %+v", c)
}
var got *Login
for _, l := range Logins(MiMoID) {
if l.User == user {
got = &l
}
}
if got == nil || got.Plan != "MiMo 高阶" || !got.Active {
t.Fatalf("logins %+v", Logins(MiMoID))
}
var p Provider
for _, a := range Accounts() {
if a.ID == MiMoID {
p = a
}
}
if p.Account == nil || p.Chat != sgp.base+"/route" || len(p.Account.models()) != 2 {
t.Fatalf("provider %+v", p)
}
req := httptest.NewRequest(http.MethodPost, p.Chat+"/chat/completions", nil)
req.Header.Set("Authorization", "Bearer magpie")
if err := p.Sign(context.Background(), req, Chat, nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" || !strings.Contains(req.Header.Get("Cookie"), "serviceToken=st-1") ||
req.Header.Get("X-Mimo-Source") != "mimocode-cli-free" || req.Header.Get("X-Client-Version") != mimoAppVersion {
t.Fatalf("signed %v", req.Header)
}
if b := string(p.Prepare([]byte(`{"model":"mimo-auto","stream":true}`))); !strings.Contains(b, `"model":"mimo-pro"`) || !strings.Contains(b, `"stream":true`) {
t.Fatalf("mimo-auto asked as %s", b)
}
if b := string(p.Prepare([]byte(`{"model":"mimo-flash"}`))); b != `{"model":"mimo-flash"}` {
t.Fatalf("mimo-flash asked as %s", b)
}
q := mimoLoginQuota(context.Background(), *got)
if q.Error != "" || q.Plan != "MiMo 高阶" || q.Renew != "auto" || len(q.Windows) != 1 {
t.Fatalf("quota %+v", q)
}
w := q.Windows[0]
if w.Used != 30 || w.Span != 7*24*time.Hour || w.ResetsAt == nil || !w.ResetsAt.Equal(time.Date(2026, 10, 5, 0, 0, 0, 0, mimoZone)) {
t.Fatalf("window %+v", w)
}
if q.Until == nil || !q.Until.Equal(time.Date(2026, 11, 1, 0, 0, 0, 0, mimoZone)) {
t.Fatalf("until %v", q.Until)
}
if sgp.sessions.Load() != 1 {
t.Errorf("signed on %d times, want once", sgp.sessions.Load())
}
}
// A session the server turns away is renewed once through the passToken;
// an old one before it is used; and when Xiaomi no longer takes the
// passToken, the account is marked to be signed in again.
func TestMiMoRenewsAndLapses(t *testing.T) {
signIn(t)
acct, servers := mimoStubs(t, "SGP")
sgp := servers["SGP"]
user := mimoSignInFor(t)
sgp.drop()
if _, _, err := mimoPlan(context.Background(), user); err != nil {
t.Fatalf("plan after the session was dropped: %v", err)
}
if sgp.sessions.Load() != 2 {
t.Fatalf("signed on %d times, want 2", sgp.sessions.Load())
}
if c, _ := mimoLookup(user); c.Cookies["serviceToken"] != "st-2" {
t.Fatalf("renewed session not kept: %v", c.Cookies["serviceToken"])
}
// a day old: renewed before the request is signed
if err := mimoEdit(user, func(c *mimoCreds, _ *savedLogin) { c.Issued = time.Now().Add(-25 * time.Hour) }); err != nil {
t.Fatal(err)
}
p, _ := mimoAccount()
req := httptest.NewRequest(http.MethodPost, p.Chat+"/chat/completions", nil)
if err := p.Sign(context.Background(), req, Chat, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(req.Header.Get("Cookie"), "serviceToken=st-3") {
t.Fatalf("an old session was used: %q", req.Header.Get("Cookie"))
}
acct.refuse.Store(true)
sgp.drop()
l := Logins(MiMoID)[0]
q := mimoLoginQuota(context.Background(), l)
if !strings.Contains(q.Error, "sign in again") {
t.Fatalf("quota with a refused passToken: %+v", q)
}
if l := Logins(MiMoID)[0]; l.Lapsed == "" {
t.Fatalf("not marked lapsed: %+v", l)
}
if _, err := mimoFresh(context.Background(), user, true); !errors.Is(err, ErrMiMoSignIn) {
t.Fatalf("renewing a refused sign-in: %v", err)
}
}
// An account served at another region's server is moved there, as the app
// moves it.
func TestMiMoSignInMovesToItsRegion(t *testing.T) {
signIn(t)
_, servers := mimoStubs(t, "RU", "SGP")
user := mimoSignInFor(t)
c, _ := mimoLookup(user)
if c.Region != "RU" || c.Base != servers["RU"].base || servers["RU"].sessions.Load() != 1 {
t.Fatalf("saved at %s %s, RU signed on %d times", c.Region, c.Base, servers["RU"].sessions.Load())
}
if p, _ := mimoAccount(); p.Chat != servers["RU"].base+"/route" {
t.Fatalf("routed to %s", p.Chat)
}
}