Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// collectMapstructureKeys walks a config struct and returns every dotted key
|
||||
// viper would need in order to populate it.
|
||||
func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue // unexported
|
||||
}
|
||||
tag := field.Tag.Get("mapstructure")
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.ToLower(field.Name)
|
||||
}
|
||||
key := name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + name
|
||||
}
|
||||
|
||||
ft := field.Type
|
||||
for ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
collectMapstructureKeys(ft, key, out)
|
||||
continue
|
||||
}
|
||||
if ft.Kind() == reflect.Map {
|
||||
// A map cannot be expressed in a single environment variable, so it
|
||||
// is out of scope here — such settings need a config file either way.
|
||||
continue
|
||||
}
|
||||
if ft.Kind() == reflect.Slice {
|
||||
elem := ft.Elem()
|
||||
for elem.Kind() == reflect.Ptr {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
if elem.Kind() == reflect.Struct {
|
||||
// AutomaticEnv exposes one string value. Viper's string-to-slice
|
||||
// hook can populate scalar slices, but it cannot decode a string
|
||||
// into []struct. Registering a default would turn silent ignore
|
||||
// into a startup unmarshal error, so structured slices remain
|
||||
// config-file-only just like maps.
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[strings.ToLower(key)] = ft.String()
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage
|
||||
// bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions
|
||||
// SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can
|
||||
// override a key already in that union but never introduces one, and the
|
||||
// viper_bind_struct escape hatch is compiled out (we build with -tags embed).
|
||||
//
|
||||
// So a Config field with no registered default is unreachable by environment
|
||||
// variable whenever the deployment has no config.yaml containing it — the
|
||||
// operator sets the variable, the loader discards it, and the feature behaves
|
||||
// as if it were never configured. That is exactly how image_storage credentials
|
||||
// were lost, silently disabling async image tasks for env-driven deployments.
|
||||
//
|
||||
// When this fails, register a zero-valued default in setEnvReachableDefaults
|
||||
// for each reported scalar key. Maps and slices of structs are config-file-only.
|
||||
func TestConfigKeysAreEnvReachable(t *testing.T) {
|
||||
bound := map[string]string{}
|
||||
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
setDefaults()
|
||||
registered := map[string]struct{}{}
|
||||
for _, key := range viper.AllKeys() {
|
||||
registered[key] = struct{}{}
|
||||
}
|
||||
|
||||
var unreachable []string
|
||||
for key, kind := range bound {
|
||||
if _, ok := registered[key]; !ok {
|
||||
unreachable = append(unreachable, key+" ("+kind+")")
|
||||
}
|
||||
}
|
||||
sort.Strings(unreachable)
|
||||
|
||||
if len(unreachable) > 0 {
|
||||
t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s",
|
||||
len(unreachable), strings.Join(unreachable, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLoadImageStorageFromEnv guards against a viper trap that silently disabled
|
||||
// asynchronous image tasks for every environment-variable-only deployment.
|
||||
//
|
||||
// viper only decodes keys returned by AllKeys(), which unions SetDefault keys,
|
||||
// config-file keys and explicit BindEnv keys. AutomaticEnv can override a key
|
||||
// that is already in that list, but it never introduces a new one. Credentials
|
||||
// such as image_storage.bucket therefore need an (empty) default registered, or
|
||||
// IMAGE_STORAGE_BUCKET is dropped on the floor and Active() stays false while
|
||||
// image_storage.enabled reads true — the endpoints 404 with no useful signal.
|
||||
func TestLoadImageStorageFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("IMAGE_STORAGE_ENABLED", "true")
|
||||
t.Setenv("IMAGE_STORAGE_ENDPOINT", "https://acct.r2.cloudflarestorage.com")
|
||||
t.Setenv("IMAGE_STORAGE_BUCKET", "my-images")
|
||||
t.Setenv("IMAGE_STORAGE_ACCESS_KEY_ID", "ak")
|
||||
t.Setenv("IMAGE_STORAGE_SECRET_ACCESS_KEY", "sk")
|
||||
t.Setenv("IMAGE_STORAGE_PUBLIC_BASE_URL", "https://cdn.example.com")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, cfg.ImageStorage.Enabled)
|
||||
require.Equal(t, "https://acct.r2.cloudflarestorage.com", cfg.ImageStorage.Endpoint)
|
||||
require.Equal(t, "my-images", cfg.ImageStorage.Bucket)
|
||||
require.Equal(t, "ak", cfg.ImageStorage.AccessKeyID)
|
||||
require.Equal(t, "sk", cfg.ImageStorage.SecretAccessKey)
|
||||
require.Equal(t, "https://cdn.example.com", cfg.ImageStorage.PublicBaseURL)
|
||||
|
||||
require.True(t, cfg.ImageStorage.IsConfigured())
|
||||
require.True(t, cfg.ImageStorage.Active(), "async image tasks must be active when every credential is supplied via env")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeProxyProbeURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeProxyProbeURLs([]ProbeURLConfig{
|
||||
{URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "},
|
||||
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ProbeURLConfig{
|
||||
{URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"},
|
||||
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target ProbeURLConfig
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"},
|
||||
{name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"},
|
||||
{name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"},
|
||||
{name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"},
|
||||
{name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target})
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package config 包含钉钉连接配置的校验逻辑。
|
||||
//
|
||||
// internal_only 模式安全模型(方案 A):
|
||||
// 不再要求 admin 填写 InternalCorpID 做二次 corpID 比对。
|
||||
// 安全边界由钉钉"企业内部应用"类型本身保证——只有应用所属企业的员工才能完成 OAuth,
|
||||
// 因此 ValidateDingTalkConfig 只要求 app_type=internal(V1),不再要求 InternalCorpID 非空(原 V3 已删除)。
|
||||
// InternalCorpID 字段保留,admin 可选填;若填写,checkDingTalkCorpAllowed 不会使用它做约束。
|
||||
package config
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrDingTalkV1AppTypeMismatch = errors.New("dingtalk: internal_only requires app_type=internal")
|
||||
ErrDingTalkV4InvalidAppKind = errors.New("dingtalk: dingtalk_app_kind must be internal_app")
|
||||
)
|
||||
|
||||
func ValidateDingTalkConfig(cfg DingTalkConnectConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.DingTalkAppKind != "internal_app" {
|
||||
return ErrDingTalkV4InvalidAppKind
|
||||
}
|
||||
if cfg.CorpRestrictionPolicy == "internal_only" {
|
||||
if cfg.AppType != "internal" {
|
||||
return ErrDingTalkV1AppTypeMismatch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateDingTalkConfig_Disabled_Skip(t *testing.T) {
|
||||
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{Enabled: false}))
|
||||
}
|
||||
|
||||
func TestValidateDingTalkConfig_V4_DingTalkAppKind(t *testing.T) {
|
||||
err := ValidateDingTalkConfig(DingTalkConnectConfig{
|
||||
Enabled: true,
|
||||
DingTalkAppKind: "third_party_enterprise_app",
|
||||
CorpRestrictionPolicy: "none",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrDingTalkV4InvalidAppKind)
|
||||
}
|
||||
|
||||
func TestValidateDingTalkConfig_V1_InternalOnlyRequiresInternalAppType(t *testing.T) {
|
||||
err := ValidateDingTalkConfig(DingTalkConnectConfig{
|
||||
Enabled: true,
|
||||
DingTalkAppKind: "internal_app",
|
||||
AppType: "public",
|
||||
CorpRestrictionPolicy: "internal_only",
|
||||
InternalCorpID: "dingABC",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrDingTalkV1AppTypeMismatch)
|
||||
}
|
||||
|
||||
// TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A:
|
||||
// internal_only 策略下,InternalCorpID="" 应通过校验(企业隔离由钉钉 AppType=internal 保证)。
|
||||
func TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) {
|
||||
err := ValidateDingTalkConfig(DingTalkConnectConfig{
|
||||
Enabled: true,
|
||||
DingTalkAppKind: "internal_app",
|
||||
AppType: "internal",
|
||||
CorpRestrictionPolicy: "internal_only",
|
||||
InternalCorpID: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateDingTalkConfig_HappyPath_None(t *testing.T) {
|
||||
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{
|
||||
Enabled: true,
|
||||
DingTalkAppKind: "internal_app",
|
||||
AppType: "public",
|
||||
CorpRestrictionPolicy: "none",
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateWebAuthnConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(*Config)
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "valid production origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid localhost development origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API Dev",
|
||||
RPID: "localhost",
|
||||
RPOrigins: []string{"http://localhost:5173"},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing relying party id",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "webauthn.rp_id",
|
||||
},
|
||||
{
|
||||
name: "relying party id contains scheme",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "https://sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "domain without scheme",
|
||||
},
|
||||
{
|
||||
name: "non-local insecure origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"http://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "must use HTTPS",
|
||||
},
|
||||
{
|
||||
name: "origin outside relying party id",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "example.com",
|
||||
RPOrigins: []string{"https://example.net"},
|
||||
}
|
||||
},
|
||||
wantError: "not within relying party ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
tt.configure(cfg)
|
||||
|
||||
err = cfg.Validate()
|
||||
if tt.wantError == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet 提供配置层的依赖
|
||||
var ProviderSet = wire.NewSet(
|
||||
ProvideConfig,
|
||||
)
|
||||
|
||||
// ProvideConfig 提供应用配置
|
||||
func ProvideConfig() (*Config, error) {
|
||||
return LoadForBootstrap()
|
||||
}
|
||||
Reference in New Issue
Block a user