jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

268 lines
11 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)
}