164 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|