package main import ( "encoding/json" "io" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/stretchr/testify/require" ) func TestLoginWritesTheTokenFile(t *testing.T) { server := newChatGPTFake(t) server.pending = 2 auth := newTestChatGPTAuth(t, server) var out strings.Builder err := auth.Login(t.Context(), &out) require.NoError(t, err) require.Contains(t, out.String(), server.URL+"/codex/device") require.Contains(t, out.String(), "CODE-1234") require.Equal(t, 3, server.polls) form := server.exchange require.Equal(t, "authorization_code", form.Get("grant_type")) require.Equal(t, "auth-code", form.Get("code")) require.Equal(t, "verifier", form.Get("code_verifier")) require.Equal(t, chatgptClientID, form.Get("client_id")) require.Equal(t, server.URL+"/deviceauth/callback", form.Get("redirect_uri")) tokens := chatgptReadTokenFile(t, auth.path) require.Equal(t, "refresh-1", tokens.RefreshToken) require.Equal(t, chatgptTestAccount, tokens.AccountID) require.Equal(t, server.access[0], tokens.AccessToken) require.NotContains(t, out.String(), tokens.AccessToken) require.NotContains(t, out.String(), tokens.RefreshToken) info, err := os.Stat(auth.path) require.NoError(t, err) require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) } func TestLoginFailsWhenTheCodeIsDenied(t *testing.T) { server := newChatGPTFake(t) server.deny = true auth := newTestChatGPTAuth(t, server) err := auth.Login(t.Context(), io.Discard) require.ErrorContains(t, err, "HTTP 400") require.NoFileExists(t, auth.path) } func TestAuthorizeUsesAFreshTokenWithoutRefresh(t *testing.T) { server := newChatGPTFake(t) auth := newTestChatGPTAuth(t, server) chatgptWriteTestTokens(t, auth, server.token(time.Hour), "refresh-0") header := http.Header{} err := auth.Authorize(t.Context(), header) require.NoError(t, err) require.Equal(t, "Bearer "+server.access[0], header.Get("Authorization")) require.Equal(t, chatgptTestAccount, header.Get("ChatGPT-Account-Id")) require.Equal(t, 0, server.refreshes) } func TestAuthorizeRefreshesNearTheExpiry(t *testing.T) { testCases := []struct { name string newRefresh string wantRefresh string }{ { name: "new refresh token kept", newRefresh: "refresh-new", wantRefresh: "refresh-new", }, { name: "old refresh token kept without a new one", newRefresh: "", wantRefresh: "refresh-0", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := newChatGPTFake(t) server.newRefresh = tc.newRefresh auth := newTestChatGPTAuth(t, server) chatgptWriteTestTokens(t, auth, server.token(59*time.Second), "refresh-0") header := http.Header{} err := auth.Authorize(t.Context(), header) require.NoError(t, err) require.Equal(t, 1, server.refreshes) require.Equal(t, "refresh-0", server.refreshed) tokens := chatgptReadTokenFile(t, auth.path) require.Equal(t, server.access[1], tokens.AccessToken) require.Equal(t, tc.wantRefresh, tokens.RefreshToken) require.Equal(t, chatgptTestAccount, tokens.AccountID) require.Equal(t, "Bearer "+server.access[1], header.Get("Authorization")) err = auth.Authorize(t.Context(), header) require.NoError(t, err) require.Equal(t, 1, server.refreshes) }) } } func TestAuthorizeRunsOneRefreshAtATime(t *testing.T) { server := newChatGPTFake(t) server.newRefresh = "refresh-new" auth := newTestChatGPTAuth(t, server) chatgptWriteTestTokens(t, auth, server.token(time.Second), "refresh-0") var group sync.WaitGroup errs := make(chan error, 8) for range 8 { group.Go(func() { errs <- auth.Authorize(t.Context(), http.Header{}) }) } group.Wait() close(errs) for err := range errs { require.NoError(t, err) } require.Equal(t, 1, server.refreshes) } func TestAuthorizeNeedsALogin(t *testing.T) { server := newChatGPTFake(t) auth := newTestChatGPTAuth(t, server) err := auth.Authorize(t.Context(), http.Header{}) require.ErrorIs(t, err, errChatGPTLoginNeeded) server.failRefresh = true chatgptWriteTestTokens(t, auth, server.token(-time.Minute), "refresh-0") before, err := os.ReadFile(auth.path) require.NoError(t, err) err = auth.Authorize(t.Context(), http.Header{}) require.ErrorIs(t, err, errChatGPTLoginNeeded) require.NotContains(t, err.Error(), "refresh-0") after, err := os.ReadFile(auth.path) require.NoError(t, err) require.Equal(t, before, after) } func TestForcedRefreshLetsTheNextCallWork(t *testing.T) { server := newChatGPTFake(t) auth := newTestChatGPTAuth(t, server) chatgptWriteTestTokens(t, auth, server.token(-time.Hour), "refresh-0") responses := newModelFake(t) client := newModelClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) _, err := client.Create(t.Context(), modelTestRequest()) require.NoError(t, err) require.Equal(t, 1, server.refreshes) call := responses.Call(0) require.Equal(t, "Bearer "+server.access[1], call.header.Get("Authorization")) require.Equal(t, chatgptTestAccount, call.header.Get("ChatGPT-Account-Id")) require.Equal(t, server.access[1], chatgptReadTokenFile(t, auth.path).AccessToken) } func TestModelEndpointFollowsTheConfig(t *testing.T) { dir := t.TempDir() config := defaultConfig() t.Setenv("OPENAI_API_KEY", modelTestKey) address, auth, err := modelEndpoint(config, dir) require.NoError(t, err) require.Equal(t, modelChatGPTURL, address) require.IsType(t, &ChatGPTAuth{}, auth) require.Equal(t, filepath.Join(dir, chatgptTokenFileName), auth.(*ChatGPTAuth).path) config.Endpoint = configEndpointAPI address, auth, err = modelEndpoint(config, dir) require.NoError(t, err) require.Equal(t, modelAPIURL, address) require.Equal(t, ModelAPIKey(modelTestKey), auth) t.Setenv("OPENAI_API_KEY", "") _, _, err = modelEndpoint(config, dir) require.ErrorContains(t, err, "OPENAI_API_KEY") } func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile { t.Helper() data, err := os.ReadFile(path) require.NoError(t, err) var tokens ChatGPTTokenFile require.NoError(t, json.Unmarshal(data, &tokens)) return tokens }