The repo already layered file defaults under environment overrides, on both the backend (settings.yml < GOAUTO_*) and the frontend (.env.production < process.env). What was missing was a translator for production: config.yaml only ever existed for the PowerShell launchers, so a packaged binary read none of it and had no database credentials either — SYB was inheriting an existing gap, not creating one. The server now reads config.yaml itself, between settings.yml and the environment. Lookup is GOAUTO_CONFIG, then ./config.yaml, then beside the executable, so a packaged binary works wherever it is started. An absent file is not an error: containers supply everything through the environment. Scalars are read by YAML type and coerced, so an unquoted all-digit password cannot take startup down over a quoting detail. This removed the need for a Read-SybConfig in PowerShell: the launcher just hands over the path it already knows, rather than reimplementing a YAML parser. The server also serves the built frontend when dist is present, which is what .env.production's empty VUE_APP_BASE_API already assumes. The history fallback is restricted to non-API GETs, and is not installed at all without dist, so development 404s stay 404s. Precedence is mutation-tested: applying the local file after the environment instead of before makes the layering test fail. Not verified: the PowerShell change and any Windows deployment — both need a run on the Windows side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
6.2 KiB
Go
204 lines
6.2 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config"
|
|
)
|
|
|
|
func writeLocalConfig(t *testing.T, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), LocalConfigName)
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("写入配置失败: %v", err)
|
|
}
|
|
t.Setenv("GOAUTO_CONFIG", path)
|
|
return path
|
|
}
|
|
|
|
func restoreConfigState(t *testing.T) {
|
|
t.Helper()
|
|
database := *sdkconfig.DatabaseConfig
|
|
application := *sdkconfig.ApplicationConfig
|
|
ext := ExtConfig.SYB
|
|
t.Cleanup(func() {
|
|
*sdkconfig.DatabaseConfig = database
|
|
*sdkconfig.ApplicationConfig = application
|
|
ExtConfig.SYB = ext
|
|
})
|
|
}
|
|
|
|
func TestApplyLocalConfigReadsDatabasePortsAndSYB(t *testing.T) {
|
|
restoreConfigState(t)
|
|
writeLocalConfig(t, `
|
|
database:
|
|
host: 127.0.0.1
|
|
port: 3307
|
|
user: root
|
|
password: "123456"
|
|
name: goauto
|
|
|
|
ports:
|
|
server: 8010
|
|
web: 9527
|
|
|
|
syb:
|
|
username: operator
|
|
password: "654321"
|
|
`)
|
|
ApplyLocalConfig()
|
|
|
|
if sdkconfig.DatabaseConfig.Driver != "mysql" {
|
|
t.Fatalf("driver 不对: %q", sdkconfig.DatabaseConfig.Driver)
|
|
}
|
|
want := "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
|
if sdkconfig.DatabaseConfig.Source != want {
|
|
t.Fatalf("DSN 不对:\n 实际 %s\n 期望 %s", sdkconfig.DatabaseConfig.Source, want)
|
|
}
|
|
if sdkconfig.ApplicationConfig.Port != 8010 {
|
|
t.Fatalf("端口不对: %d", sdkconfig.ApplicationConfig.Port)
|
|
}
|
|
if ExtConfig.SYB.Username != "operator" || ExtConfig.SYB.Password != "654321" {
|
|
t.Fatalf("SYB 凭据没有读进来: %+v", ExtConfig.SYB)
|
|
}
|
|
}
|
|
|
|
// `[必须]` 纯数字密码不加引号会被 YAML 读成整数。这是上游踩过的坑,
|
|
// 也是用户第一版 config.yaml 的实际写法——绝不能因此启动失败或丢值。
|
|
func TestApplyLocalConfigToleratesUnquotedNumericPassword(t *testing.T) {
|
|
restoreConfigState(t)
|
|
writeLocalConfig(t, `
|
|
database:
|
|
host: 127.0.0.1
|
|
port: 3307
|
|
user: root
|
|
password: 123456
|
|
name: goauto
|
|
syb:
|
|
username: operator
|
|
password: 654321
|
|
`)
|
|
ApplyLocalConfig()
|
|
|
|
if got := sdkconfig.DatabaseConfig.Source; got != "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s" {
|
|
t.Fatalf("数字密码没有被正确还原: %s", got)
|
|
}
|
|
if ExtConfig.SYB.Password != "654321" {
|
|
t.Fatalf("SYB 数字密码没有被正确还原: %q", ExtConfig.SYB.Password)
|
|
}
|
|
}
|
|
|
|
// 分层的核心:环境变量必须压过 config.yaml。
|
|
func TestEnvironmentOverridesLocalConfig(t *testing.T) {
|
|
restoreConfigState(t)
|
|
writeLocalConfig(t, `
|
|
database:
|
|
host: 127.0.0.1
|
|
port: 3307
|
|
user: root
|
|
password: "fromfile"
|
|
name: goauto
|
|
ports:
|
|
server: 8010
|
|
syb:
|
|
username: from-file
|
|
password: "from-file"
|
|
`)
|
|
t.Setenv("GOAUTO_DB_DSN", "envuser:envpass@tcp(10.0.0.1:3306)/envdb")
|
|
t.Setenv("GOAUTO_SERVER_PORT", "9000")
|
|
t.Setenv("GOAUTO_SYB_USERNAME", "from-env")
|
|
t.Setenv("GOAUTO_SYB_PASSWORD", "from-env-pass")
|
|
|
|
// 真实启动顺序:先 config.yaml,再环境变量。
|
|
ApplyLocalConfig()
|
|
ApplyEnvironment()
|
|
|
|
if sdkconfig.DatabaseConfig.Source != "envuser:envpass@tcp(10.0.0.1:3306)/envdb" {
|
|
t.Fatalf("环境变量没有压过 config.yaml: %s", sdkconfig.DatabaseConfig.Source)
|
|
}
|
|
if sdkconfig.ApplicationConfig.Port != 9000 {
|
|
t.Fatalf("端口应以环境变量为准: %d", sdkconfig.ApplicationConfig.Port)
|
|
}
|
|
if ExtConfig.SYB.Username != "from-env" || ExtConfig.SYB.Password != "from-env-pass" {
|
|
t.Fatalf("SYB 凭据应以环境变量为准: %+v", ExtConfig.SYB)
|
|
}
|
|
}
|
|
|
|
// 只写凭据、不写其它项时,settings.yml 里的运维参数必须原样保留。
|
|
func TestLocalConfigWithOnlyCredentialsKeepsSettingsValues(t *testing.T) {
|
|
restoreConfigState(t)
|
|
ExtConfig.SYB = SYB{BaseURL: "https://from-settings", PageSize: 20, MaxMatches: 10000, OcrURL: "https://ocr", OcrMaxAttempts: 5}
|
|
writeLocalConfig(t, `
|
|
syb:
|
|
username: operator
|
|
password: "654321"
|
|
`)
|
|
ApplyLocalConfig()
|
|
|
|
if ExtConfig.SYB.BaseURL != "https://from-settings" || ExtConfig.SYB.PageSize != 20 ||
|
|
ExtConfig.SYB.MaxMatches != 10000 || ExtConfig.SYB.OcrURL != "https://ocr" || ExtConfig.SYB.OcrMaxAttempts != 5 {
|
|
t.Fatalf("settings.yml 的非机密项被意外覆盖了: %+v", ExtConfig.SYB)
|
|
}
|
|
if ExtConfig.SYB.Username != "operator" {
|
|
t.Fatalf("凭据没有生效: %+v", ExtConfig.SYB)
|
|
}
|
|
}
|
|
|
|
// 容器场景根本没有 config.yaml,必须安静地当作正常情况。
|
|
func TestAbsentLocalConfigIsASilentNoOp(t *testing.T) {
|
|
restoreConfigState(t)
|
|
t.Setenv("GOAUTO_CONFIG", "")
|
|
if path := LocalConfigPath(); path != "" {
|
|
t.Fatalf("测试环境不应找到 config.yaml,实际找到 %s", path)
|
|
}
|
|
ApplyLocalConfig()
|
|
}
|
|
|
|
// 但显式指定了 GOAUTO_CONFIG 却指不到文件,是配置错误,要给出提示——
|
|
// 这和"压根没有这个文件"是两回事,不能一起静默掉。
|
|
func TestExplicitlyNamedMissingConfigStillStartsButIsReported(t *testing.T) {
|
|
restoreConfigState(t)
|
|
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
|
t.Setenv("GOAUTO_CONFIG", missing)
|
|
if LocalConfigPath() != missing {
|
|
t.Fatal("显式指定的路径应原样返回,好让读取失败时报出来")
|
|
}
|
|
ApplyLocalConfig()
|
|
}
|
|
|
|
// 配置文件写坏了也不能让服务起不来——它提供的每一项都是可选的。
|
|
func TestMalformedLocalConfigIsIgnored(t *testing.T) {
|
|
restoreConfigState(t)
|
|
writeLocalConfig(t, "database: [this is not a mapping\n")
|
|
ApplyLocalConfig()
|
|
}
|
|
|
|
// 数据库段缺字段时不能拼出半截 DSN,宁可完全不动。
|
|
func TestIncompleteDatabaseSectionIsIgnored(t *testing.T) {
|
|
restoreConfigState(t)
|
|
sdkconfig.DatabaseConfig.Source = "untouched"
|
|
writeLocalConfig(t, `
|
|
database:
|
|
host: 127.0.0.1
|
|
user: root
|
|
`)
|
|
ApplyLocalConfig()
|
|
|
|
if sdkconfig.DatabaseConfig.Source != "untouched" {
|
|
t.Fatalf("字段不全时不应改写 DSN: %s", sdkconfig.DatabaseConfig.Source)
|
|
}
|
|
}
|
|
|
|
func TestScalarRendersYAMLTypesWithoutExponentOrTrailingZero(t *testing.T) {
|
|
values := map[string]any{"int": 3307, "float": float64(8010), "str": "x", "bool": true, "big": float64(1e7)}
|
|
for key, want := range map[string]string{
|
|
"int": "3307", "float": "8010", "str": "x", "bool": "true", "big": "10000000", "missing": "",
|
|
} {
|
|
if got := scalar(values, key); got != want {
|
|
t.Fatalf("%s: 期望 %q,实际 %q", key, want, got)
|
|
}
|
|
}
|
|
}
|