forked from alterminal/alterminal
292 lines
10 KiB
Go
292 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"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 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,連不上時跳過 ---
|
||
|
||
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 := getApplicationByClientID(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 := getApplicationByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err)
|
||
}
|
||
}
|