* feat(proxy): 实现第一阶段数据面代理 * feat(proxy): 收敛渠道与代理协议支持 * fix(proxy): 修复环境检测与迁移验证 * fix(proxy): 回收旧代理运行时并校验地址 * fix(proxy): 补齐创建链路与前端校验 * fix(proxy): 收口订阅代理与迁移兼容 * fix(proxy): 修复环境代理与运行时回收 * fix(proxy): 为系统外部请求应用全局代理 * docs(config): 精简环境配置说明 * feat(proxy): 重构代理设置的前端交互与样式 设置页与分组设置页: - 代理设置接入页面统一的草稿态与保存栏,不再独立即时提交 - 计入页面 dirty/valid,参与未保存离开拦截与并发冲突推迟 - 新增 ProxyOverrideRow,用分段控件替代原独立编辑弹层 账号卡片与密钥行: - ProxyConfigEditor 新增 card 外观:标题在白底面板外,与相邻小节同级 - 修掉按视口宽度判断断点的问题,窄容器内不再挤压错乱 - 编辑态下拉固定宽度,不随所选模式伸缩;整体收进两行 - 新增 ProxyScopeIndicator,凭据配置了独立代理时给出标识与悬停提示 密钥行交互: - 操作列收成「更多操作」菜单与展开图标,与账号卡片同构 - 折叠区分为权重、出站代理、运行诊断三个带标题小节,设置与诊断不再混放 - 权重编辑从弹层迁入折叠区,权重列的值可点击直达 - 释放的列宽还给凭据、权重与最近调用列 文案精简为继承、直连、自定义,并清理两处无引用的死键 * refactor(proxy): 统一代理设置的覆盖交互并消除样式重复 设置页与分组设置页: - 代理行改用 RuntimeOverrideRow,与超时等设置项共用同一组件 - inherit 映射为“未覆盖”,direct/custom 映射为“显式覆盖”,因此补齐了 覆盖/恢复默认按钮、覆盖状态徽章、当前有效值与待恢复默认态 - 分段控件只保留直连与自定义,继承由外层覆盖开关表达 - 删除自造的 ProxyOverrideRow,新增只含编辑控件的 ProxyOverrideControl 已保存的自定义地址: - 输入框不做回填,统一由 placeholder 展示已存地址 - 同模式下留空或与已存地址逐字相同均视为未改动,顺带挡住掩码被当作 新密码存回;无改动时保存按钮禁用 - 重新开启覆盖时回到基线已存的覆盖模式,使恢复默认可原路撤销 代码质量: - 面板外壳(标题 + 白底面板 + 标签/值/编辑图标/操作)抽为全局 .setting-panel,代理、权重、诊断三处共用,不再各写一份 - 删除 ProxyConfigEditor 已无调用方的 row 外观及随之失效的 props、 徽章分支与始终隐藏的文案 - 占位地址推导与覆盖开关规则收敛为 proxy.ts 中的共享函数 - 移除代理输入提示文案及其失去引用的 i18n 键 密钥行折叠区诊断值字号对齐上方面板。重构以构建产物 CSS 逐条比对验证, 差异均为已论证的等价改写。 * fix(proxy): 修复代理传输与校验边界
275 lines
9.0 KiB
Go
275 lines
9.0 KiB
Go
package releasecheck
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gpt-load/internal/outboundproxy"
|
|
"gpt-load/internal/platform/httpclient"
|
|
)
|
|
|
|
func TestClientUsesLatestGlobalProxyPolicyForEachFetch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
writeReleases := func(writer http.ResponseWriter) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`[{"tag_name":"v2.0.1","html_url":"https://github.com/tbphp/gpt-load/releases/tag/v2.0.1","published_at":"2026-08-19T13:09:53Z","draft":false}]`))
|
|
}
|
|
var targetCalls atomic.Int32
|
|
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
targetCalls.Add(1)
|
|
writeReleases(writer)
|
|
}))
|
|
defer target.Close()
|
|
var proxyCalls atomic.Int32
|
|
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
proxyCalls.Add(1)
|
|
writeReleases(writer)
|
|
}))
|
|
defer proxy.Close()
|
|
|
|
effective := outboundproxy.Effective{
|
|
Config: outboundproxy.Config{Mode: outboundproxy.ModeCustom, URL: proxy.URL},
|
|
Source: outboundproxy.SourceGlobal,
|
|
}
|
|
var providerCalls atomic.Int32
|
|
client := NewClient(httpclient.NewHTTPClientManager(), func() outboundproxy.Effective {
|
|
providerCalls.Add(1)
|
|
return effective
|
|
})
|
|
client.endpoint = target.URL
|
|
if _, err := client.Fetch(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if proxyCalls.Load() != 1 || targetCalls.Load() != 0 {
|
|
t.Fatalf("custom proxy/target calls = %d/%d, want 1/0", proxyCalls.Load(), targetCalls.Load())
|
|
}
|
|
|
|
effective = outboundproxy.Effective{
|
|
Config: outboundproxy.Config{Mode: outboundproxy.ModeDirect},
|
|
Source: outboundproxy.SourceGlobal,
|
|
}
|
|
if _, err := client.Fetch(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if proxyCalls.Load() != 1 || targetCalls.Load() != 1 || providerCalls.Load() != 2 {
|
|
t.Fatalf(
|
|
"direct proxy/target/provider calls = %d/%d/%d, want 1/1/2",
|
|
proxyCalls.Load(),
|
|
targetCalls.Load(),
|
|
providerCalls.Load(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestClientFetchUsesFixedPublicGitHubContract(t *testing.T) {
|
|
published := "2026-08-19T13:09:53Z"
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodGet || request.URL.Path != "/repos/tbphp/gpt-load/releases" ||
|
|
request.URL.Query().Get("per_page") != "30" || len(request.URL.Query()) != 1 {
|
|
t.Errorf("request = %s %s", request.Method, request.URL.String())
|
|
}
|
|
if request.Header.Get("Accept") != "application/vnd.github+json" ||
|
|
request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" ||
|
|
request.Header.Get("User-Agent") != "GPT-Load" ||
|
|
request.Header.Get("Authorization") != "" {
|
|
t.Errorf("request headers = %#v", request.Header)
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprintf(writer, `[
|
|
{"tag_name":"v2.0.0-beta.7","html_url":"%s","published_at":"%s","draft":false,"prerelease":true,"ignored":"value"}
|
|
]`, testReleaseURL("v2.0.0-beta.7"), published)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &Client{
|
|
httpClient: server.Client(),
|
|
endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30",
|
|
maxResponseBytes: maxGitHubResponseBytes,
|
|
}
|
|
releases, err := client.Fetch(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Fetch() error = %v", err)
|
|
}
|
|
if len(releases) != 1 || releases[0].TagName != "v2.0.0-beta.7" ||
|
|
releases[0].HTMLURL != testReleaseURL("v2.0.0-beta.7") || releases[0].Draft ||
|
|
!releases[0].PublishedAt.Equal(mustParseReleaseTime(published)) {
|
|
t.Fatalf("Fetch() = %#v", releases)
|
|
}
|
|
}
|
|
|
|
func TestClientFetchReadsEligibleV2ReleaseFromSecondPage(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
requests++
|
|
if request.URL.Path != "/repos/tbphp/gpt-load/releases" ||
|
|
request.URL.Query().Get("per_page") != "30" {
|
|
t.Errorf("request = %s %s", request.Method, request.URL.String())
|
|
}
|
|
page := request.URL.Query().Get("page")
|
|
switch requests {
|
|
case 1:
|
|
if page != "" {
|
|
t.Errorf("first page query = %q, want empty", page)
|
|
}
|
|
writer.Header().Set("Link", `<http://127.0.0.1:1/untrusted?page=2>; rel="next"`)
|
|
writeGitHubReleasePage(t, writer, releasePageTags("v3.0", githubReleasesPerPage))
|
|
case 2:
|
|
if page != "2" {
|
|
t.Errorf("second page query = %q, want 2", page)
|
|
}
|
|
writeGitHubReleasePage(t, writer, []string{"v2.0.1"})
|
|
default:
|
|
t.Errorf("unexpected request %d: %s", requests, request.URL.String())
|
|
writeGitHubReleasePage(t, writer, nil)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &Client{
|
|
httpClient: server.Client(),
|
|
endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30",
|
|
maxResponseBytes: maxGitHubResponseBytes,
|
|
}
|
|
releases, err := client.Fetch(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Fetch() error = %v", err)
|
|
}
|
|
update := SelectUpdate("v2.0.0", releases)
|
|
if update == nil || update.Version != "v2.0.1" || requests != 2 {
|
|
t.Fatalf("SelectUpdate()/requests = %#v/%d, want v2.0.1/2", update, requests)
|
|
}
|
|
}
|
|
|
|
func TestClientFetchRejectsReleaseHistoryBeyondPageLimit(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
requests++
|
|
page := request.URL.Query().Get("page")
|
|
wantPage := ""
|
|
if requests > 1 {
|
|
wantPage = strconv.Itoa(requests)
|
|
}
|
|
if page != wantPage {
|
|
t.Errorf("request %d page = %q, want %q", requests, page, wantPage)
|
|
}
|
|
writeGitHubReleasePage(t, writer, releasePageTags("v3."+strconv.Itoa(requests), githubReleasesPerPage))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &Client{
|
|
httpClient: server.Client(),
|
|
endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30",
|
|
maxResponseBytes: maxGitHubResponseBytes,
|
|
}
|
|
if releases, err := client.Fetch(t.Context()); err == nil || releases != nil ||
|
|
requests != maxGitHubReleasePages+1 {
|
|
t.Fatalf(
|
|
"Fetch() = %#v, %v after %d requests, want nil/error after %d",
|
|
releases,
|
|
err,
|
|
requests,
|
|
maxGitHubReleasePages+1,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestClientFetchRejectsUnusableResponses(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
contentType string
|
|
body string
|
|
limit int64
|
|
}{
|
|
{name: "non success", status: http.StatusForbidden, contentType: "application/json", body: `{"message":"limited"}`},
|
|
{name: "wrong content type", status: http.StatusOK, contentType: "text/html", body: `[]`},
|
|
{name: "invalid json", status: http.StatusOK, contentType: "application/json", body: `[`},
|
|
{name: "trailing json", status: http.StatusOK, contentType: "application/json", body: `[] {}`},
|
|
{name: "oversized", status: http.StatusOK, contentType: "application/json", body: strings.Repeat(" ", 33), limit: 32},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.Header().Set("Content-Type", test.contentType)
|
|
writer.WriteHeader(test.status)
|
|
_, _ = writer.Write([]byte(test.body))
|
|
}))
|
|
defer server.Close()
|
|
limit := test.limit
|
|
if limit == 0 {
|
|
limit = maxGitHubResponseBytes
|
|
}
|
|
client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: limit}
|
|
if releases, err := client.Fetch(context.Background()); err == nil || releases != nil {
|
|
t.Fatalf("Fetch() = %#v, %v, want error", releases, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientFetchHonorsCancellation(t *testing.T) {
|
|
started := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
|
close(started)
|
|
<-request.Context().Done()
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: maxGitHubResponseBytes}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.Fetch(ctx)
|
|
done <- err
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Fetch() did not start an HTTP request")
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("Fetch() error = nil after cancellation")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Fetch() did not return after cancellation")
|
|
}
|
|
}
|
|
|
|
func writeGitHubReleasePage(t *testing.T, writer http.ResponseWriter, tags []string) {
|
|
t.Helper()
|
|
published := "2026-08-19T13:09:53Z"
|
|
payload := make([]map[string]any, 0, len(tags))
|
|
for _, tag := range tags {
|
|
payload = append(payload, map[string]any{
|
|
"tag_name": tag,
|
|
"html_url": testReleaseURL(tag),
|
|
"published_at": published,
|
|
"draft": false,
|
|
})
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(writer).Encode(payload); err != nil {
|
|
t.Errorf("encode release page: %v", err)
|
|
}
|
|
}
|
|
|
|
func releasePageTags(prefix string, count int) []string {
|
|
tags := make([]string, 0, count)
|
|
for index := 0; index < count; index++ {
|
|
tags = append(tags, prefix+"."+strconv.Itoa(index))
|
|
}
|
|
return tags
|
|
}
|