package application import ( "errors" "fmt" "os" "reflect" "strings" "testing" "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestNewApplicationConfidential(t *testing.T) { a, secret, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/oidc/callback"}, []GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile offline_access") if err != nil { t.Fatal("NewApplication: ", err) } if len(a.ClientID) != 22 { t.Errorf("ClientID 應為 16 bytes 亂數的 base64url(22 字元),得到 %d 字元", len(a.ClientID)) } if a.IsPublic() { t.Error("機密式 Client 的 IsPublic() 應為 false") } if len(secret) != 43 { t.Errorf("client secret 應為 32 bytes 亂數的 base64url(43 字元),得到 %d 字元", len(secret)) } if !strings.HasPrefix(a.ClientSecretHash, "$argon2id$") { t.Errorf("ClientSecretHash 應為 argon2id PHC 字串,得到 %q", a.ClientSecretHash) } if strings.Contains(a.ClientSecretHash, secret) { t.Error("client secret 不應以明文出現在雜湊欄位") } if !a.CheckSecret(secret) { t.Error("正確的 client secret 應驗證成功") } if a.CheckSecret("wrong-secret") { t.Error("錯誤的 client secret 不應驗證成功") } if err := a.Validate(); err != nil { t.Error("新建立的註冊資料應通過驗證: ", err) } } func TestNewApplicationPublic(t *testing.T) { a, secret, err := NewApplication("行動應用", ClientPublic, []string{"com.example.app:/oidc/callback"}, nil, "") if err != nil { t.Fatal("NewApplication: ", err) } if !a.IsPublic() { t.Error("公開式 Client 的 IsPublic() 應為 true") } if secret != "" { t.Errorf("公開式 Client 不應簽發 client secret,得到 %q", secret) } if a.ClientSecretHash != "" { t.Errorf("公開式 Client 不應存 secret 雜湊,得到 %q", a.ClientSecretHash) } for _, s := range []string{"", "anything"} { if a.CheckSecret(s) { t.Errorf("公開式 Client 的 CheckSecret(%q) 應為 false", s) } } if _, err := a.GenerateSecret(); err == nil { t.Error("公開式 Client 呼叫 GenerateSecret 應回傳錯誤") } } func TestNewApplicationDefaults(t *testing.T) { a, _, err := NewApplication(" 示範應用 ", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } if a.Name != "示範應用" { t.Errorf("名稱應去除首尾空白,得到 %q", a.Name) } if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) { t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes) } if a.Scope != defaultScope { t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope) } } func TestNewApplicationClientIDUnique(t *testing.T) { a, _, err := NewApplication("A", ClientPublic, []string{"https://a.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } b, _, err := NewApplication("B", ClientPublic, []string{"https://b.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } if a.ClientID == b.ClientID { t.Error("兩次建立的 client_id 不應相同") } } func TestNewApplicationClientCredentialsWithoutRedirectURIs(t *testing.T) { // 僅 client_credentials 的機器對機器應用不經過瀏覽器,無須 redirect URI。 a, secret, err := NewApplication("批次服務", ClientConfidential, nil, []GrantType{GrantClientCredentials}, "openid") if err != nil { t.Fatal("僅 client_credentials 註冊不應要求 redirect URI: ", err) } if secret == "" || !a.CheckSecret(secret) { t.Error("機密式 Client 應簽發可驗證的 client secret") } } func TestNewApplicationInvalid(t *testing.T) { cases := []struct { desc string name string typ ClientType uris []string grants []GrantType scope string want string // 錯誤訊息應包含的子字串 }{ {"空名稱", "", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "", "名稱"}, {"不支援的類型", "A", "webapp", []string{"https://a.example.com/cb"}, nil, "", "類型"}, {"不支援的 grant type", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{"implicit"}, "", "grant type"}, {"公開式使用 client_credentials", "A", ClientPublic, []string{"https://a.example.com/cb"}, []GrantType{GrantClientCredentials}, "", "client_credentials"}, {"refresh_token 未伴隨授權碼", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantRefreshToken}, "", "refresh_token"}, {"授權碼流程無 redirect URI", "A", ClientConfidential, nil, []GrantType{GrantAuthorizationCode}, "", "redirect URI"}, {"相對 URI", "A", ClientConfidential, []string{"app.example.com/cb"}, nil, "", "絕對 URI"}, {"非 loopback 的 http", "A", ClientConfidential, []string{"http://app.example.com/cb"}, nil, "", "loopback"}, {"含 fragment", "A", ClientConfidential, []string{"https://app.example.com/cb#frag"}, nil, "", "fragment"}, {"含 userinfo", "A", ClientConfidential, []string{"https://user@app.example.com/cb"}, nil, "", "userinfo"}, {"缺少 host", "A", ClientConfidential, []string{"https:///cb"}, nil, "", "host"}, {"不支援的 scope", "A", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "openid admin", "scope"}, {"offline_access 無 refresh_token grant", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantAuthorizationCode}, "openid offline_access", "offline_access"}, } for _, c := range cases { _, _, err := NewApplication(c.name, c.typ, c.uris, c.grants, c.scope) if err == nil { t.Errorf("%s:應回傳錯誤", c.desc) continue } if !strings.Contains(err.Error(), c.want) { t.Errorf("%s:錯誤訊息 %q 應包含 %q", c.desc, err.Error(), c.want) } } } func TestApplicationSecretRotation(t *testing.T) { a, secret1, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } secret2, err := a.GenerateSecret() if err != nil { t.Fatal("GenerateSecret: ", err) } if secret1 == secret2 { t.Error("輪替後的 client secret 不應與舊值相同") } if a.CheckSecret(secret1) { t.Error("輪替後舊 client secret 應立即失效") } if !a.CheckSecret(secret2) { t.Error("新 client secret 應驗證成功") } } func TestApplicationUpdate(t *testing.T) { a, secret, err := NewApplication("舊名稱", ClientConfidential, []string{"https://old.example.com/cb"}, []GrantType{GrantAuthorizationCode}, "openid") if err != nil { t.Fatal(err) } oldID := a.ClientID if err := a.Update(" 新名稱 ", ClientConfidential, []string{"https://new.example.com/cb", "https://alt.example.com/cb"}, []GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile offline_access"); err != nil { t.Fatal("Update: ", err) } if a.Name != "新名稱" { t.Errorf("名稱應更新並去除首尾空白,得到 %q", a.Name) } if a.ClientID != oldID { t.Errorf("client_id 不可因更新而變更:%q → %q", oldID, a.ClientID) } wantURIs := RedirectURIs{"https://new.example.com/cb", "https://alt.example.com/cb"} if !reflect.DeepEqual(a.RedirectURIs, wantURIs) { t.Errorf("RedirectURIs = %v, want %v", a.RedirectURIs, wantURIs) } if !a.GrantTypes.Contains(GrantRefreshToken) { t.Errorf("GrantTypes 應更新,得到 %v", a.GrantTypes) } if !a.CheckSecret(secret) { t.Error("更新註冊內容不應影響既有 client secret") } } func TestApplicationUpdateDefaultsAndInvalid(t *testing.T) { a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } // grant type 與 scope 留空時沿用註冊時的預設行為。 if err := a.Update("更新後", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil { t.Fatal("Update: ", err) } if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) { t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes) } if a.Scope != defaultScope { t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope) } if err := a.Update("示範應用", ClientConfidential, []string{"http://a.example.com/cb"}, nil, ""); err == nil { t.Error("非法 redirect URI 的 Update 應回傳錯誤") } } func TestApplicationUpdateToPublicClearsSecret(t *testing.T) { a, secret, err := NewApplication("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } if err := a.Update("後端服務", ClientPublic, []string{"https://a.example.com/cb"}, nil, ""); err != nil { t.Fatal("Update: ", err) } if !a.IsPublic() { t.Error("更新為公開式後 IsPublic() 應為 true") } if a.ClientSecretHash != "" { t.Errorf("改為公開式應清除 secret 雜湊,得到 %q", a.ClientSecretHash) } if a.CheckSecret(secret) { t.Error("改為公開式後舊 client secret 應失效") } // 改回機密式:雜湊不應復活,須以 GenerateSecret 重新輪替。 if err := a.Update("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil { t.Fatal("Update 回機密式: ", err) } if a.ClientSecretHash != "" || a.CheckSecret(secret) { t.Error("由公開式改回機密式不應復活舊 secret,須重新輪替") } newSecret, err := a.GenerateSecret() if err != nil || !a.CheckSecret(newSecret) { t.Error("改回機密式後應可重新輪替取得有效 secret") } } func TestApplicationCheckSecretMalformedHash(t *testing.T) { for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} { a := &Application{Type: ClientConfidential, ClientSecretHash: hash} if a.CheckSecret("whatever") { t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash) } } } func TestRedirectURIsContains(t *testing.T) { uris := RedirectURIs{"https://app.example.com/cb", "com.example.app:/cb"} for _, uri := range []string{"https://app.example.com/cb", "com.example.app:/cb"} { if !uris.Contains(uri) { t.Errorf("已註冊的 %q 應比對成功", uri) } } for _, uri := range []string{ "https://app.example.com/cb?x=1", // 未註冊的 query "https://app.example.com/cb/", // 結尾斜線不同即不同字串 "https://evil.example.com/cb", "HTTPS://APP.EXAMPLE.COM/cb", "", } { if uris.Contains(uri) { t.Errorf("未註冊的 %q 不應比對成功(須完全相同)", uri) } } } func TestGrantTypesContains(t *testing.T) { gts := GrantTypes{GrantAuthorizationCode, GrantRefreshToken} if !gts.Contains(GrantAuthorizationCode) || !gts.Contains(GrantRefreshToken) { t.Error("已啟用的 grant type 應比對成功") } if gts.Contains(GrantClientCredentials) { t.Error("未啟用的 grant type 不應比對成功") } } func TestValidateRedirectURI(t *testing.T) { valid := []string{ "https://app.example.com/oidc/callback", "https://app.example.com", // 無 path "https://app.example.com:8443/cb", // 帶 port "http://localhost:8080/cb", // loopback 例外 "http://127.0.0.1/cb", // loopback IP "http://[::1]:8080/cb", // IPv6 loopback "com.example.app:/oidc/callback", // 原生應用自訂 scheme "urn:ietf:wg:oauth:2.0:oob", // opaque URI } for _, uri := range valid { if err := validateRedirectURI(uri); err != nil { t.Errorf("redirect URI %q 應有效,得到錯誤:%v", uri, err) } } invalid := []string{ "", // 空字串 "app.example.com/cb", // 相對 URI(無 scheme) "/cb", // path only "https://app.example.com/cb#frag", // fragment(RFC 6749 §3.1.2 禁止) "https://user@app.example.com/cb", // userinfo "https:///cb", // 無 host "http://app.example.com/cb", // 非 loopback 的 http "myapp:", // 僅有 scheme } for _, uri := range invalid { if err := validateRedirectURI(uri); err == nil { t.Errorf("redirect URI %q 應無效", uri) } } } // --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- // envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯; // 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。 func envOrTest(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫 // (與開發資料庫 alterminal 隔離),僅遷移與清空 applications 資料表 // (本套件測試不涉及其他模型)。 func newTestDB(t *testing.T) *gorm.DB { t.Helper() admin, err := gorm.Open(postgres.Open(fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC", envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"), envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"), )), &gorm.Config{}) if err != nil { t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err) } if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") { t.Skipf("無法建立測試資料庫:%v", err) } db, err := gorm.Open(postgres.Open(fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=alterminal_test sslmode=disable TimeZone=UTC", envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"), envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"), )), &gorm.Config{TranslateError: true}) if err != nil { t.Skipf("連線測試資料庫失敗:%v", err) } t.Cleanup(func() { if sqlDB, err := db.DB(); err == nil { sqlDB.Close() } }) if err := db.AutoMigrate(&Application{}); err != nil { t.Fatalf("遷移測試資料表失敗:%v", err) } if err := db.Exec("TRUNCATE applications RESTART IDENTITY CASCADE").Error; err != nil { t.Fatalf("清空測試資料失敗:%v", err) } return db } func TestApplicationPersistence(t *testing.T) { db := newTestDB(t) a, secret, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/oidc/callback", "https://app.example.com/other"}, []GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile email offline_access") if err != nil { t.Fatal(err) } if err := db.Create(a).Error; err != nil { t.Fatalf("建立應用程式失敗:%v", err) } got, err := GetByClientID(db, a.ClientID) if err != nil { t.Fatalf("以 client_id 查詢失敗:%v", err) } if got.ID == 0 || got.Name != a.Name || got.Type != a.Type || got.Scope != a.Scope { t.Errorf("基本欄位往返不一致:got %+v", got) } if !reflect.DeepEqual(got.RedirectURIs, a.RedirectURIs) { t.Errorf("RedirectURIs 往返不一致:got %v want %v", got.RedirectURIs, a.RedirectURIs) } if !reflect.DeepEqual(got.GrantTypes, a.GrantTypes) { t.Errorf("GrantTypes 往返不一致:got %v want %v", got.GrantTypes, a.GrantTypes) } if !got.CheckSecret(secret) { t.Error("資料庫往返後 client secret 應仍可驗證") } if !got.RedirectURIs.Contains("https://app.example.com/oidc/callback") { t.Error("往返後 redirect URI 比對應仍可用") } dup, _, err := NewApplication("重複測試", ClientPublic, []string{"https://dup.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } dup.ClientID = a.ClientID if err := db.Create(dup).Error; !errors.Is(err, gorm.ErrDuplicatedKey) { t.Errorf("重複的 client_id 應回 gorm.ErrDuplicatedKey,得到 %v", err) } if _, err := GetByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err) } } func TestPostLogoutRedirectURIs(t *testing.T) { t.Run("註冊並精確比對", func(t *testing.T) { a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "", "https://app.example.com/logged-out") if err != nil { t.Fatal(err) } if !a.PostLogoutRedirectURIs.Contains("https://app.example.com/logged-out") { t.Error("註冊的登出後返回 URI 應精確比對成功") } // 與 redirect URI 不互通(RP-Initiated Logout 1.0 §3:僅比對 // post_logout_redirect_uris 註冊值)。 if a.PostLogoutRedirectURIs.Contains("https://app.example.com/cb") { t.Error("redirect URI 不應混入登出後返回 URI 的比對") } }) t.Run("未註冊時為非 nil 空清單", func(t *testing.T) { a, _, err := NewApplication("無返回", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "") if err != nil { t.Fatal(err) } if a.PostLogoutRedirectURIs == nil || len(a.PostLogoutRedirectURIs) != 0 { t.Errorf("未指定應為非 nil 空清單(序列化為 []),得到 %v", a.PostLogoutRedirectURIs) } }) t.Run("格式驗證與 redirect URI 同規則", func(t *testing.T) { if _, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "", "http://app.example.com/logged-out"); err == nil { t.Error("非 loopback 的 http 登出後返回 URI 應被拒") } if _, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "", "https://app.example.com/logged-out#frag"); err == nil { t.Error("含 fragment 的登出後返回 URI 應被拒") } }) t.Run("Update 可清空", func(t *testing.T) { a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, "", "https://app.example.com/logged-out") if err != nil { t.Fatal(err) } if err := a.Update("示範應用", ClientConfidential, []string{"https://app.example.com/cb"}, nil, ""); err != nil { t.Fatal(err) } if len(a.PostLogoutRedirectURIs) != 0 { t.Errorf("Update 未指定時應清空,得到 %v", a.PostLogoutRedirectURIs) } }) }