Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
331 lines
13 KiB
Go
331 lines
13 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/require"
|
||
"github.com/tidwall/gjson"
|
||
)
|
||
|
||
// compactProbeSSESuccessBody 是原生 v2 压缩成功的最小 SSE 形态:
|
||
// output_item.done 携带 compaction item + response.completed。
|
||
const compactProbeSSESuccessBody = "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"id\":\"cmp_probe\",\"encrypted_content\":\"blob\"}}\n\n" +
|
||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_probe\",\"output\":[]}}\n\n"
|
||
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersistsSupport(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 1,
|
||
Name: "openai-oauth",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"access_token": "oauth-token",
|
||
"chatgpt_account_id": "chatgpt-acc",
|
||
"chatgpt_account_is_fedramp": true,
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-probe"}},
|
||
Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
|
||
}}
|
||
svc := &AccountTestService{
|
||
accountRepo: repo,
|
||
httpUpstream: upstream,
|
||
}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", bytes.NewReader(nil))
|
||
|
||
err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
|
||
require.NoError(t, err)
|
||
|
||
// 原生 v2:探测普通 /responses 线,不再打已下线的 /responses/compact。
|
||
require.Equal(t, chatgptCodexAPIURL, upstream.lastReq.URL.String())
|
||
require.Equal(t, "chatgpt.com", upstream.lastReq.Host)
|
||
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
|
||
require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2")
|
||
require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id"))
|
||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
|
||
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
|
||
require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||
require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
|
||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
|
||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||
require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||
inputItems := gjson.GetBytes(upstream.lastBody, "input").Array()
|
||
require.NotEmpty(t, inputItems)
|
||
require.Equal(t, "compaction_trigger", inputItems[len(inputItems)-1].Get("type").String())
|
||
|
||
updates := <-updateCalls
|
||
require.Equal(t, true, updates["openai_compact_supported"])
|
||
require.Equal(t, http.StatusOK, updates["openai_compact_last_status"])
|
||
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
|
||
}
|
||
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsupported(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 2,
|
||
Name: "openai-oauth",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"access_token": "oauth-token",
|
||
"chatgpt_account_id": "chatgpt-acc",
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusNotFound,
|
||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||
Body: io.NopCloser(strings.NewReader(`404 page not found`)),
|
||
}}
|
||
svc := &AccountTestService{
|
||
accountRepo: repo,
|
||
httpUpstream: upstream,
|
||
}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/2/test", bytes.NewReader(nil))
|
||
|
||
err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
|
||
require.Error(t, err)
|
||
|
||
updates := <-updateCalls
|
||
require.Equal(t, false, updates["openai_compact_supported"])
|
||
require.Equal(t, http.StatusNotFound, updates["openai_compact_last_status"])
|
||
require.Contains(t, rec.Body.String(), `"type":"error"`)
|
||
}
|
||
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesNativeResponsesPath(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 3,
|
||
Name: "openai-apikey",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeAPIKey,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"api_key": "sk-test",
|
||
"base_url": "https://example.com/v1",
|
||
// post-#5641:compact_model_mapping 仅作用于 legacy /responses/compact,
|
||
// 原生 v2 探测不应用它。
|
||
"compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||
Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
|
||
}}
|
||
svc := &AccountTestService{
|
||
accountRepo: repo,
|
||
httpUpstream: upstream,
|
||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||
}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/3/test", bytes.NewReader(nil))
|
||
|
||
err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
|
||
require.NoError(t, err)
|
||
|
||
require.Equal(t, "https://example.com/v1/responses", upstream.lastReq.URL.String())
|
||
requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header)
|
||
require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2")
|
||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String(),
|
||
"原生 v2 探测不应用 compact_model_mapping")
|
||
updates := <-updateCalls
|
||
require.Equal(t, true, updates["openai_compact_supported"])
|
||
}
|
||
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBaseURLUsesResponsesPath(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 4,
|
||
Name: "openai-apikey-default",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeAPIKey,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"api_key": "sk-test",
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||
Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
|
||
}}
|
||
svc := &AccountTestService{
|
||
accountRepo: repo,
|
||
httpUpstream: upstream,
|
||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||
}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/4/test", bytes.NewReader(nil))
|
||
|
||
err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
|
||
require.NoError(t, err)
|
||
require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String())
|
||
<-updateCalls
|
||
}
|
||
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompact2xxWithoutItemMarksUnsupported(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 5,
|
||
Name: "openai-oauth-no-item",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"access_token": "oauth-token",
|
||
"chatgpt_account_id": "chatgpt-acc",
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
// 200 但流里没有 compaction item:链路吞掉了 compaction_trigger 的形态
|
||
//(#5478 的 "got 0 items"),必须判定为不支持。
|
||
noItemBody := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_1\"}}\n\n" +
|
||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_x\",\"output\":[]}}\n\n"
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||
Body: io.NopCloser(strings.NewReader(noItemBody)),
|
||
}}
|
||
svc := &AccountTestService{
|
||
accountRepo: repo,
|
||
httpUpstream: upstream,
|
||
}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/5/test", bytes.NewReader(nil))
|
||
|
||
err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
|
||
require.Error(t, err)
|
||
|
||
updates := <-updateCalls
|
||
require.Equal(t, false, updates["openai_compact_supported"])
|
||
require.Contains(t, rec.Body.String(), `"type":"error"`)
|
||
}
|
||
|
||
// 探测与真实转发走同一 /responses 端点,出站身份必须与真实 Codex 同构:
|
||
// session/thread 为 UUID、携带 x-codex-installation-id(收敛账号用收敛值)。
|
||
func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatchesRealTraffic(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
updateCalls := make(chan map[string]any, 1)
|
||
account := Account{
|
||
ID: 6,
|
||
Name: "openai-oauth-identity",
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
Credentials: map[string]any{
|
||
"access_token": "oauth-token",
|
||
"chatgpt_account_id": "chatgpt-acc",
|
||
},
|
||
// 收敛是显式 opt-in(#5610),这里显式开启以验证探测身份与真实流量同构。
|
||
Extra: map[string]any{
|
||
"codex_fingerprint_mode": "session",
|
||
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
|
||
},
|
||
}
|
||
repo := &snapshotUpdateAccountRepo{
|
||
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||
updateExtraCalls: updateCalls,
|
||
}
|
||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||
Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
|
||
}}
|
||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
||
|
||
rec := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(rec)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/6/test", bytes.NewReader(nil))
|
||
|
||
require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
|
||
|
||
// 显式 session 收敛模式:出站身份 = 账号级收敛值
|
||
seed, ok := codexFingerprintSeed(account.Extra)
|
||
require.True(t, ok)
|
||
converged := resolveConvergedSessionID(seed)
|
||
require.Equal(t, converged, upstream.lastReq.Header.Get("session-id"))
|
||
require.Equal(t, converged, upstream.lastReq.Header.Get("session_id"))
|
||
require.Equal(t, resolveConvergedInstallationID(&account, seed), upstream.lastReq.Header.Get("x-codex-installation-id"),
|
||
"真实 Codex 每个请求必带 installation-id,探测不得缺失")
|
||
require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact",
|
||
"探测标识不得是可被上游一眼识别的字面量")
|
||
<-updateCalls
|
||
}
|
||
|
||
func TestCompactProbeSessionID_IsUUIDShaped(t *testing.T) {
|
||
for _, id := range []int64{0, 1, 987654} {
|
||
got := compactProbeSessionID(id)
|
||
_, err := uuid.Parse(got)
|
||
require.NoError(t, err, "探测会话标识必须是 UUID 形态: %s", got)
|
||
}
|
||
require.Equal(t, compactProbeSessionID(7), compactProbeSessionID(7), "同账号应稳定复用同一会话")
|
||
require.NotEqual(t, compactProbeSessionID(7), compactProbeSessionID(8))
|
||
}
|