]> git.giacomolli.uk - onechat/blob - chatgpt_fake_helper_test.go
Give each subagent its own prompt cache key
[onechat] / chatgpt_fake_helper_test.go
1 package main
2
3 import (
4         "encoding/base64"
5         "encoding/json"
6         "fmt"
7         "net/http"
8         "net/http/httptest"
9         "net/url"
10         "path/filepath"
11         "sync"
12         "testing"
13         "time"
14
15         "github.com/stretchr/testify/require"
16 )
17
18 // chatgptTestAccount is the fake ChatGPT account ID in the fake tokens.
19 const chatgptTestAccount = "account-test"
20
21 // ChatGPTFake is an httptest login service. The device token endpoint answers
22 // 403 pending times before it grants the code. Each token answer has a new
23 // access token, kept in access.
24 type ChatGPTFake struct {
25         *httptest.Server
26         mutex       sync.Mutex
27         pending     int
28         deny        bool
29         polls       int
30         exchange    url.Values
31         refreshes   int
32         refreshed   string
33         newRefresh  string
34         failRefresh bool
35         access      []string
36 }
37
38 // token returns a new access token that expires after ttl, for a token file
39 // that a test writes.
40 func (f *ChatGPTFake) token(ttl time.Duration) string {
41         f.mutex.Lock()
42         defer f.mutex.Unlock()
43
44         return f.newToken(ttl)
45 }
46
47 // newToken returns a new fake JWT with the expiry and the account claim, and
48 // keeps it in access. The caller holds the mutex.
49 func (f *ChatGPTFake) newToken(ttl time.Duration) string {
50         claims, err := json.Marshal(map[string]any{
51                 "exp": time.Now().Add(ttl).Unix(),
52                 chatgptAuthClaim: map[string]any{
53                         "chatgpt_account_id": chatgptTestAccount,
54                 },
55                 "serial": len(f.access),
56         })
57         if err != nil {
58                 panic(err)
59         }
60
61         encode := base64.RawURLEncoding.EncodeToString
62         token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
63         f.access = append(f.access, token)
64
65         return token
66 }
67
68 func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) {
69         var body map[string]string
70         err := json.NewDecoder(r.Body).Decode(&body)
71         if err != nil || body["client_id"] != chatgptClientID {
72                 http.Error(w, "bad client", http.StatusBadRequest)
73                 return
74         }
75
76         chatgptWriteJSON(w, map[string]any{
77                 "device_auth_id": "device-1",
78                 "user_code":      "CODE-1234",
79                 "interval":       "0",
80         })
81 }
82
83 func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
84         f.mutex.Lock()
85         defer f.mutex.Unlock()
86
87         var body map[string]string
88         err := json.NewDecoder(r.Body).Decode(&body)
89         if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
90                 http.Error(w, "bad device", http.StatusBadRequest)
91                 return
92         }
93
94         f.polls++
95         if f.deny {
96                 http.Error(w, "denied", http.StatusBadRequest)
97                 return
98         }
99
100         if f.polls <= f.pending {
101                 http.Error(w, "pending", http.StatusForbidden)
102                 return
103         }
104
105         chatgptWriteJSON(w, map[string]any{
106                 "authorization_code": "auth-code",
107                 "code_challenge":     "challenge",
108                 "code_verifier":      "verifier",
109         })
110 }
111
112 func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
113         f.mutex.Lock()
114         defer f.mutex.Unlock()
115
116         if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
117                 err := r.ParseForm()
118                 if err != nil {
119                         http.Error(w, err.Error(), http.StatusBadRequest)
120                         return
121                 }
122
123                 f.exchange = r.PostForm
124                 chatgptWriteJSON(w, map[string]any{
125                         "access_token":  f.newToken(time.Hour),
126                         "refresh_token": "refresh-1",
127                         "id_token":      f.newToken(time.Hour),
128                 })
129
130                 return
131         }
132
133         var body map[string]string
134         err := json.NewDecoder(r.Body).Decode(&body)
135         if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
136                 http.Error(w, "bad refresh", http.StatusBadRequest)
137                 return
138         }
139
140         f.refreshes++
141         f.refreshed = body["refresh_token"]
142         if f.failRefresh {
143                 http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
144                 return
145         }
146
147         answer := map[string]any{
148                 "access_token": f.newToken(time.Hour),
149                 "id_token":     f.newToken(time.Hour),
150         }
151
152         if f.newRefresh != "" {
153                 answer["refresh_token"] = f.newRefresh
154         }
155
156         chatgptWriteJSON(w, answer)
157 }
158
159 func newChatGPTFake(t *testing.T) *ChatGPTFake {
160         t.Helper()
161
162         f := &ChatGPTFake{}
163         mux := http.NewServeMux()
164         mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
165         mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
166         mux.HandleFunc("POST /oauth/token", f.oauthToken)
167         f.Server = httptest.NewServer(mux)
168         t.Cleanup(f.Close)
169
170         return f
171 }
172
173 func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
174         t.Helper()
175
176         return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
177 }
178
179 func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
180         t.Helper()
181
182         err := auth.write(ChatGPTTokenFile{
183                 AccessToken:  access,
184                 RefreshToken: refresh,
185                 IDToken:      access,
186                 AccountID:    chatgptTestAccount,
187         })
188         require.NoError(t, err)
189 }
190
191 func chatgptWriteJSON(w http.ResponseWriter, value any) {
192         w.Header().Set("Content-Type", "application/json")
193         err := json.NewEncoder(w).Encode(value)
194         if err != nil {
195                 panic(fmt.Sprint("encode answer: ", err))
196         }
197 }