Files
alterminal/internal/admin/adminapplications_test.go
T
2026-10-03 10:44:29 +08:00

828 lines
31 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 admin
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"regexp"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/testdb"
)
// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
// 因此 handler 可傳入 nil db。
func TestAdminApplicationsHandlersRequireLogin(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"GET 列表": ApplicationsPageHandler(nil),
"GET 註冊頁": ApplicationNewPageHandler(nil),
"POST 註冊": ApplicationsCreateHandler(nil),
"GET 編輯頁": ApplicationEditPageHandler(nil),
"POST 更新": ApplicationUpdateHandler(nil),
"POST 輪替": ApplicationsRotateSecretHandler(nil),
"POST 刪除": ApplicationsDeleteHandler(nil),
}
for name, h := range handlers {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/admin/applications", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
})
}
}
func TestNewAdminApplicationRows(t *testing.T) {
apps := []application.Application{
{
ID: 1, ClientID: "cid-a", Name: "官方網站", Type: application.ClientConfidential,
RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"},
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken},
Scope: "openid offline_access", CreatedAt: time.Now(),
},
{
ID: 2, ClientID: "cid-b", Name: "行動 App", Type: application.ClientPublic,
RedirectURIs: application.RedirectURIs{"com.example.app:/cb"},
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode},
CreatedAt: time.Now(),
},
}
rows := newAdminApplicationRows(apps)
if len(rows) != 2 {
t.Fatalf("rows = %d 筆, want 2", len(rows))
}
if rows[0].RedirectURIs != "https://a.example.com/cb\nhttps://a.example.com/alt" {
t.Errorf("RedirectURIs 應以換行分隔,得到 %q", rows[0].RedirectURIs)
}
if rows[0].GrantTypes != "authorization_code、refresh_token" {
t.Errorf("GrantTypes 應以頓號分隔,得到 %q", rows[0].GrantTypes)
}
if rows[0].CreatedAt == "" {
t.Error("CreatedAt 應格式化為本地時間字串")
}
if !rows[0].Confidential {
t.Error("機密式應標記 Confidential(顯示輪替表單)")
}
if rows[1].Confidential || rows[1].Type != "public" {
t.Errorf("公開式 row 不應標記 Confidential,Type = %q", rows[1].Type)
}
if rows[1].GrantTypes != "authorization_code" {
t.Errorf("單一 grant type 不應有分隔符,得到 %q", rows[1].GrantTypes)
}
}
func TestNewApplicationFormDefaults(t *testing.T) {
f := newApplicationForm()
if f.Type != string(application.ClientConfidential) {
t.Errorf("預設類型應為 confidential,得到 %q", f.Type)
}
if !f.GrantAuthCode || f.GrantRefresh || f.GrantClientCred {
t.Error("預設應僅勾選 authorization_code")
}
}
func TestApplicationFormFromPost(t *testing.T) {
vals := url.Values{
"name": {"示範應用"},
"type": {"public"},
"redirect_uris": {"https://a.example.com/cb\r\ncom.example.app:/cb\r\n\r\n https://b.example.com/cb \n"},
"grant_types": {"refresh_token"},
"scope": {"openid"},
}
vals.Add("grant_types", "client_credentials")
vals.Add("grant_types", "implicit") // 未知值應略過
req := httptest.NewRequest(http.MethodPost, "/admin/applications", strings.NewReader(vals.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f := applicationFormFromPost(req)
if f.Name != "示範應用" || f.Type != "public" || f.Scope != "openid" {
t.Errorf("基本欄位還原不符:%+v", f)
}
if !f.GrantRefresh || !f.GrantClientCred || f.GrantAuthCode {
t.Errorf("核取狀態還原不符:%+v", f)
}
wantURIs := []string{"https://a.example.com/cb", "com.example.app:/cb", "https://b.example.com/cb"}
if got := f.redirectURIList(); !reflect.DeepEqual(got, wantURIs) {
t.Errorf("redirectURIList = %v, want %v(每行一個、去空白、略過空行)", got, wantURIs)
}
wantGrants := []application.GrantType{application.GrantRefreshToken, application.GrantClientCredentials}
if got := f.grantTypeList(); !reflect.DeepEqual(got, wantGrants) {
t.Errorf("grantTypeList = %v, want %v", got, wantGrants)
}
}
// 類型選單僅兩值,偽造的值一律回復為 confidential。
func TestApplicationFormFromPostInvalidType(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/admin/applications",
strings.NewReader("name=A&type=webapp"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if f := applicationFormFromPost(req); f.Type != string(application.ClientConfidential) {
t.Errorf("非法類型應回復 confidential,得到 %q", f.Type)
}
}
// applicationFormFromApp:預填既有註冊資料(redirect URI 每行一個)。
func TestApplicationFormFromApp(t *testing.T) {
a := &application.Application{
Name: "官方網站",
Type: application.ClientConfidential,
RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "com.example.app:/cb"},
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken, application.GrantClientCredentials},
Scope: "openid profile offline_access",
}
f := applicationFormFromApp(a)
if f.Name != "官方網站" || f.Type != "confidential" || f.Scope != "openid profile offline_access" {
t.Errorf("基本欄位預填不符:%+v", f)
}
if f.RedirectURIs != "https://a.example.com/cb\ncom.example.app:/cb" {
t.Errorf("RedirectURIs 應以換行分隔預填,得到 %q", f.RedirectURIs)
}
if !f.GrantAuthCode || !f.GrantRefresh || !f.GrantClientCred {
t.Errorf("核取狀態預填不符:%+v", f)
}
// 預填後再以 redirectURIList 解析應還原為原清單(textarea 往返)。
want := []string{"https://a.example.com/cb", "com.example.app:/cb"}
if got := f.redirectURIList(); !reflect.DeepEqual(got, want) {
t.Errorf("redirectURIList = %v, want %v", got, want)
}
}
// renderAdminApplicationsPage 需要資料庫,模板輸出直接以假資料渲染測試。
func TestAdminApplicationsTemplate(t *testing.T) {
data := adminApplicationsPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
Apps: []adminApplicationRow{
{ID: 9, ClientID: "cid-conf", Name: "官方網站", Type: "confidential",
RedirectURIs: "https://app.example.com/cb", GrantTypes: "authorization_code、refresh_token",
Scope: "openid offline_access", CreatedAt: "2026-10-02 12:00:00 +08:00", Confidential: true},
{ID: 5, ClientID: "cid-pub", Name: "行動 App", Type: "public",
RedirectURIs: "com.example.app:/cb", GrantTypes: "authorization_code",
Scope: "openid", CreatedAt: "2026-10-01 12:00:00 +08:00"},
},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
body := rec.Body.String()
for _, want := range []string{
"應用程式管理", // 標題
`href="/admin/applications/new"`, // 註冊新應用程式按鈕(獨立頁)
`href="/admin/applications/9"`, // 編輯連結(機密式)
`href="/admin/applications/5"`, // 編輯連結(公開式)
`value="token-A"`, // CSRF 隱藏欄位
`action="/admin/applications/9/secret"`, // 機密式的輪替表單
`action="/admin/applications/9/delete"`, // 刪除表單
`action="/admin/applications/5/delete"`,
"cid-conf", "cid-pub", // client_id 欄
"機密式", "公開式", // 類型徽章
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁)
`href="/admin/keys"`, // 導覽(金鑰管理)
`href="/login"`, // 導覽(帳號資訊)
`action="/logout"`, // 側欄頁尾登出表單(版面預設)
"alice@example.com",
} {
if !strings.Contains(body, want) {
t.Errorf("應用程式管理頁缺少 %s", want)
}
}
for _, absent := range []string{
"/admin/applications/5/secret", // 公開式無 secret,不應出現輪替表單
"尚無應用程式",
"只顯示這一次", // 未輪替 secret 時不出現明文面板
`name="redirect_uris"`, // 註冊表單已獨立於 /admin/applications/new
`action="/admin/applications"`, // 註冊不再 POST 到列表頁
} {
if strings.Contains(body, absent) {
t.Errorf("頁面不應出現 %s", absent)
}
}
}
// 註冊頁模板:表單欄位與送出目標,導覽同管理頁。
func TestAdminApplicationNewTemplate(t *testing.T) {
data := adminApplicationNewPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
Form: newApplicationForm(),
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
body := rec.Body.String()
for _, want := range []string{
"註冊新應用程式", // 標題
`action="/admin/applications/new"`, // 表單送回本頁
`value="token-N"`, // CSRF 隱藏欄位
`name="name"`,
`name="redirect_uris"`,
`value="authorization_code"`, // grant type 核取方塊(預設勾選)
`checked`, // 預設勾選狀態
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁)
`href="/admin/keys"`,
`href="/login"`,
`action="/logout"`,
"alice@example.com",
} {
if !strings.Contains(body, want) {
t.Errorf("註冊頁缺少 %s", want)
}
}
if strings.Contains(body, "只顯示這一次") {
t.Error("未註冊成功時不應出現明文面板")
}
}
// 註冊機密式成功的一次性明文 client secret 面板(渲染於註冊頁)。
func TestAdminApplicationNewTemplateSecretPanel(t *testing.T) {
data := adminApplicationNewPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
Form: newApplicationForm(),
Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value"},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
body := rec.Body.String()
for _, want := range []string{"已註冊", "只顯示這一次", "cid-conf", "plain-secret-value"} {
if !strings.Contains(body, want) {
t.Errorf("secret 面板缺少 %s", want)
}
}
if strings.Contains(body, "已輪替") {
t.Error("註冊面板不應出現輪替文案")
}
}
// 註冊公開式成功的面板:無明文 secret,改顯示 PKCE 提示。
func TestAdminApplicationNewTemplatePublicPanel(t *testing.T) {
data := adminApplicationNewPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
Form: newApplicationForm(),
Secret: &secretPanel{Name: "行動 App", ClientID: "cid-pub", Public: true},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
body := rec.Body.String()
for _, want := range []string{"行動 App 已註冊", "PKCE", "cid-pub"} {
if !strings.Contains(body, want) {
t.Errorf("公開式面板缺少 %s", want)
}
}
for _, absent := range []string{"只顯示這一次", "client_secret"} {
if strings.Contains(body, absent) {
t.Errorf("公開式面板不應出現 %s", absent)
}
}
}
// 編輯頁模板:唯讀欄位(client_id)、預填表單與送出目標;無一次性面板。
func TestAdminApplicationEditTemplate(t *testing.T) {
data := adminApplicationEditPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-E",
ID: 9, ClientID: "cid-conf", Created: "2026-10-02 12:00:00 +08:00",
Form: applicationFormFromApp(&application.Application{
Name: "官方網站",
Type: application.ClientConfidential,
RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"},
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken},
Scope: "openid offline_access",
}),
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data)
body := rec.Body.String()
for _, want := range []string{
"編輯應用程式", // 標題
`action="/admin/applications/9"`, // 表單送回本頁
`value="token-E"`, // CSRF 隱藏欄位
"cid-conf", // client_id 唯讀顯示
"2026-10-02 12:00:00 +08:00", // 建立時間(html/template 將 + 轉義)
`value="官方網站"`, // 名稱預填
`>https://app.example.com/cb</textarea>`, // redirect URI 預填
`value="openid offline_access"`, // scope 預填
"checked", // 已啟用 grant type 的核取狀態
"儲存變更", // 送出按鈕
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁)
`href="/admin/keys"`,
`href="/login"`,
`action="/logout"`,
"alice@example.com",
} {
if !strings.Contains(body, want) {
t.Errorf("編輯頁缺少 %s", want)
}
}
for _, absent := range []string{
"只顯示這一次", // 編輯無一次性面板
"已儲存變更", // 未帶 ?saved=1 時不出現成功訊息
`action="/admin/applications/new"`, // 不應送回註冊頁
} {
if strings.Contains(body, absent) {
t.Errorf("編輯頁不應出現 %s", absent)
}
}
}
// PRG(?saved=1)後的編輯頁顯示成功訊息。
func TestAdminApplicationEditTemplateSaved(t *testing.T) {
data := adminApplicationEditPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-E",
ID: 9, ClientID: "cid-conf", Success: "已儲存變更",
Form: applicationFormFromApp(&application.Application{
Name: "官方網站", Type: application.ClientConfidential,
RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"},
}),
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data)
if body := rec.Body.String(); !strings.Contains(body, "已儲存變更") {
t.Error("帶 Success 時應顯示成功訊息")
}
}
// 輪替成功的一次性明文面板(渲染於管理列表頁)。
func TestAdminApplicationsTemplateRotatePanel(t *testing.T) {
data := adminApplicationsPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value", Rotated: true},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
body := rec.Body.String()
for _, want := range []string{"已輪替", "只顯示這一次", "cid-conf", "plain-secret-value"} {
if !strings.Contains(body, want) {
t.Errorf("輪替面板缺少 %s", want)
}
}
}
// 無應用程式時顯示空狀態提示(註冊表單已獨立,不再內嵌於列表頁)。
func TestAdminApplicationsTemplateEmpty(t *testing.T) {
data := adminApplicationsPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
body := rec.Body.String()
if !strings.Contains(body, "尚無應用程式") {
t.Error("應顯示空狀態提示")
}
if !strings.Contains(body, `href="/admin/applications/new"`) {
t.Error("空狀態仍應提供前往註冊頁的按鈕")
}
if strings.Contains(body, `name="redirect_uris"`) {
t.Error("列表頁不應內嵌註冊表單")
}
}
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
// secretInBody 從頁面抽出一次性明文 client secret:面板以 <code>/<dd> 包裹
// 43 字元 base64url(CSRF token 在屬性值內、client_id 僅 22 字元,皆不符)。
var secretInBody = regexp.MustCompile(`>([A-Za-z0-9_-]{43})<`)
func TestAdminApplicationsIntegration(t *testing.T) {
db := testdb.New(t)
admin := &auth.User{Username: "appadmin", Email: "appadmin@example.com", Role: auth.RoleAdmin}
if err := admin.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(admin).Error; err != nil {
t.Fatal(err)
}
member := &auth.User{Username: "appuser", Email: "appuser@example.com", Role: auth.RoleUser}
if err := member.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(member).Error; err != nil {
t.Fatal(err)
}
adminSess, err := auth.CreateSession(db, admin.ID)
if err != nil {
t.Fatal(err)
}
memberSess, err := auth.CreateSession(db, member.ID)
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
r.Get("/admin/applications", ApplicationsPageHandler(db))
r.Get("/admin/applications/new", ApplicationNewPageHandler(db))
r.Post("/admin/applications/new", ApplicationsCreateHandler(db))
r.Get("/admin/applications/{id}", ApplicationEditPageHandler(db))
r.Post("/admin/applications/{id}", ApplicationUpdateHandler(db))
r.Post("/admin/applications/{id}/secret", ApplicationsRotateSecretHandler(db))
r.Post("/admin/applications/{id}/delete", ApplicationsDeleteHandler(db))
appCount := func(t *testing.T) int64 {
t.Helper()
var n int64
if err := db.Model(&application.Application{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
t.Run("非 admin 存取回 403", func(t *testing.T) {
for _, path := range []string{"/admin/applications", "/admin/applications/new", "/admin/applications/1"} {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet(path, memberSess))
if rec.Code != http.StatusForbidden {
t.Fatalf("GET %s status = %d, want 403", path, rec.Code)
}
}
})
t.Run("admin 首次檢視為空狀態", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/applications", adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "尚無應用程式") {
t.Fatalf("應顯示空狀態提示:%s", rec.Body.String())
}
})
t.Run("admin 檢視註冊頁含表單", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `action="/admin/applications/new"`) || !strings.Contains(body, `name="redirect_uris"`) {
t.Fatal("註冊頁應含送回本頁的表單")
}
})
// currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次
// 渲染都會輪替);註冊表單位於 /admin/applications/new。
currentCSRF := func(t *testing.T) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("GET /admin/applications/new status = %d", rec.Code)
}
return csrfCookieOf(t, rec)
}
postForm := func(t *testing.T, path string, vals url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := currentCSRF(t)
vals.Set("csrf_token", cookie.Value)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost(path, vals.Encode(), adminSess, cookie))
return rec
}
t.Run("CSRF 不符回 403", func(t *testing.T) {
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost("/admin/applications/new", "csrf_token=wrong", adminSess, cookie))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應顯示 CSRF 錯誤訊息")
}
})
var secret1 string
t.Run("註冊機密式應用程式顯示一次性 secret", func(t *testing.T) {
rec := postForm(t, "/admin/applications/new", url.Values{
"name": {"官方網站"},
"type": {"confidential"},
"redirect_uris": {"https://app.example.com/oidc/callback"},
"grant_types": {"authorization_code", "refresh_token"},
"scope": {"openid offline_access"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "只顯示這一次") {
t.Fatal("應顯示一次性 secret 面板")
}
m := secretInBody.FindStringSubmatch(body)
if m == nil {
t.Fatal("頁面應包含 43 字元明文 client secret")
}
secret1 = m[1]
var app application.Application
if err := db.First(&app).Error; err != nil {
t.Fatal(err)
}
if app.Name != "官方網站" || app.IsPublic() || !app.CheckSecret(secret1) {
t.Errorf("儲存的應用程式與表單輸入不符或 secret 驗證失敗:%+v", app)
}
if !app.GrantTypes.Contains(application.GrantRefreshToken) {
t.Errorf("應啟用 refresh_token,得到 %v", app.GrantTypes)
}
if !strings.Contains(body, app.ClientID) {
t.Error("頁面應顯示新註冊的 client_id")
}
if n := appCount(t); n != 1 {
t.Fatalf("資料庫應用程式數 = %d, want 1", n)
}
})
t.Run("註冊驗證失敗回 400 並保留輸入", func(t *testing.T) {
rec := postForm(t, "/admin/applications/new", url.Values{
"name": {"後台系統"},
"type": {"confidential"},
"redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "loopback") {
t.Fatal("應顯示 redirect URI 驗證錯誤")
}
if !strings.Contains(body, `value="後台系統"`) {
t.Fatal("重繪時應保留已輸入的名稱")
}
if n := appCount(t); n != 1 {
t.Fatalf("驗證失敗不應寫入,資料庫應用程式數 = %d, want 1", n)
}
})
var publicApp application.Application
t.Run("註冊公開式應用程式顯示 PKCE 面板", func(t *testing.T) {
rec := postForm(t, "/admin/applications/new", url.Values{
"name": {"行動 App"},
"type": {"public"},
"redirect_uris": {"com.example.app:/cb"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "行動 App 已註冊") || !strings.Contains(body, "PKCE") {
t.Fatal("公開式註冊成功應顯示 PKCE 面板")
}
if strings.Contains(body, "只顯示這一次") {
t.Fatal("公開式無 client secret,不應顯示明文警告")
}
if err := db.Where("type = ?", application.ClientPublic).First(&publicApp).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(body, publicApp.ClientID) {
t.Error("面板應顯示新註冊的 client_id")
}
if n := appCount(t); n != 2 {
t.Fatalf("資料庫應用程式數 = %d, want 2", n)
}
})
t.Run("輪替機密式 secret", func(t *testing.T) {
var conf application.Application
if err := db.Where("type = ?", application.ClientConfidential).First(&conf).Error; err != nil {
t.Fatal(err)
}
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
m := secretInBody.FindStringSubmatch(rec.Body.String())
if m == nil {
t.Fatal("輪替後應顯示新的明文 client secret")
}
var reloaded application.Application
if err := db.First(&reloaded, conf.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.CheckSecret(secret1) {
t.Error("輪替後舊 client secret 應失效")
}
if !reloaded.CheckSecret(m[1]) {
t.Error("新 client secret 應可驗證")
}
})
t.Run("輪替公開式回 409", func(t *testing.T) {
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", publicApp.ID), url.Values{})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "公開式 Client 不持有 client secret") {
t.Fatal("應顯示公開式不可輪替的訊息")
}
})
t.Run("刪除應用程式後 PRG 導回", func(t *testing.T) {
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/delete", publicApp.ID), url.Values{})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/admin/applications" {
t.Fatalf("Location = %q, want /admin/applications", loc)
}
if n := appCount(t); n != 1 {
t.Fatalf("刪除後資料庫應用程式數 = %d, want 1", n)
}
rec = httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/applications", adminSess))
if strings.Contains(rec.Body.String(), "行動 App") {
t.Error("刪除後列表不應再出現該應用程式")
}
})
t.Run("刪除不存在的應用程式回 404", func(t *testing.T) {
rec := postForm(t, "/admin/applications/99999/delete", url.Values{})
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), "應用程式不存在") {
t.Fatal("應顯示應用程式不存在")
}
})
// 以下編輯流程子測試:此時資料庫僅剩註冊時輪替過一次 secret 的機密式
// 應用程式(公開式已於前述子測試刪除)。
confidential := func(t *testing.T) application.Application {
t.Helper()
var a application.Application
if err := db.Where("type = ?", application.ClientConfidential).First(&a).Error; err != nil {
t.Fatal(err)
}
return a
}
t.Run("編輯頁預填既有註冊內容", func(t *testing.T) {
conf := confidential(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d", conf.ID), adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
fmt.Sprintf(`action="/admin/applications/%d"`, conf.ID),
conf.ClientID, // 唯讀顯示
`value="` + conf.Name + `"`, // 名稱預填
conf.RedirectURIs[0], // redirect URI 預填
} {
if !strings.Contains(body, want) {
t.Errorf("編輯頁缺少 %s", want)
}
}
})
t.Run("編輯不存在的應用程式回 404", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/applications/99999", adminSess))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), "應用程式不存在") {
t.Fatal("應顯示應用程式不存在")
}
})
t.Run("編輯儲存後 PRG 並更新資料庫", func(t *testing.T) {
conf := confidential(t)
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
"name": {"官方網站 2.0"},
"type": {"confidential"},
"redirect_uris": {"https://app.example.com/oidc/callback\nhttps://alt.example.com/cb"},
"grant_types": {"authorization_code", "refresh_token", "client_credentials"},
"scope": {"openid profile email offline_access"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if want := fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID); rec.Header().Get("Location") != want {
t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), want)
}
var reloaded application.Application
if err := db.First(&reloaded, conf.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.Name != "官方網站 2.0" || reloaded.ClientID != conf.ClientID {
t.Errorf("名稱應更新且 client_id 不變:%+v", reloaded)
}
if len(reloaded.RedirectURIs) != 2 || !reloaded.RedirectURIs.Contains("https://alt.example.com/cb") {
t.Errorf("RedirectURIs 應更新,得到 %v", reloaded.RedirectURIs)
}
if !reloaded.GrantTypes.Contains(application.GrantClientCredentials) {
t.Errorf("GrantTypes 應更新,得到 %v", reloaded.GrantTypes)
}
if reloaded.ClientSecretHash != conf.ClientSecretHash {
t.Error("編輯不應更動 client secret 雜湊")
}
})
t.Run("PRG 後的編輯頁顯示成功訊息與新值", func(t *testing.T) {
conf := confidential(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID), adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"已儲存變更", `value="官方網站 2.0"`, "https://alt.example.com/cb"} {
if !strings.Contains(body, want) {
t.Errorf("儲存後的編輯頁缺少 %s", want)
}
}
})
t.Run("編輯驗證失敗回 400 保留輸入且不寫入", func(t *testing.T) {
conf := confidential(t)
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
"name": {"壞 URI 練習"},
"type": {"confidential"},
"redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "loopback") {
t.Fatal("應顯示 redirect URI 驗證錯誤")
}
if !strings.Contains(body, `value="壞 URI 練習"`) {
t.Fatal("重繪時應保留已輸入的名稱")
}
var reloaded application.Application
if err := db.First(&reloaded, conf.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.Name != "官方網站 2.0" {
t.Errorf("驗證失敗不應寫入,名稱 = %q", reloaded.Name)
}
})
t.Run("改為公開式清除 secret 並停用輪替", func(t *testing.T) {
conf := confidential(t)
edit := func(t *testing.T, typ string) {
t.Helper()
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
"name": {"官方網站 2.0"},
"type": {typ},
"redirect_uris": {"https://app.example.com/oidc/callback"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("改為 %s status = %d, body = %s", typ, rec.Code, rec.Body.String())
}
}
edit(t, "public")
var pub application.Application
if err := db.First(&pub, conf.ID).Error; err != nil {
t.Fatal(err)
}
if !pub.IsPublic() || pub.ClientSecretHash != "" {
t.Fatalf("改為公開式後應清除 secret 雜湊:%+v", pub)
}
// 公開式不持有 secret,輪替回 409。
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
if rec.Code != http.StatusConflict {
t.Fatalf("公開式輪替 status = %d, want 409", rec.Code)
}
// 改回機密式:雜湊不應復活,須重新輪替取得新 secret。
edit(t, "confidential")
var back application.Application
if err := db.First(&back, conf.ID).Error; err != nil {
t.Fatal(err)
}
if back.IsPublic() || back.ClientSecretHash != "" {
t.Fatalf("改回機密式不應復活舊 secret 雜湊:%+v", back)
}
rec = postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
if rec.Code != http.StatusOK {
t.Fatalf("改回機密式後輪替 status = %d, body = %s", rec.Code, rec.Body.String())
}
m := secretInBody.FindStringSubmatch(rec.Body.String())
if m == nil {
t.Fatal("輪替後應顯示新的明文 client secret")
}
var rotated application.Application
if err := db.First(&rotated, conf.ID).Error; err != nil {
t.Fatal(err)
}
if !rotated.CheckSecret(m[1]) {
t.Error("重新輪替的新 client secret 應可驗證")
}
})
}