Files
2026-10-03 12:37:38 +08:00

482 lines
18 KiB
Go
Raw Permalink 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 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)
}
})
}