package config import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestLoad_Defaults(t *testing.T) { // Arrange:清掉所有相關 env(t.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-tool(B4)。 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() 會回 MissingEnvError,main.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_ClientSecretOptional:A1(2026-05-01)— 缺 ClientSecret 不再回 MissingEnvError。 // // 模擬 Stage 用的 public PKCE-only client(MC 給的 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 mode:ClientSecret 應為空字串") assert.NoError(t, cfg.Validate(), "ClientSecret 為空不應觸發 MissingEnvError") } // TestLoad_OIDC_ServiceClientFields:A1 預留 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。 // // A1(2026-05-01):ClientSecret 改為選填,已從必填清單移除;剩 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", "A1:ClientSecret 為選填,不應出現在必填缺失清單") } // 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_PKCEOnlyPublicClient:A1 — 只給 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(), "A1:public PKCE-only client(ClientSecret 留空)應通過 Validate") } // TestValidate_ServiceClientFieldsNotChecked:A1 — 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 不會 wire(main.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.8b:API key 預設留空") assert.Empty(t, cfg.Conversion.FAAAPIKey, "Phase 0.8b:API 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_ConversionAPIKeysOnly:Phase 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 T5:4 個必要欄位齊全即 Enabled") }