Files
alterminal/application_test.go
T

292 lines
10 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 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)
}
}