]> git.giacomolli.uk - onechat/blob - chatgpt_fake_helper_test.go
Import WhatsApp history into a child
[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 }