jim800121chen 9e29ebf767 feat(visionA-backend): Phase 0.8b v0.6 T4 — config FAA 欄位砍 + .env 清 + i18n/godoc polish
對齊 ADR-016 / conversion.md v0.6.1 §3.1:visionA 端不再需要 FAA 設定(v0.5 T1 加的 FAAAPIKey/FAABaseURL 撤回)。

config 砍除:
- internal/config/config.go: ConversionConfig.FAABaseURL + FAAAPIKey 兩欄位
- internal/config/load.go: VISIONA_FAA_BASE_URL + VISIONA_FAA_API_KEY 兩 env 讀取
- Enabled() 簡化為「ConverterBaseURL + ConverterAPIKey 兩個非空」
- internal/config/load_test.go: TestLoad_ConversionEnabled 從 6 case 簡化為 4 case (all_set / missing_converter_url / missing_converter_key / all_empty)

.env*.example 對齊(3 個檔):
- visionA-backend/.env.example: 砍 2 個 FAA env row + 註解;header 改「2 欄位啟用」
- .env.stage.example: 同上;VISIONA_CONVERTER_API_KEY 保留 CHANGE_ME_OPENSSL_RAND_HEX_32 placeholder
- .env.dev.example: 註解區塊統一對齊

T3 review polish:
- m-2 internal/api/conversion.go: i18n message map 砍 4 個 dead case (download_token_failed / mc_token_unavailable / idp_misconfigured / idp_unavailable) — 對應 v0.5 mc_token_client 撤回時砍的 sentinel;落入 default「內部錯誤」、行為不變
- m-3 internal/conversion/util.go: hashObjectKey godoc 補「設計約束(重要)」段 + 3 條「不應做的事」(不出現在 response body/header / 不組 URL / 不寫進 user-facing 錯誤訊息)— 明示用途限定於 slog 欄位內、避免 misuse vector
- cmd/api-server/main.go: godoc 對齊 T4 完成狀態

驗證:
- B 層 verification 主動跑(T3 reviewer 接受暫緩、backend 主動跑避免 reviewer 二次要求):
  * 跨檔 grep: production code 0 functional 命中(殘留全是註解 audit trail / test fixture name)
  * 17 packages race -count=3 全綠
  * 3 個 .env 環境一致性驗證
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t4-review) 通過(0 Critical / 0 Major / 2 Minor / 4 Suggestion)

v0.6 對齊改造事實上完工:
- T1 ConverterClient.GetResult method
- T2 flow.go DownloadStream/PromoteToModels 改用 GetResult + e2e endpoint
- T3 faa_client 整檔砍 + ErrFAA* sentinel 清 + s-3/s-4/s-5 必補 + mockFAA regression-only
- T4 config FAA 欄位砍 + .env 清 + i18n/godoc polish

main.go startup log 已是「converter_api_key_set only」、無 FAA 殘留 / 無 tenant_id(T2-T3 已處理)。e2e regression 防護由 mockFAA negative assertion 守住(T3)。

下一步:
- visionA backend 端 ADR-016 對齊完工,等使用者跨 repo 加 converter GET /api/v1/jobs/{id}/result endpoint
- stage redeploy + e2e 完整測試

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

371 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 欄位的預設行為。
//
// 對齊 docs/autoflow/04-architecture/conversion.md §3 + ADR-015 + ADR-016留空時
// Enabled() 為 false5 個 endpoint 不會 wiremain.go 在 wire 階段會跳過)。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已從 ConversionConfig 移除
// MC 認證鏈與 delegated download token 機制不存在了);本 test 不再驗這兩欄位。
//
// Phase 0.8b v0.6 T4原 FAA 相關欄位 FAABaseURL / FAAAPIKey 已移除ADR-016 撤回
// v0.5 設計缺口visionA 端不再直接呼叫 FAA本 test 不再驗這兩欄位,對應的
// VISIONA_FAA_BASE_URL / VISIONA_FAA_API_KEY env 也不再 clear。
func TestLoad_ConversionDefaults(t *testing.T) {
for _, k := range []string{
"VISIONA_CONVERTER_BASE_URL",
"VISIONA_CONVERTER_API_KEY",
"VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
} {
t.Setenv(k, "")
}
cfg := Load()
assert.Empty(t, cfg.Conversion.ConverterBaseURL)
assert.Empty(t, cfg.Conversion.ConverterAPIKey, "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 v0.6 T4 簡化ADR-016visionA 端撤回對 FAA 的直接呼叫後,
// 只剩 ConverterBaseURL + ConverterAPIKey 兩個欄位需非空;任一缺即 disable。
func TestLoad_ConversionEnabled(t *testing.T) {
cases := []struct {
name string
converterURL string
converterKey string
wantEnabled bool
}{
{"all_set_enables",
"http://converter:9501",
"converter-key-32-bytes-hex-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
true},
{"missing_converter_url_disabled",
"",
"converter-key",
false},
{"missing_converter_key_disabled",
"http://converter:9501",
"",
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_CONVERTER_API_KEY", tc.converterKey)
cfg := Load()
assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled())
})
}
}
// TestLoad_ConversionAllSet 驗證 Phase 0.8b 所有欄位設定後正確讀取。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已移除,本 test
// 不再驗這兩欄位(對應 env 也不再讀取)。
//
// Phase 0.8b v0.6 T4原 FAA 相關欄位 FAABaseURL / FAAAPIKey 已移除ADR-016
// 本 test 不再驗這兩欄位、對應 env 也不再讀取。
func TestLoad_ConversionAllSet(t *testing.T) {
const fakeConverterKey = "fake-converter-api-key-for-test-do-not-use-in-prod"
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey)
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, fakeConverterKey, cfg.Conversion.ConverterAPIKey)
assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB)
assert.True(t, cfg.Conversion.Enabled())
}
// TestLoad_ConversionAPIKeysOnlyPhase 0.8b v0.6 T4 — 2 個必要欄位齊全即 Enabled。
//
// 本 test 與 TestLoad_ConversionAllSet 共同覆蓋 Enabled() 的最小啟用條件:
// 只要 ConverterBaseURL + ConverterAPIKey 兩個欄位都非空、即視為啟用,
// MaxModelSizeMB 不影響啟用判定。
func TestLoad_ConversionAPIKeysOnly(t *testing.T) {
const fakeConverterKey = "fake-converter-api-key-only-test"
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey)
cfg := Load()
assert.True(t, cfg.Conversion.Enabled(),
"Phase 0.8b v0.6 T42 個必要欄位齊全即 Enabled")
}