mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
Browser pairing authenticates users as RoleOperator, but every provider and OAuth route was wrapped in requireAuth(RoleAdmin). The /setup flow reads GET /v1/providers, so paired browsers got 403 and hung on the setup page. Split provider/OAuth route auth by operation: - providers.go: GET list/get/models/status/embedding/codex-activity now use a readAuth wrapper (method-derived min → Viewer); POST/PUT/DELETE and reconnect/verify stay Admin. Responses already mask API keys and queries stay tenant-scoped, so no secrets are exposed. - oauth.go: GET status/quota use readAuth (they return only connection state and quota, never tokens); start/callback/logout stay Admin (preserving the #450 admin-only-management decision for mutations). Updates the two auth tests to assert the new contract: an Operator can read providers/OAuth status (200) but still cannot mutate them (403).
601 lines
17 KiB
Go
601 lines
17 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/oauth"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// --- mock stores for tests ---
|
|
|
|
type mockProviderStore struct {
|
|
providers map[string]*store.LLMProviderData
|
|
}
|
|
|
|
func newMockProviderStore() *mockProviderStore {
|
|
return &mockProviderStore{providers: make(map[string]*store.LLMProviderData)}
|
|
}
|
|
|
|
func (m *mockProviderStore) CreateProvider(_ context.Context, p *store.LLMProviderData) error {
|
|
if p.ID == uuid.Nil {
|
|
p.ID = uuid.New()
|
|
}
|
|
m.providers[p.Name] = p
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProviderStore) GetProvider(_ context.Context, id uuid.UUID) (*store.LLMProviderData, error) {
|
|
for _, p := range m.providers {
|
|
if p.ID == id {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockProviderStore) GetProviderByName(_ context.Context, name string) (*store.LLMProviderData, error) {
|
|
if p, ok := m.providers[name]; ok {
|
|
return p, nil
|
|
}
|
|
return nil, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockProviderStore) ListProviders(_ context.Context) ([]store.LLMProviderData, error) {
|
|
var out []store.LLMProviderData
|
|
for _, p := range m.providers {
|
|
out = append(out, *p)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mockProviderStore) UpdateProvider(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
for _, p := range m.providers {
|
|
if p.ID == id {
|
|
if v, ok := updates["api_key"]; ok {
|
|
p.APIKey = v.(string)
|
|
}
|
|
if v, ok := updates["api_base"]; ok {
|
|
p.APIBase = v.(string)
|
|
}
|
|
if v, ok := updates["provider_type"]; ok {
|
|
p.ProviderType = v.(string)
|
|
}
|
|
if v, ok := updates["settings"]; ok {
|
|
p.Settings = v.(json.RawMessage)
|
|
}
|
|
if v, ok := updates["enabled"]; ok {
|
|
p.Enabled = v.(bool)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockProviderStore) DeleteProvider(_ context.Context, id uuid.UUID) error {
|
|
for name, p := range m.providers {
|
|
if p.ID == id {
|
|
delete(m.providers, name)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockProviderStore) ListAllProviders(_ context.Context) ([]store.LLMProviderData, error) {
|
|
var out []store.LLMProviderData
|
|
for _, p := range m.providers {
|
|
out = append(out, *p)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type mockSecretsStore struct {
|
|
data map[string]string
|
|
}
|
|
|
|
func newMockSecretsStore() *mockSecretsStore {
|
|
return &mockSecretsStore{data: make(map[string]string)}
|
|
}
|
|
|
|
func (m *mockSecretsStore) Get(_ context.Context, key string) (string, error) {
|
|
if v, ok := m.data[key]; ok {
|
|
return v, nil
|
|
}
|
|
return "", fmt.Errorf("not found: %s", key)
|
|
}
|
|
|
|
func (m *mockSecretsStore) Set(_ context.Context, key, value string) error {
|
|
m.data[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSecretsStore) Delete(_ context.Context, key string) error {
|
|
delete(m.data, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSecretsStore) GetAll(_ context.Context) (map[string]string, error) {
|
|
return m.data, nil
|
|
}
|
|
|
|
// --- helper ---
|
|
|
|
func newTestOAuthHandler(t *testing.T, token string) *OAuthHandler {
|
|
t.Helper()
|
|
old := pkgGatewayToken
|
|
pkgGatewayToken = token
|
|
t.Cleanup(func() { pkgGatewayToken = old })
|
|
return NewOAuthHandler(newMockProviderStore(), newMockSecretsStore(), nil, nil)
|
|
}
|
|
|
|
func newTestOAuthHandlerWithStores(t *testing.T, token string) (*OAuthHandler, *mockProviderStore, *mockSecretsStore) {
|
|
t.Helper()
|
|
old := pkgGatewayToken
|
|
pkgGatewayToken = token
|
|
t.Cleanup(func() { pkgGatewayToken = old })
|
|
provStore := newMockProviderStore()
|
|
secretStore := newMockSecretsStore()
|
|
return NewOAuthHandler(provStore, secretStore, nil, nil), provStore, secretStore
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestOAuthHandlerStatusNoToken(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("GET", "/v1/auth/openai/status", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]any
|
|
json.NewDecoder(w.Body).Decode(&result)
|
|
|
|
if result["authenticated"] != false {
|
|
t.Errorf("authenticated = %v, want false", result["authenticated"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerAuth(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "secret-token")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// Without token - should be unauthorized
|
|
req := httptest.NewRequest("GET", "/v1/auth/openai/status", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status code without token = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
|
|
// With correct token - should work
|
|
req2 := httptest.NewRequest("GET", "/v1/auth/openai/status", nil)
|
|
req2.Header.Set("Authorization", "Bearer secret-token")
|
|
w2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("status code with token = %d, want %d", w2.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerSaveAndRegisterAppliesCodexPoolDefaults(t *testing.T) {
|
|
provStore := newMockProviderStore()
|
|
secretStore := newMockSecretsStore()
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewOAuthHandler(provStore, secretStore, providerReg, nil)
|
|
|
|
tenantID := uuid.New()
|
|
ctx := store.WithTenantID(context.Background(), tenantID)
|
|
if err := provStore.CreateProvider(ctx, &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: tenantID,
|
|
Name: oauth.DefaultProviderName,
|
|
ProviderType: store.ProviderChatGPTOAuth,
|
|
APIBase: oauth.DefaultProviderAPIBase,
|
|
Enabled: true,
|
|
Settings: json.RawMessage(`{
|
|
"codex_pool": {
|
|
"strategy": "priority_order",
|
|
"extra_provider_names": ["openai-codex-team"]
|
|
}
|
|
}`),
|
|
}); err != nil {
|
|
t.Fatalf("CreateProvider: %v", err)
|
|
}
|
|
|
|
if _, err := handler.saveAndRegister(ctx, oauth.DefaultProviderName, "", "", &oauth.OpenAITokenResponse{
|
|
AccessToken: "access-token",
|
|
RefreshToken: "refresh-token",
|
|
ExpiresIn: 3600,
|
|
}); err != nil {
|
|
t.Fatalf("saveAndRegister: %v", err)
|
|
}
|
|
|
|
runtimeProvider, err := providerReg.GetForTenant(tenantID, oauth.DefaultProviderName)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant: %v", err)
|
|
}
|
|
codex, ok := runtimeProvider.(*providers.CodexProvider)
|
|
if !ok {
|
|
t.Fatalf("runtime provider = %T, want *providers.CodexProvider", runtimeProvider)
|
|
}
|
|
defaults := codex.RoutingDefaults()
|
|
if defaults == nil {
|
|
t.Fatal("RoutingDefaults() = nil, want defaults")
|
|
}
|
|
if defaults.Strategy != store.ChatGPTOAuthStrategyPriority {
|
|
t.Fatalf("Strategy = %q, want %q", defaults.Strategy, store.ChatGPTOAuthStrategyPriority)
|
|
}
|
|
if len(defaults.ExtraProviderNames) != 1 || defaults.ExtraProviderNames[0] != "openai-codex-team" {
|
|
t.Fatalf("ExtraProviderNames = %#v, want [\"openai-codex-team\"]", defaults.ExtraProviderNames)
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerLogoutNoProvider(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/auth/openai/logout", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]string
|
|
json.NewDecoder(w.Body).Decode(&result)
|
|
|
|
if result["status"] != "logged out" {
|
|
t.Errorf("status = %q, want 'logged out'", result["status"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerRouteRegistration(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{"GET", "/v1/auth/openai/status"},
|
|
{"POST", "/v1/auth/openai/logout"},
|
|
{"POST", "/v1/auth/openai/start"},
|
|
}
|
|
|
|
for _, r := range routes {
|
|
req := httptest.NewRequest(r.method, r.path, nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("%s %s returned 404", r.method, r.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerStartReturnsAuthURL(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/auth/openai/start", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
// Skip if port 1455 is already in use (environment issue, not code bug)
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Skip("port 1455 unavailable, skipping")
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
var result map[string]any
|
|
json.NewDecoder(w.Body).Decode(&result)
|
|
|
|
_, hasURL := result["auth_url"]
|
|
_, hasStatus := result["status"]
|
|
|
|
if !hasURL && !hasStatus {
|
|
t.Fatal("response has neither auth_url nor status")
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerProviderStatusRoute(t *testing.T) {
|
|
h, provStore, _ := newTestOAuthHandlerWithStores(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{
|
|
Name: "codex-work",
|
|
DisplayName: "Codex Work",
|
|
ProviderType: store.ProviderChatGPTOAuth,
|
|
APIBase: "https://chatgpt.com/backend-api",
|
|
APIKey: "token-work",
|
|
Enabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("CreateProvider: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/v1/auth/chatgpt/codex-work/status", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if result["authenticated"] != true {
|
|
t.Fatalf("authenticated = %v, want true", result["authenticated"])
|
|
}
|
|
if result["provider_name"] != "codex-work" {
|
|
t.Fatalf("provider_name = %v, want codex-work", result["provider_name"])
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerProviderStatusRouteRejectsTypeConflict(t *testing.T) {
|
|
h, provStore, _ := newTestOAuthHandlerWithStores(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{
|
|
Name: "codex-work",
|
|
DisplayName: "Codex Work",
|
|
ProviderType: store.ProviderOpenRouter,
|
|
APIBase: "https://openrouter.ai/api/v1",
|
|
APIKey: "sk-live",
|
|
Enabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("CreateProvider: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/v1/auth/chatgpt/codex-work/status", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusConflict)
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerProviderLogoutRoute(t *testing.T) {
|
|
h, provStore, secretStore := newTestOAuthHandlerWithStores(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{
|
|
Name: "codex-work",
|
|
DisplayName: "Codex Work",
|
|
ProviderType: store.ProviderChatGPTOAuth,
|
|
APIBase: "https://chatgpt.com/backend-api",
|
|
APIKey: "token-work",
|
|
Enabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("CreateProvider: %v", err)
|
|
}
|
|
if err := secretStore.Set(context.Background(), oauth.RefreshTokenSecretKey("codex-work"), "refresh-work"); err != nil {
|
|
t.Fatalf("Set refresh token: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/logout", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
if _, err := provStore.GetProviderByName(context.Background(), "codex-work"); err == nil {
|
|
t.Fatal("provider still exists after logout")
|
|
}
|
|
if _, err := secretStore.Get(context.Background(), oauth.RefreshTokenSecretKey("codex-work")); err == nil {
|
|
t.Fatal("refresh token still exists after logout")
|
|
}
|
|
}
|
|
|
|
// operatorAPIKeyRequest builds a request authenticated as a browser-pairing-style
|
|
// Operator (operator.write scope → RoleOperator).
|
|
func operatorAPIKeyRequest(t *testing.T, method, path string) *http.Request {
|
|
t.Helper()
|
|
token := "operator-key-" + method + path
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(token): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.write"},
|
|
TenantID: store.MasterTenantID,
|
|
},
|
|
})
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return req
|
|
}
|
|
|
|
// TestProvidersHandler_OperatorReadsButCannotMutate is a regression for issue #1075:
|
|
// browser-paired Operators must read providers so /setup can proceed, while
|
|
// create/update/delete stay Admin-only.
|
|
func TestProvidersHandler_OperatorReadsButCannotMutate(t *testing.T) {
|
|
h := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
wGet := httptest.NewRecorder()
|
|
mux.ServeHTTP(wGet, operatorAPIKeyRequest(t, "GET", "/v1/providers"))
|
|
if wGet.Code != http.StatusOK {
|
|
t.Fatalf("operator GET /v1/providers = %d, want 200", wGet.Code)
|
|
}
|
|
|
|
wPost := httptest.NewRecorder()
|
|
mux.ServeHTTP(wPost, operatorAPIKeyRequest(t, "POST", "/v1/providers"))
|
|
if wPost.Code != http.StatusForbidden {
|
|
t.Fatalf("operator POST /v1/providers = %d, want 403", wPost.Code)
|
|
}
|
|
}
|
|
|
|
// TestOAuthHandler_OperatorReadsStatusButCannotManage: Operator can read OAuth
|
|
// status for setup; managing OAuth (start) remains Admin-only (issue #1075, #450).
|
|
func TestOAuthHandler_OperatorReadsStatusButCannotManage(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
wGet := httptest.NewRecorder()
|
|
mux.ServeHTTP(wGet, operatorAPIKeyRequest(t, "GET", "/v1/auth/openai/status"))
|
|
if wGet.Code != http.StatusOK {
|
|
t.Fatalf("operator GET oauth status = %d, want 200", wGet.Code)
|
|
}
|
|
|
|
wPost := httptest.NewRecorder()
|
|
mux.ServeHTTP(wPost, operatorAPIKeyRequest(t, "POST", "/v1/auth/openai/start"))
|
|
if wPost.Code != http.StatusForbidden {
|
|
t.Fatalf("operator POST oauth start = %d, want 403", wPost.Code)
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerStartReusesPendingFlowForSameRequester(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil)
|
|
w1 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w1, req1)
|
|
|
|
if w1.Code == http.StatusInternalServerError {
|
|
t.Skip("port 1455 unavailable, skipping")
|
|
}
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK)
|
|
}
|
|
key := oauthFlowKey(req1.Context(), "codex-work")
|
|
firstPending := h.pending[key]
|
|
if firstPending == nil {
|
|
t.Fatal("pending flow = nil after first start")
|
|
}
|
|
defer func() {
|
|
firstPending.cancel()
|
|
firstPending.login.Shutdown()
|
|
}()
|
|
|
|
req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil)
|
|
w2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("second start status = %d, want %d", w2.Code, http.StatusOK)
|
|
}
|
|
if h.pending[key] == nil {
|
|
t.Fatal("pending flow = nil after second start")
|
|
}
|
|
if h.pending[key] != firstPending {
|
|
t.Fatal("pending flow should be reused for the same requester")
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerStartRejectsConcurrentFlowForDifferentRequester(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil)
|
|
req1.Header.Set("X-GoClaw-User-Id", "alice")
|
|
w1 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w1, req1)
|
|
|
|
if w1.Code == http.StatusInternalServerError {
|
|
t.Skip("port 1455 unavailable, skipping")
|
|
}
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK)
|
|
}
|
|
key := oauthFlowKey(req1.Context(), "codex-work")
|
|
firstPending := h.pending[key]
|
|
if firstPending == nil {
|
|
t.Fatal("pending flow = nil after first start")
|
|
}
|
|
defer func() {
|
|
firstPending.cancel()
|
|
firstPending.login.Shutdown()
|
|
}()
|
|
|
|
req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-personal/start", nil)
|
|
req2.Header.Set("X-GoClaw-User-Id", "bob")
|
|
w2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusConflict {
|
|
t.Fatalf("second start status = %d, want %d", w2.Code, http.StatusConflict)
|
|
}
|
|
if h.pending[key] != firstPending {
|
|
t.Fatal("first pending flow should remain active")
|
|
}
|
|
}
|
|
|
|
func TestOAuthHandlerManualCallbackRequiresSameRequester(t *testing.T) {
|
|
h := newTestOAuthHandler(t, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil)
|
|
req1.Header.Set("X-GoClaw-User-Id", "alice")
|
|
w1 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w1, req1)
|
|
|
|
if w1.Code == http.StatusInternalServerError {
|
|
t.Skip("port 1455 unavailable, skipping")
|
|
}
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK)
|
|
}
|
|
key := oauthFlowKey(req1.Context(), "codex-work")
|
|
firstPending := h.pending[key]
|
|
if firstPending == nil {
|
|
t.Fatal("pending flow = nil after first start")
|
|
}
|
|
defer func() {
|
|
firstPending.cancel()
|
|
firstPending.login.Shutdown()
|
|
}()
|
|
|
|
req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/callback", nil)
|
|
req2.Header.Set("X-GoClaw-User-Id", "bob")
|
|
req2 = httptest.NewRequest(
|
|
"POST",
|
|
"/v1/auth/chatgpt/codex-work/callback",
|
|
strings.NewReader(`{"redirect_url":"http://localhost:1455/auth/callback?code=test&state=wrong"}`),
|
|
)
|
|
req2.Header.Set("X-GoClaw-User-Id", "bob")
|
|
w2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusBadRequest {
|
|
t.Fatalf("callback status = %d, want %d", w2.Code, http.StatusBadRequest)
|
|
}
|
|
}
|