Files

164 lines
5.1 KiB
Go

package qoder
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestAuthorizationURL checks the device-flow page carries PKCE S256 and the
// client id, and returns the verifier+nonce the later poll needs.
func TestAuthorizationURL(t *testing.T) {
f := NewDeviceFlow(nil, nil)
authURL, verifier, nonce, err := f.Authorization()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(authURL, DeviceFlowHost+DeviceSelectAccountsPath) {
t.Fatalf("url %q", authURL)
}
for _, q := range []string{"challenge=", "challenge_method=S256", "client_id=" + ClientID, "nonce=", "redirect_uri="} {
if !strings.Contains(authURL, strings.TrimSuffix(q, "=")) {
t.Fatalf("url missing %q: %s", q, authURL)
}
}
if verifier == "" || nonce == "" {
t.Fatal("no verifier/nonce")
}
u, _ := url.Parse(authURL)
if u.Query().Get("machine_id") != f.MachineID() {
t.Fatal("login machine id differs from flow")
}
second, _, _, _ := f.Authorization()
u, _ = url.Parse(second)
if u.Query().Get("machine_id") != f.MachineID() {
t.Fatal("flow changed its machine id")
}
}
// TestDeviceFlowAndRefresh runs the real poll -> jobToken -> refresh rounds
// against a stub of the OpenAPI host (repointed through Global.OpenAPI), checking
// each round trips the right fields and headers.
func TestDeviceFlowAndRefresh(t *testing.T) {
var pollHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case DeviceTokenPollPath:
pollHits++
if pollHits < 2 { // first try: not authorized yet
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"token": "dt-abc", "refresh_token": "drt", "user_id": "uid-9"})
case JobTokenPath:
if r.Header.Get("Authorization") != "Bearer dt-abc" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"token": "jt-xyz", "refresh_token": "jrt", "expires_in": 3600000})
case JobTokenRefreshPath:
var b struct {
RefreshToken string `json:"refresh_token"`
}
_ = json.NewDecoder(r.Body).Decode(&b)
if b.RefreshToken != "jrt" {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"token": "jt-new", "refresh_token": "jrt2", "expires_in": 7200000})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// Repoint the package host at the stub so the real round-trip functions run
// against it (they build their URLs from Global.OpenAPI + the path constants).
host := Global.OpenAPI
Global.OpenAPI = srv.URL
defer func() { Global.OpenAPI = host }()
client := srv.Client()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
f := NewDeviceFlow(client, nil)
dt, err := f.PollDeviceToken(ctx, "nonce", "verifier", time.Millisecond)
if err != nil {
t.Fatal(err)
}
if dt.Token != "dt-abc" || dt.UserID != "uid-9" {
t.Fatalf("poll: %+v", dt)
}
jt, err := f.JobToken(ctx, dt.Token)
if err != nil {
t.Fatal(err)
}
if jt.Token != "jt-xyz" || jt.Expiry() != time.Hour {
t.Fatalf("jobToken: %+v", jt)
}
updated, err := RefreshJobToken(ctx, client, nil, "jrt")
if err != nil {
t.Fatal(err)
}
if updated.Token != "jt-new" || updated.RefreshToken != "jrt2" || updated.Expiry() != 2*time.Hour {
t.Fatalf("refresh: %+v", updated)
}
}
// TestFetchUserInfo checks the account endpoint is asked with the device token
// and its email and name are decoded.
func TestFetchUserInfo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != UserInfoPath {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Header.Get("Authorization") != "Bearer dt-abc" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"id":"u1","name":"Ethan Xu","email":"e@x.com","source":"sso.google"}`))
}))
defer srv.Close()
host := Global.OpenAPI
Global.OpenAPI = srv.URL
defer func() { Global.OpenAPI = host }()
ui, err := FetchUserInfo(context.Background(), srv.Client(), nil, "dt-abc")
if err != nil {
t.Fatal(err)
}
if ui.Email != "e@x.com" || ui.Name != "Ethan Xu" || ui.ID != "u1" {
t.Fatalf("userinfo: %+v", ui)
}
if _, err := FetchUserInfo(context.Background(), srv.Client(), nil, ""); err == nil {
t.Fatal("empty device token should error")
}
}
// TestParseModels checks enabled, routable chat models are kept with magpie's
// fields, and aggregate "auto" and disabled entries are dropped.
func TestParseModels(t *testing.T) {
body := []byte(`{"chat":[
{"key":"qfmodel","display_name":"Qwen3.8-Flash","enable":true,"is_vl":true,"max_input_tokens":200000},
{"key":"auto","enable":true},
{"key":"off-model","enable":false}]}`)
ms, err := ParseModels(body, ProviderKey)
if err != nil {
t.Fatal(err)
}
if len(ms) != 1 {
t.Fatalf("want 1 routable model, got %d: %+v", len(ms), ms)
}
m := ms[0]
if m.ID != "qfmodel" || m.Name != "Qwen3.8-Flash" || m.Provider != ProviderKey || m.Context != 200000 || !m.Images {
t.Fatalf("model mapping wrong: %+v", m)
}
}