* test(control): 消除测试 fixture 中的重复密钥派生 - internal/control:fixture 构造中 388 次重复执行 PBKDF2(10 万轮 SHA256)密钥派生,改为共享单例,仅派生一次 - 新增 internal/testutil/encryptiontest,提供按 key material 值 memoize 的共享 encryption.Service,横向覆盖 gateway、subscription、 execution/cpa 中同样存在的重复派生(PBKDF2 占比最高达 79%) - aesService 构造后只读,跨 fixture(含并行测试)共享安全;container 包同类调用经排查确认属于被测装配流程本身,不适用本次改造 * test(state): 迁移测试改用共享 schema 模板 state/loader 中 openMigratedDatabase 被 21 次调用,每次都完整走一遍 迁移注册表;改为复用 sqlitetest.OpenMigrated 的模板缓存,与 control、 requestlog 部分测试已采用的方式一致。app、container、requestlog 中同 类调用点经逐点排查,均属被测对象本身或依赖真实文件/账本状态,不适用。 * ci(release): 消除 CI 与发布流水线中的耗时浪费 - race-tests 独立 setup-go 缓存命名空间,避免与 test/database-contract 共用 key 导致 race-instrumented GOCACHE 永远无法写回 - 移除 publish-images 中零命中的 GHA buildx image cache,同时消除其 失败会拖累发布状态判定为 partial/blocked 的关联风险 - 三个只读 job(仅用 buildx imagetools 查询)移除多余的 buildx 初始化 - native-artifact-smoke 改为按需下载单个 binary + 独立 checksums, 不再下载完整 release-assets * test(control): 第一批测试标记 t.Parallel(35 个文件) internal/control 651 个顶层测试里只有 5.8% 标了 t.Parallel(),其余 在包内是排队串行执行的;race-tests 总耗时现在完全由该包的最长执行时 间决定(213s),是唯一剩余的瓶颈。 本批覆盖 35 个文件、299 个函数。排除 18 个不适用的函数: - 16 个直接操作全局 logrus 状态的测试(catalog_sync_test.go 等 8 个 文件),继续保持串行;Go 的 t.Parallel() 调度语义保证未标记的测试 会在所有并行测试开始前完整跑完,两者不会交叉执行 - external_database_integration_test.go 的 2 个测试依赖 GPT_LOAD_DATABASE_TEST_DSN:race-tests 中因未设置而被 t.Skip,标记 并行无收益;database-contract job 中会共享同一个外部数据库实例真 实执行,标记并行有正确性风险(唯一索引冲突/断言假设独占) 已排查确认安全:t.Setenv/os.Setenv 均为 0 处,无硬编码端口占用,每个 fixture 走独立 :memory: 数据库;本地 -count=3 重复验证无 flaky。 剩余约 297 个函数(36 个文件)作为第二批,待本批 CI 实测确认收益后 再决定是否推进。 * test(control): 第二批测试标记 t.Parallel,补齐排除说明(36 个文件) 覆盖剩余 297 个函数、36 个文件,加上第一批的 299 个,internal/control 除 18 个明确排除的函数外全部标记 t.Parallel()。 为 18 个排除函数逐一补充一行注释说明原因,避免日后被误认为遗漏而被 顺手加上 t.Parallel(): - 16 个劫持全局 logrus 状态的测试 - 2 个依赖 GPT_LOAD_DATABASE_TEST_DSN 共享外部数据库的测试 本地 -count=3 重复验证、全量 go test -count=1 . ./internal/...(58 包 ok)均通过。 * fix(release): 采纳 Review 反馈,修正文档措辞与产物保留期 - encryptiontest.Service 的文档注释修正为明确说明缓存仅在单个包的 测试二进制内有效,不跨包共享(每个包的 go test 是独立进程) - native-artifact-smoke 依赖的 binary-*、release-checksums 保留期 从 1 天提高到与 release-assets 一致的 14 天:失败的 release run 若延迟超过 1 天才重试,此前较短的保留期会导致产物过期、下载失败, 阻塞发布流程 以下反馈经核实判定为不成立,未采纳: - validation_test.go 中两个测试被指"竞争全局并发槽位"——经查 newValidationWorkerForTest 每次调用构造全新独立实例(worker、 channel、mutation coordinator 等),validationConcurrency 只是 编译期常量,两个测试之间不存在任何运行时共享状态
260 lines
9.9 KiB
Go
260 lines
9.9 KiB
Go
package control
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/outboundproxy"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestGroupAndCredentialProxyUseFinalPrecedenceAndEncryptedStorage(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
runtime := &recordingCredentialRuntimeExecutor{}
|
|
fixture.service.executor = runtime
|
|
groupID := createGroupWithCredentials(t, fixture, "sk-proxy-test")
|
|
|
|
if _, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{outboundproxy.SystemSettingKey: json.RawMessage(
|
|
`{"mode":"custom","url":"http://global-user:global-password@global-proxy.example.com:8080"}`,
|
|
)},
|
|
}); err != nil {
|
|
t.Fatalf("set global proxy: %v", err)
|
|
}
|
|
group, err := fixture.service.GetGroupSettings(t.Context(), groupID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if group.Proxy.ConfiguredMode != outboundproxy.ModeInherit || group.Proxy.EffectiveSource != outboundproxy.SourceGlobal {
|
|
t.Fatalf("inherited group proxy = %#v", group.Proxy)
|
|
}
|
|
|
|
group, err = fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{
|
|
Proxy: optionalField[outboundproxy.Config]{Set: true, Value: outboundproxy.Config{
|
|
Mode: outboundproxy.ModeCustom,
|
|
URL: "socks5://group-user:group-password@group-proxy.example.com:1080",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateGroupSettings(proxy) error = %v", err)
|
|
}
|
|
if group.Proxy.EffectiveSource != outboundproxy.SourceGroup ||
|
|
group.Proxy.DisplayURL != "socks5://group-user:******@group-proxy.example.com:1080" {
|
|
t.Fatalf("group proxy = %#v", group.Proxy)
|
|
}
|
|
|
|
credentials, err := fixture.service.ListGroupCredentials(t.Context(), groupID, CredentialCollectionQuery{Page: 1, PageSize: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(credentials.Items) != 1 || credentials.Items[0].Proxy.EffectiveSource != outboundproxy.SourceGroup {
|
|
t.Fatalf("inherited credential proxy = %#v", credentials.Items)
|
|
}
|
|
credentialID := credentials.Items[0].CredentialID
|
|
secretVersion := credentials.Items[0].SecretVersion
|
|
|
|
updated, err := fixture.service.UpdateGroupCredential(t.Context(), groupID, credentialID, CredentialUpdateRequest{
|
|
Proxy: optionalField[outboundproxy.Config]{Set: true, Value: outboundproxy.Config{Mode: outboundproxy.ModeDirect}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateGroupCredential(proxy) error = %v", err)
|
|
}
|
|
if updated.Proxy.ConfiguredMode != outboundproxy.ModeDirect ||
|
|
updated.Proxy.EffectiveMode != outboundproxy.ModeDirect ||
|
|
updated.Proxy.EffectiveSource != outboundproxy.SourceCredential {
|
|
t.Fatalf("credential proxy = %#v", updated.Proxy)
|
|
}
|
|
if updated.SecretVersion != secretVersion {
|
|
t.Fatalf("proxy update changed secret version %d -> %d", secretVersion, updated.SecretVersion)
|
|
}
|
|
if got := runtime.retiredCredentialIDs(); len(got) != 1 || got[0] != credentialID {
|
|
t.Fatalf("retired credential runtimes = %v, want [%d]", got, credentialID)
|
|
}
|
|
ref, ok := fixture.registry.CredentialRef(credentialID)
|
|
if !ok || ref.EncryptedProxy == "" || ref.ProxyFingerprint == "" {
|
|
t.Fatalf("credential registry proxy identity = %#v, %t", ref, ok)
|
|
}
|
|
|
|
var groupRow models.Group
|
|
if err := fixture.db.Take(&groupRow, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var credentialRow models.Credential
|
|
if err := fixture.db.Take(&credentialRow, credentialID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, ciphertext := range map[string]*string{
|
|
"group": groupRow.ProxyConfig, "credential": credentialRow.ProxyConfig,
|
|
} {
|
|
if ciphertext == nil || strings.Contains(*ciphertext, "password") || strings.Contains(*ciphertext, "proxy.example.com") {
|
|
t.Fatalf("%s proxy is not encrypted: %v", name, ciphertext)
|
|
}
|
|
}
|
|
|
|
reset, err := fixture.service.UpdateGroupCredential(t.Context(), groupID, credentialID, CredentialUpdateRequest{
|
|
Proxy: optionalField[outboundproxy.Config]{Set: true, Null: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("reset credential proxy: %v", err)
|
|
}
|
|
if reset.Proxy.ConfiguredMode != outboundproxy.ModeInherit || reset.Proxy.EffectiveSource != outboundproxy.SourceGroup {
|
|
t.Fatalf("reset credential proxy = %#v", reset.Proxy)
|
|
}
|
|
ref, ok = fixture.registry.CredentialRef(credentialID)
|
|
if !ok || ref.EncryptedProxy != "" || ref.ProxyFingerprint != "" {
|
|
t.Fatalf("reset credential registry proxy identity = %#v, %t", ref, ok)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProxyUpdateRetiresRuntimeAfterCommittedRegistryRecovery(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
runtime := &recordingCredentialRuntimeExecutor{}
|
|
fixture.service.executor = runtime
|
|
groupID := createGroupWithCredentials(t, fixture, "sk-proxy-recovery-test")
|
|
credentials, err := fixture.service.ListGroupCredentials(
|
|
t.Context(),
|
|
groupID,
|
|
CredentialCollectionQuery{Page: 1, PageSize: 20},
|
|
)
|
|
if err != nil || len(credentials.Items) != 1 {
|
|
t.Fatalf("ListGroupCredentials() = %#v, %v", credentials, err)
|
|
}
|
|
credentialID := credentials.Items[0].CredentialID
|
|
|
|
const callbackName = "test:remove_registry_entry_after_credential_update"
|
|
removed := false
|
|
if err := fixture.db.Callback().Update().After("gorm:update").Register(
|
|
callbackName,
|
|
func(*gorm.DB) {
|
|
if !removed {
|
|
removed = fixture.registry.RemoveCredential(credentialID)
|
|
}
|
|
},
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = fixture.db.Callback().Update().Remove(callbackName)
|
|
})
|
|
|
|
_, err = fixture.service.UpdateGroupCredential(
|
|
t.Context(),
|
|
groupID,
|
|
credentialID,
|
|
CredentialUpdateRequest{Proxy: optionalField[outboundproxy.Config]{
|
|
Set: true,
|
|
Value: outboundproxy.Config{
|
|
Mode: outboundproxy.ModeCustom,
|
|
URL: "http://recovered-proxy.example.com:8080",
|
|
},
|
|
}},
|
|
)
|
|
var operationErr *controlOperationError
|
|
if !errors.As(err, &operationErr) || operationErr.stage != stageApplyCommittedRegistryMutation {
|
|
t.Fatalf("UpdateGroupCredential() error = %#v, want committed Registry recovery", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("test did not remove the Registry entry after the database update")
|
|
}
|
|
ref, ok := fixture.registry.CredentialRef(credentialID)
|
|
if !ok || ref.EncryptedProxy == "" || ref.ProxyFingerprint == "" {
|
|
t.Fatalf("recovered credential Registry proxy = %#v, %t", ref, ok)
|
|
}
|
|
if got := runtime.retiredCredentialIDs(); len(got) != 1 || got[0] != credentialID {
|
|
t.Fatalf("retired credential runtimes = %v, want [%d]", got, credentialID)
|
|
}
|
|
}
|
|
|
|
func TestGroupAndCredentialProxyRejectInvalidConfigWithoutMutation(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupWithCredentials(t, fixture, "sk-invalid-proxy-test")
|
|
credentials, err := fixture.service.ListGroupCredentials(t.Context(), groupID, CredentialCollectionQuery{Page: 1, PageSize: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credentialID := credentials.Items[0].CredentialID
|
|
|
|
if _, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{
|
|
Proxy: optionalField[outboundproxy.Config]{Set: true, Value: outboundproxy.Config{Mode: outboundproxy.ModeCustom, URL: "ftp://proxy.example.com"}},
|
|
}); err == nil {
|
|
t.Fatal("UpdateGroupSettings accepted invalid proxy")
|
|
}
|
|
if _, err := fixture.service.UpdateGroupCredential(t.Context(), groupID, credentialID, CredentialUpdateRequest{
|
|
Proxy: optionalField[outboundproxy.Config]{Set: true, Value: outboundproxy.Config{Mode: outboundproxy.ModeInherit}},
|
|
}); err == nil {
|
|
t.Fatal("UpdateGroupCredential accepted inherit object")
|
|
}
|
|
|
|
var groupRow models.Group
|
|
if err := fixture.db.Take(&groupRow, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var credentialRow models.Credential
|
|
if err := fixture.db.Take(&credentialRow, credentialID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if groupRow.ProxyConfig != nil || credentialRow.ProxyConfig != nil {
|
|
t.Fatalf("invalid proxy mutated rows: %v/%v", groupRow.ProxyConfig, credentialRow.ProxyConfig)
|
|
}
|
|
}
|
|
|
|
func TestUnsupportedChannelRejectsManagedGroupAndCredentialProxy(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
name := "azure-without-managed-proxy"
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
Name: &name, ChannelID: channel.AzureOpenAI,
|
|
ConnectionType: models.ConnectionTypeAPIKey,
|
|
Params: json.RawMessage(`{"endpoint":"https://resource.openai.azure.com"}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{{ID: "gpt-4o"}}},
|
|
Credentials: "azure-key",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateGroup() error = %v", err)
|
|
}
|
|
|
|
proxy := optionalField[outboundproxy.Config]{Set: true, Value: outboundproxy.Config{
|
|
Mode: outboundproxy.ModeCustom,
|
|
URL: "http://proxy.example.com:8080",
|
|
}}
|
|
if _, err := fixture.service.UpdateGroupSettings(t.Context(), created.GroupID, GroupSettingsUpdateRequest{
|
|
Proxy: proxy,
|
|
}); !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("UpdateGroupSettings() error = %v, want ErrValidation", err)
|
|
}
|
|
credentials, err := fixture.service.ListGroupCredentials(
|
|
t.Context(), created.GroupID, CredentialCollectionQuery{Page: 1, PageSize: 20},
|
|
)
|
|
if err != nil || len(credentials.Items) != 1 {
|
|
t.Fatalf("ListGroupCredentials() = %#v, %v", credentials, err)
|
|
}
|
|
if _, err := fixture.service.UpdateGroupCredential(
|
|
t.Context(), created.GroupID, credentials.Items[0].CredentialID,
|
|
CredentialUpdateRequest{Proxy: proxy},
|
|
); !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("UpdateGroupCredential() error = %v, want ErrValidation", err)
|
|
}
|
|
|
|
withProxy := GroupCreateRequest{
|
|
Name: new(string), ChannelID: channel.AzureOpenAI,
|
|
ConnectionType: models.ConnectionTypeAPIKey,
|
|
Params: json.RawMessage(`{"endpoint":"https://another.openai.azure.com"}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{{ID: "gpt-4o"}}},
|
|
Credentials: "another-azure-key",
|
|
Proxy: proxy,
|
|
}
|
|
*withProxy.Name = "azure-with-managed-proxy"
|
|
if _, err := fixture.service.CreateGroup(t.Context(), withProxy); !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("CreateGroup(proxy) error = %v, want ErrValidation", err)
|
|
}
|
|
}
|