Files
goclaw/internal/oauth/openai_quota_test.go
Kai (Tam Nhu) Tran 30708ae79d feat(providers): support Codex OAuth pools with inherited routing defaults
* feat(auth): support named chatgpt oauth providers

- add provider-scoped ChatGPT OAuth routes and CLI support

- persist refresh tokens per provider and reject provider-type collisions

- wire provider OAuth setup flows in the dashboard and setup UI

Refs #448

* feat(agent): add chatgpt oauth account routing

- add agent other_config routing for manual and round-robin selection

- reuse routed provider resolution across resolver and pending loaders

- add router, parser, and agent advanced dialog coverage for multi-account use

Refs #448

* docs(api): describe chatgpt oauth routing

- document named-provider ChatGPT OAuth auth routes

- describe agent-side account routing and round-robin behavior

- update OpenAPI agent config schema and provider type enum

Refs #448

* fix(store): add missing agent key context helpers

* feat(ui): clarify chatgpt oauth account setup and routing

* docs(providers): align chatgpt oauth alias examples

* feat(agent): add codex pool activity dashboard

* fix(providers): harden codex oauth alias setup

* feat(codex-pool): improve routing dashboard UX

- redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence

- add clearer selection, attention, and recent-proof states for pool members

- make the lower panels fill the remaining desktop viewport while staying responsive

* fix(store): resolve context helper merge duplication

* feat(oauth): add codex pool quota and observation APIs

- add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers

- teach codex routing to surface pool activity, observation metadata, and quota-aware readiness

- extend tests and HTTP docs/OpenAPI for the new pool monitoring flows

* feat(web): add codex pool quota monitor and controls

- add provider quota fetching, readiness badges, and live routing evidence on the account pool page

- redesign pool setup and activity panels for multi-account management with localized copy updates

- keep the live monitor internally scrollable and compact the account cards for better viewport fit

* fix(web): clarify pool routing labels

- rename the recent request badge from Direct to Selected

- restore compact quota bars in the live pool cards

* feat(codex-pool): add runtime health dashboard

- derive per-provider success and failure health from routed Codex traces

- surface routing, quota, and recent request evidence in the pool UI

- align provider alias guidance and owner access with the dashboard role model

* docs(auth): document tenant scoping and key roles

* fix(auth): harden tenant and codex pool access control

* fix(providers): align codex pool runtime defaults

* feat(ui): tighten codex pool responsive layout

* feat(chatgpt-oauth): refine codex pool management UX

* feat(chatgpt-oauth): surface quota bars on provider pages

- add compact quota bars to Codex provider rows and provider detail

- fetch quota only for ready visible provider rows and ready detail aliases

- fix managed-member detail visibility and tighten provider locale copy
2026-03-27 09:35:57 +07:00

306 lines
10 KiB
Go

package oauth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestRequestOpenAIQuotaUsesExpectedHeaders(t *testing.T) {
oldClient := quotaHTTPClient
t.Cleanup(func() { quotaHTTPClient = oldClient })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/backend-api/wham/usage" {
t.Fatalf("path = %q, want /backend-api/wham/usage", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Fatalf("Authorization = %q", got)
}
if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_123" {
t.Fatalf("ChatGPT-Account-Id = %q", got)
}
if got := r.Header.Get("User-Agent"); got != openAIQuotaUserAgent {
t.Fatalf("User-Agent = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":20,"reset_after_seconds":60},"secondary_window":{"used_percent":40,"reset_after_seconds":3600}}}`))
}))
defer server.Close()
quotaHTTPClient = server.Client()
payload, err := requestOpenAIQuota(t.Context(), server.URL+"/backend-api/", "access-token", "acct_123")
if err != nil {
t.Fatalf("requestOpenAIQuota() error = %v", err)
}
if payload.PlanType != "team" {
t.Fatalf("PlanType = %q, want team", payload.PlanType)
}
}
func TestBuildOpenAIQuotaCoreUsageFallsBackToResetWindows(t *testing.T) {
short := 120
long := 7200
summary := buildOpenAIQuotaCoreUsage([]OpenAIQuotaWindow{
{Label: "Window A", RemainingPercent: 81, ResetAfterSeconds: &short},
{Label: "Window B", RemainingPercent: 52, ResetAfterSeconds: &long},
})
if summary == nil || summary.FiveHour == nil || summary.Weekly == nil {
t.Fatal("expected both quota summary windows")
}
if summary.FiveHour.Label != "Window A" {
t.Fatalf("FiveHour.Label = %q, want Window A", summary.FiveHour.Label)
}
if summary.Weekly.Label != "Window B" {
t.Fatalf("Weekly.Label = %q, want Window B", summary.Weekly.Label)
}
}
func TestBuildOpenAIQuotaCoreUsageDoesNotFabricateWeeklyFromSingleWindow(t *testing.T) {
short := 120
summary := buildOpenAIQuotaCoreUsage([]OpenAIQuotaWindow{
{Label: "Primary", RemainingPercent: 81, ResetAfterSeconds: &short},
})
if summary == nil || summary.FiveHour == nil {
t.Fatal("expected five-hour quota summary")
}
if summary.Weekly != nil {
t.Fatalf("Weekly = %#v, want nil", summary.Weekly)
}
}
func TestBuildOpenAIQuotaHTTPFailurePaymentRequiredDoesNotRequireReauth(t *testing.T) {
result := buildOpenAIQuotaHTTPFailure(OpenAIQuotaResult{}, http.StatusPaymentRequired, "billing blocked")
if result.ErrorCode != "payment_required" {
t.Fatalf("ErrorCode = %q, want payment_required", result.ErrorCode)
}
if result.NeedsReauth {
t.Fatal("NeedsReauth = true, want false")
}
if result.Retryable {
t.Fatal("Retryable = true, want false")
}
}
func TestOpenAIQuotaRouteEligibilityClassifiesSignals(t *testing.T) {
result := OpenAIQuotaResult{
Success: true,
CoreUsage: &OpenAIQuotaCoreUsageSummary{
FiveHour: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 72},
Weekly: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 58},
},
}
eligibility := OpenAIQuotaRouteEligibility(result)
if eligibility.Class != providers.RouteEligibilityHealthy {
t.Fatalf("eligibility.Class = %q, want %q", eligibility.Class, providers.RouteEligibilityHealthy)
}
}
func TestOpenAIQuotaRouteEligibilityBlocksBillingAndExhausted(t *testing.T) {
billing := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{
Success: false,
ErrorCode: "payment_required",
})
if billing.Class != providers.RouteEligibilityBlocked || billing.Reason != "billing" {
t.Fatalf("billing eligibility = %#v", billing)
}
exhausted := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{
Success: true,
CoreUsage: &OpenAIQuotaCoreUsageSummary{
FiveHour: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 0},
},
})
if exhausted.Class != providers.RouteEligibilityBlocked || exhausted.Reason != "exhausted" {
t.Fatalf("exhausted eligibility = %#v", exhausted)
}
}
func TestOpenAIQuotaRouteEligibilityKeepsRetryableFailuresAsUnknown(t *testing.T) {
eligibility := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{
Success: false,
Retryable: true,
ErrorCode: "rate_limited",
})
if eligibility.Class != providers.RouteEligibilityUnknown || eligibility.Reason != "retry_later" {
t.Fatalf("eligibility = %#v", eligibility)
}
}
func TestFetchOpenAIQuotaBackfillsMetadataFromStoredJWT(t *testing.T) {
oldClient := quotaHTTPClient
oldRefresh := refreshOpenAITokenFunc
t.Cleanup(func() { quotaHTTPClient = oldClient })
t.Cleanup(func() { refreshOpenAITokenFunc = oldRefresh })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got == "" {
t.Fatal("Authorization header is empty")
}
if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_jwt" {
t.Fatalf("ChatGPT-Account-Id = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":20,"reset_after_seconds":60},"secondary_window":{"used_percent":40,"reset_after_seconds":3600}}}`))
}))
defer server.Close()
quotaHTTPClient = server.Client()
provStore := newMockProviderStore()
secretStore := newMockSecretsStore()
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: DefaultProviderName,
ProviderType: store.ProviderChatGPTOAuth,
APIBase: server.URL + "/backend-api",
APIKey: "jwt-access-token",
Enabled: true,
Settings: marshalOAuthSettings(OAuthSettings{
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}),
}
provStore.providers[provider.Name] = provider
refreshOpenAITokenFunc = func(string) (*OpenAITokenResponse, error) {
t.Fatal("refreshOpenAITokenFunc should not be called for JWT metadata backfill")
return nil, nil
}
provider.APIKey = testJWT(t, map[string]any{
"https://api.openai.com/auth.chatgpt_account_id": "acct_jwt",
"https://api.openai.com/auth.chatgpt_plan_type": "team",
})
result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name))
if !result.Success {
t.Fatalf("Success = false, want true: %#v", result)
}
settings := parseOAuthSettings(provider.Settings)
if settings.AccountID != "acct_jwt" {
t.Fatalf("AccountID = %q, want acct_jwt", settings.AccountID)
}
if settings.PlanType != "team" {
t.Fatalf("PlanType = %q, want team", settings.PlanType)
}
}
func TestFetchOpenAIQuotaBackfillsMetadataFromRefresh(t *testing.T) {
oldClient := quotaHTTPClient
oldRefresh := refreshOpenAITokenFunc
t.Cleanup(func() {
quotaHTTPClient = oldClient
refreshOpenAITokenFunc = oldRefresh
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-access-token" {
t.Fatalf("Authorization = %q", got)
}
if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_refresh" {
t.Fatalf("ChatGPT-Account-Id = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":12,"reset_after_seconds":60},"secondary_window":{"used_percent":31,"reset_after_seconds":3600}}}`))
}))
defer server.Close()
quotaHTTPClient = server.Client()
refreshOpenAITokenFunc = func(refreshToken string) (*OpenAITokenResponse, error) {
if refreshToken != "refresh-token" {
t.Fatalf("refresh token = %q, want refresh-token", refreshToken)
}
return &OpenAITokenResponse{
AccessToken: "refreshed-access-token",
RefreshToken: "refresh-token-2",
ExpiresIn: 3600,
IDToken: testJWT(t, map[string]any{
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "acct_refresh",
"chatgpt_plan_type": "team",
},
}),
}, nil
}
provStore := newMockProviderStore()
secretStore := newMockSecretsStore()
secretStore.data[RefreshTokenSecretKey(DefaultProviderName)] = "refresh-token"
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: DefaultProviderName,
ProviderType: store.ProviderChatGPTOAuth,
APIBase: server.URL + "/backend-api",
APIKey: "opaque-access-token",
Enabled: true,
Settings: marshalOAuthSettings(OAuthSettings{
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}),
}
provStore.providers[provider.Name] = provider
result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name))
if !result.Success {
t.Fatalf("Success = false, want true: %#v", result)
}
settings := parseOAuthSettings(provider.Settings)
if settings.AccountID != "acct_refresh" {
t.Fatalf("AccountID = %q, want acct_refresh", settings.AccountID)
}
if settings.PlanType != "team" {
t.Fatalf("PlanType = %q, want team", settings.PlanType)
}
if provider.APIKey != "refreshed-access-token" {
t.Fatalf("APIKey = %q, want refreshed-access-token", provider.APIKey)
}
}
func TestFetchOpenAIQuotaStillFailsWhenMetadataCannotBeRecovered(t *testing.T) {
oldRefresh := refreshOpenAITokenFunc
t.Cleanup(func() { refreshOpenAITokenFunc = oldRefresh })
refreshOpenAITokenFunc = func(string) (*OpenAITokenResponse, error) {
return &OpenAITokenResponse{
AccessToken: "still-opaque",
ExpiresIn: 3600,
}, nil
}
provStore := newMockProviderStore()
secretStore := newMockSecretsStore()
secretStore.data[RefreshTokenSecretKey(DefaultProviderName)] = "refresh-token"
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: DefaultProviderName,
ProviderType: store.ProviderChatGPTOAuth,
APIBase: "https://example.com/backend-api",
APIKey: "opaque-access-token",
Enabled: true,
Settings: marshalOAuthSettings(OAuthSettings{
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}),
}
provStore.providers[provider.Name] = provider
result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name))
if result.Success {
t.Fatalf("Success = true, want false: %#v", result)
}
if result.ErrorCode != "missing_account_id" {
t.Fatalf("ErrorCode = %q, want missing_account_id", result.ErrorCode)
}
}