jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

386 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoad_Defaults(t *testing.T) {
// Arrange清掉所有相關 envt.Setenv 自動還原)
for _, k := range []string{
"VISIONA_HOST", "VISIONA_API_PORT", "VISIONA_TUNNEL_PORT", "VISIONA_PROXY_INTERNAL_PORT",
"VISIONA_SESSION_BACKEND", "VISIONA_PROXY_INTERNAL_URL",
"VISIONA_AUTH_TYPE", "VISIONA_STATIC_USER_ID", "VISIONA_PAIRING_TOKEN",
"VISIONA_STORAGE_BACKEND", "VISIONA_STORAGE_LOCALFS_ROOT",
"VISIONA_MODEL_MAX_SIZE_MB",
"VISIONA_TUNNEL_HEARTBEAT_INTERVAL", "VISIONA_TUNNEL_IDLE_TIMEOUT",
"VISIONA_LOG_LEVEL",
} {
t.Setenv(k, "")
}
// Act
cfg := Load()
// Assert
assert.Equal(t, "0.0.0.0", cfg.Server.Host)
// Port 預設改為 3721 — 對齊 local-toolB4
assert.Equal(t, 3721, cfg.Server.Port)
assert.Equal(t, 3800, cfg.Server.TunnelPort)
assert.Equal(t, 3801, cfg.Server.InternalPort)
assert.False(t, cfg.Server.SeedDemoData, "預設不 seed demo data")
assert.Equal(t, "inmemory", cfg.Session.Backend)
assert.Equal(t, "http://localhost:3801", cfg.Session.ProxyInternalURL)
assert.Equal(t, "demo-user", cfg.Auth.StaticUserID)
assert.Equal(t, "localfs", cfg.Storage.Backend)
assert.Equal(t, "./data/storage", cfg.Storage.RootDir)
assert.Equal(t, 100, cfg.Model.MaxSizeMB)
assert.Equal(t, 10*time.Second, cfg.Tunnel.HeartbeatInterval)
assert.Equal(t, 30*time.Second, cfg.Tunnel.IdleTimeout)
assert.Equal(t, "info", cfg.Logger.Level)
}
func TestLoad_EnvOverrides(t *testing.T) {
t.Setenv("VISIONA_API_PORT", "8080")
t.Setenv("VISIONA_STATIC_USER_ID", "custom-user")
t.Setenv("VISIONA_MODEL_MAX_SIZE_MB", "500")
t.Setenv("VISIONA_TUNNEL_HEARTBEAT_INTERVAL", "5s")
t.Setenv("VISIONA_LOG_LEVEL", "debug")
cfg := Load()
assert.Equal(t, 8080, cfg.Server.Port)
assert.Equal(t, "custom-user", cfg.Auth.StaticUserID)
assert.Equal(t, 500, cfg.Model.MaxSizeMB)
assert.Equal(t, 5*time.Second, cfg.Tunnel.HeartbeatInterval)
assert.Equal(t, "debug", cfg.Logger.Level)
}
func TestLoad_InvalidIntFallback(t *testing.T) {
t.Setenv("VISIONA_API_PORT", "not-a-number")
cfg := Load()
assert.Equal(t, 3721, cfg.Server.Port, "無法解析時應 fallback 到預設值B4 改為 3721")
}
// TestLoad_SeedDemoData 驗證 VISIONA_SEED_DEMO_DATA env 的解析行為。
func TestLoad_SeedDemoData(t *testing.T) {
t.Setenv("VISIONA_SEED_DEMO_DATA", "true")
cfg := Load()
assert.True(t, cfg.Server.SeedDemoData)
t.Setenv("VISIONA_SEED_DEMO_DATA", "false")
cfg = Load()
assert.False(t, cfg.Server.SeedDemoData)
// 無法解析時 fallback 到預設 false
t.Setenv("VISIONA_SEED_DEMO_DATA", "not-a-bool")
cfg = Load()
assert.False(t, cfg.Server.SeedDemoData, "無法解析時應 fallback 到預設值")
}
// TestLoad_OIDCDefaults 驗證未設定任何 VISIONA_OIDC_* 時OIDC 欄位為空字串。
//
// OB5 起 OIDC.Enabled 已移除OIDC 是唯一認證路徑);空字串就是「未設定」,
// 此時 Validate() 會回 MissingEnvErrormain.go 啟動時 fatal log 退出。
func TestLoad_OIDCDefaults(t *testing.T) {
for _, k := range []string{
"VISIONA_OIDC_ISSUER_URL", "VISIONA_OIDC_CLIENT_ID",
"VISIONA_OIDC_CLIENT_SECRET", "VISIONA_OIDC_REDIRECT_URL", "VISIONA_FRONTEND_URL",
"VISIONA_OIDC_SERVICE_CLIENT_ID", "VISIONA_OIDC_SERVICE_CLIENT_SECRET",
"VISIONA_SESSION_SECRET", "VISIONA_SESSION_COOKIE_NAME", "VISIONA_SESSION_COOKIE_DOMAIN",
"VISIONA_SESSION_COOKIE_SECURE", "VISIONA_SESSION_ABSOLUTE_TTL", "VISIONA_SESSION_IDLE_TTL",
} {
t.Setenv(k, "")
}
cfg := Load()
assert.Empty(t, cfg.OIDC.IssuerURL)
assert.Empty(t, cfg.OIDC.ClientID)
assert.Empty(t, cfg.OIDC.ClientSecret)
assert.Empty(t, cfg.OIDC.RedirectURL)
assert.Empty(t, cfg.OIDC.PostLoginURL)
assert.Empty(t, cfg.OIDC.ServiceClientID, "ServiceClientID 預設留空A1未啟用")
assert.Empty(t, cfg.OIDC.ServiceClientSecret, "ServiceClientSecret 預設留空A1未啟用")
assert.Empty(t, cfg.UserSession.Secret, "雛形 dev 預設不附 secret由 caller 注入或啟動失敗")
assert.Equal(t, "visiona_session", cfg.UserSession.CookieName)
assert.Empty(t, cfg.UserSession.CookieDomain)
assert.False(t, cfg.UserSession.CookieSecure)
assert.Equal(t, 168*time.Hour, cfg.UserSession.AbsoluteTTL)
assert.Equal(t, 24*time.Hour, cfg.UserSession.IdleTTL)
}
// TestLoad_OIDC_ClientSecretOptionalA12026-05-01— 缺 ClientSecret 不再回 MissingEnvError。
//
// 模擬 Stage 用的 public PKCE-only clientMC 給的 b8093fea... 沒有 client_secret
func TestLoad_OIDC_ClientSecretOptional(t *testing.T) {
t.Setenv("VISIONA_OIDC_ISSUER_URL", "https://stage-9527.innovedus.com:7850/")
t.Setenv("VISIONA_OIDC_CLIENT_ID", "b8093fea1a504a5d8f0e04bee9f78f2e")
t.Setenv("VISIONA_OIDC_CLIENT_SECRET", "") // 故意留空 — public client
t.Setenv("VISIONA_OIDC_REDIRECT_URL", "https://stage-9527.innovedus.com:9527/api/auth/callback")
t.Setenv("VISIONA_FRONTEND_URL", "https://stage-9527.innovedus.com:9527")
t.Setenv("VISIONA_SESSION_SECRET", "32-byte-or-longer-random-secret-aaaa")
cfg := Load()
assert.Empty(t, cfg.OIDC.ClientSecret, "public client modeClientSecret 應為空字串")
assert.NoError(t, cfg.Validate(), "ClientSecret 為空不應觸發 MissingEnvError")
}
// TestLoad_OIDC_ServiceClientFieldsA1 預留 client_credentials grant 兩個欄位能正確讀取。
// 測試固定值故意用顯而易見的 fake — 不要貼任何環境的真實 client_id / secret 進測試。
func TestLoad_OIDC_ServiceClientFields(t *testing.T) {
const fakeServiceID = "fake-service-client-id-for-test"
const fakeServiceSecret = "fake-service-client-secret-for-test"
t.Setenv("VISIONA_OIDC_SERVICE_CLIENT_ID", fakeServiceID)
t.Setenv("VISIONA_OIDC_SERVICE_CLIENT_SECRET", fakeServiceSecret)
cfg := Load()
assert.Equal(t, fakeServiceID, cfg.OIDC.ServiceClientID)
assert.Equal(t, fakeServiceSecret, cfg.OIDC.ServiceClientSecret)
}
// TestLoad_OIDCAllSet 驗證 OIDC env vars 設定後能正確讀取。
func TestLoad_OIDCAllSet(t *testing.T) {
t.Setenv("VISIONA_OIDC_ISSUER_URL", "http://localhost:5050")
t.Setenv("VISIONA_OIDC_CLIENT_ID", "visionA")
t.Setenv("VISIONA_OIDC_CLIENT_SECRET", "secret")
t.Setenv("VISIONA_OIDC_REDIRECT_URL", "http://localhost:3721/api/auth/callback")
t.Setenv("VISIONA_FRONTEND_URL", "http://localhost:3000")
t.Setenv("VISIONA_SESSION_SECRET", "32-byte-or-longer-random-secret-aaaa")
t.Setenv("VISIONA_SESSION_COOKIE_SECURE", "true")
t.Setenv("VISIONA_SESSION_ABSOLUTE_TTL", "72h")
t.Setenv("VISIONA_SESSION_IDLE_TTL", "12h")
cfg := Load()
assert.Equal(t, "http://localhost:5050", cfg.OIDC.IssuerURL)
assert.Equal(t, "visionA", cfg.OIDC.ClientID)
assert.Equal(t, "secret", cfg.OIDC.ClientSecret)
assert.Equal(t, "http://localhost:3721/api/auth/callback", cfg.OIDC.RedirectURL)
assert.Equal(t, "http://localhost:3000", cfg.OIDC.PostLoginURL)
assert.Equal(t, "32-byte-or-longer-random-secret-aaaa", cfg.UserSession.Secret)
assert.True(t, cfg.UserSession.CookieSecure)
assert.Equal(t, 72*time.Hour, cfg.UserSession.AbsoluteTTL)
assert.Equal(t, 12*time.Hour, cfg.UserSession.IdleTTL)
}
// TestConfig_Validate_MissingFields 驗證 OIDC 必填欄位缺失時回 MissingEnvError。
//
// A12026-05-01ClientSecret 改為選填,已從必填清單移除;剩 5 項必填。
func TestConfig_Validate_MissingFields(t *testing.T) {
cfg := &Config{} // 全部欄位 zero value
err := cfg.Validate()
require.Error(t, err)
var missErr *MissingEnvError
require.ErrorAs(t, err, &missErr, "錯誤型別應可被 errors.As 解出")
// 應列出 5 個必填欄位(不含 ClientSecret
assert.ElementsMatch(t, []string{
"VISIONA_OIDC_ISSUER_URL",
"VISIONA_OIDC_CLIENT_ID",
"VISIONA_OIDC_REDIRECT_URL",
"VISIONA_FRONTEND_URL",
"VISIONA_SESSION_SECRET",
}, missErr.Vars)
assert.NotContains(t, missErr.Vars, "VISIONA_OIDC_CLIENT_SECRET",
"A1ClientSecret 為選填,不應出現在必填缺失清單")
}
// TestValidate_ConfidentialClient完整 confidential client含 ClientSecret能通過 Validate。
func TestValidate_ConfidentialClient(t *testing.T) {
cfg := &Config{
OIDC: OIDCConfig{
IssuerURL: "http://localhost:5050",
ClientID: "visionA",
ClientSecret: "secret", // 有值 → confidential mode
RedirectURL: "http://localhost:3721/api/auth/callback",
PostLoginURL: "http://localhost:3000",
},
UserSession: UserSessionConfig{Secret: "session-secret-32-bytes-aaaaaaaaaaaa"},
}
assert.NoError(t, cfg.Validate())
}
// TestValidate_PKCEOnlyPublicClientA1 — 只給 ClientID 沒給 Secret 也能通過 Validate。
//
// 對應 Stage 部署的真實情境MC 配給 visionA 的 client `b8093fea1a504a5d8f0e04bee9f78f2e`
// 是 public client沒有 client_secret靠 PKCE 防 code interception。
func TestValidate_PKCEOnlyPublicClient(t *testing.T) {
cfg := &Config{
OIDC: OIDCConfig{
IssuerURL: "https://stage-9527.innovedus.com:7850/",
ClientID: "b8093fea1a504a5d8f0e04bee9f78f2e",
// ClientSecret 留空 — public PKCE-only client
RedirectURL: "https://stage-9527.innovedus.com:9527/api/auth/callback",
PostLoginURL: "https://stage-9527.innovedus.com:9527",
},
UserSession: UserSessionConfig{Secret: "session-secret-32-bytes-aaaaaaaaaaaa"},
}
assert.NoError(t, cfg.Validate(),
"A1public PKCE-only clientClientSecret 留空)應通過 Validate")
}
// TestValidate_ServiceClientFieldsNotCheckedA1 — ServiceClientID/Secret 留空不影響 Validate。
//
// 兩個欄位是 client_credentials grant 預留鉤子A1 階段不啟用、不檢查。
func TestValidate_ServiceClientFieldsNotChecked(t *testing.T) {
cfg := &Config{
OIDC: OIDCConfig{
IssuerURL: "http://localhost:5050",
ClientID: "visionA",
RedirectURL: "http://localhost:3721/api/auth/callback",
PostLoginURL: "http://localhost:3000",
// 兩個 Service* 都留空 — 預期通過
},
UserSession: UserSessionConfig{Secret: "session-secret-32-bytes-aaaaaaaaaaaa"},
}
assert.NoError(t, cfg.Validate())
}
// TestLoad_CORSAllowedOrigins 驗證 VISIONA_CORS_ALLOWED_ORIGINS 的逗號分隔解析。
// 空字串 / 純分隔字元 → fallback 到 nil交由 api.Deps.validate 塞預設)。
func TestLoad_CORSAllowedOrigins(t *testing.T) {
// 未設 → nil
t.Setenv("VISIONA_CORS_ALLOWED_ORIGINS", "")
cfg := Load()
assert.Nil(t, cfg.CORS.AllowedOrigins)
// 單一 origin
t.Setenv("VISIONA_CORS_ALLOWED_ORIGINS", "http://localhost:3000")
cfg = Load()
assert.Equal(t, []string{"http://localhost:3000"}, cfg.CORS.AllowedOrigins)
// 多個 origin + trim space
t.Setenv("VISIONA_CORS_ALLOWED_ORIGINS", "http://a.com, http://b.com ,http://c.com")
cfg = Load()
assert.Equal(t, []string{"http://a.com", "http://b.com", "http://c.com"}, cfg.CORS.AllowedOrigins)
// 只有分隔字元 → fallback過濾後 len == 0
t.Setenv("VISIONA_CORS_ALLOWED_ORIGINS", " , ,")
cfg = Load()
assert.Nil(t, cfg.CORS.AllowedOrigins)
}
// TestLoad_ConversionDefaults 驗證 Phase 0.8 / 0.8b conversion 欄位的預設行為。
//
// 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015留空時 Enabled() 為 false
// 5 個 endpoint 不會 wiremain.go 在 wire 階段會跳過)。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已從 ConversionConfig 移除
// MC 認證鏈與 delegated download token 機制不存在了);本 test 不再驗這兩欄位。
func TestLoad_ConversionDefaults(t *testing.T) {
for _, k := range []string{
"VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL",
"VISIONA_CONVERTER_API_KEY", "VISIONA_FAA_API_KEY",
"VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
} {
t.Setenv(k, "")
}
cfg := Load()
assert.Empty(t, cfg.Conversion.ConverterBaseURL)
assert.Empty(t, cfg.Conversion.FAABaseURL)
assert.Empty(t, cfg.Conversion.ConverterAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Empty(t, cfg.Conversion.FAAAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Equal(t, 500, cfg.Conversion.MaxModelSizeMB, "預設 500 MB與 converter 對齊)")
assert.False(t, cfg.Conversion.Enabled(), "全空 → 不啟用")
}
// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯Phase 0.8b 修訂)。
//
// Phase 0.8b 變更4 個欄位Converter URL / FAA URL / Converter API key / FAA API key
// 全部非空才視為啟用;任一缺即 disable。
func TestLoad_ConversionEnabled(t *testing.T) {
cases := []struct {
name string
converterURL string
faaURL string
converterKey string
faaKey string
wantEnabled bool
}{
{"all_set_enables",
"http://converter:9501", "http://faa:5081",
"converter-key-32-bytes-hex-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"faa-key-32-bytes-hex-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
true},
{"missing_converter_url_disabled",
"", "http://faa:5081",
"converter-key", "faa-key",
false},
{"missing_faa_url_disabled",
"http://converter:9501", "",
"converter-key", "faa-key",
false},
{"missing_converter_key_disabled",
"http://converter:9501", "http://faa:5081",
"", "faa-key",
false},
{"missing_faa_key_disabled",
"http://converter:9501", "http://faa:5081",
"converter-key", "",
false},
{"all_empty_disabled",
"", "", "", "",
false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converterURL)
t.Setenv("VISIONA_FAA_BASE_URL", tc.faaURL)
t.Setenv("VISIONA_CONVERTER_API_KEY", tc.converterKey)
t.Setenv("VISIONA_FAA_API_KEY", tc.faaKey)
cfg := Load()
assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled())
})
}
}
// TestLoad_ConversionAllSet 驗證 Phase 0.8b 所有欄位設定後正確讀取。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已移除,本 test
// 不再驗這兩欄位(對應 env 也不再讀取)。
func TestLoad_ConversionAllSet(t *testing.T) {
const fakeConverterKey = "fake-converter-api-key-for-test-do-not-use-in-prod"
const fakeFAAKey = "fake-faa-api-key-for-test-do-not-use-in-prod"
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081")
t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey)
t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey)
t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300")
cfg := Load()
assert.Equal(t, "http://192.168.0.130:9501", cfg.Conversion.ConverterBaseURL)
assert.Equal(t, "http://192.168.0.130:5081", cfg.Conversion.FAABaseURL)
assert.Equal(t, fakeConverterKey, cfg.Conversion.ConverterAPIKey)
assert.Equal(t, fakeFAAKey, cfg.Conversion.FAAAPIKey)
assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB)
assert.True(t, cfg.Conversion.Enabled())
}
// TestLoad_ConversionAPIKeysOnlyPhase 0.8b T5 — 4 個必要欄位齊全即 Enabled。
//
// 此 test 在 T1-T4 期間驗證「廢棄 env 不設也能 Enabled」T5 完成後該邏輯
// 由本 test 與 TestLoad_ConversionAllSet 共同覆蓋(因為廢棄 env 已徹底移除)。
func TestLoad_ConversionAPIKeysOnly(t *testing.T) {
const fakeConverterKey = "fake-converter-api-key-only-test"
const fakeFAAKey = "fake-faa-api-key-only-test"
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081")
t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey)
t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey)
cfg := Load()
assert.True(t, cfg.Conversion.Enabled(),
"Phase 0.8b T54 個必要欄位齊全即 Enabled")
}