]> git.giacomolli.uk - onechat/blob - chatgpt_fake_helper_test.go
Describe child agent directories and limits
[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
71 err := json.NewDecoder(r.Body).Decode(&body)
72 if err != nil || body["client_id"] != chatgptClientID {
73 http.Error(w, "bad client", http.StatusBadRequest)
74
75 return
76 }
77
78 chatgptWriteJSON(w, map[string]any{
79 "device_auth_id": "device-1",
80 "user_code": "CODE-1234",
81 "interval": "0",
82 })
83 }
84
85 func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
86 f.mutex.Lock()
87 defer f.mutex.Unlock()
88
89 var body map[string]string
90
91 err := json.NewDecoder(r.Body).Decode(&body)
92 if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
93 http.Error(w, "bad device", http.StatusBadRequest)
94
95 return
96 }
97
98 f.polls++
99 if f.deny {
100 http.Error(w, "denied", http.StatusBadRequest)
101
102 return
103 }
104
105 if f.polls <= f.pending {
106 http.Error(w, "pending", http.StatusForbidden)
107
108 return
109 }
110
111 chatgptWriteJSON(w, map[string]any{
112 "authorization_code": "auth-code",
113 "code_challenge": "challenge",
114 "code_verifier": "verifier",
115 })
116 }
117
118 func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
119 f.mutex.Lock()
120 defer f.mutex.Unlock()
121
122 if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
123 err := r.ParseForm()
124 if err != nil {
125 http.Error(w, err.Error(), http.StatusBadRequest)
126
127 return
128 }
129
130 f.exchange = r.PostForm
131 chatgptWriteJSON(w, map[string]any{
132 "access_token": f.newToken(time.Hour),
133 "refresh_token": "refresh-1",
134 "id_token": f.newToken(time.Hour),
135 })
136
137 return
138 }
139
140 var body map[string]string
141
142 err := json.NewDecoder(r.Body).Decode(&body)
143 if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
144 http.Error(w, "bad refresh", http.StatusBadRequest)
145
146 return
147 }
148
149 f.refreshes++
150 f.refreshed = body["refresh_token"]
151 if f.failRefresh {
152 http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
153
154 return
155 }
156
157 answer := map[string]any{
158 "access_token": f.newToken(time.Hour),
159 "id_token": f.newToken(time.Hour),
160 }
161
162 if f.newRefresh != "" {
163 answer["refresh_token"] = f.newRefresh
164 }
165
166 chatgptWriteJSON(w, answer)
167 }
168
169 func newChatGPTFake(t *testing.T) *ChatGPTFake {
170 t.Helper()
171
172 f := &ChatGPTFake{}
173 mux := http.NewServeMux()
174 mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
175 mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
176 mux.HandleFunc("POST /oauth/token", f.oauthToken)
177
178 f.Server = httptest.NewServer(mux)
179 t.Cleanup(f.Close)
180
181 return f
182 }
183
184 func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
185 t.Helper()
186
187 return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
188 }
189
190 func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
191 t.Helper()
192
193 err := auth.write(ChatGPTTokenFile{
194 AccessToken: access,
195 RefreshToken: refresh,
196 IDToken: access,
197 AccountID: chatgptTestAccount,
198 })
199 require.NoError(t, err)
200 }
201
202 func chatgptWriteJSON(w http.ResponseWriter, value any) {
203 w.Header().Set("Content-Type", "application/json")
204
205 err := json.NewEncoder(w).Encode(value)
206 if err != nil {
207 panic(fmt.Sprint("encode answer: ", err))
208 }
209 }