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