重組前檢查點:根目錄 main package

This commit is contained in:
2026-10-03 09:23:38 +08:00
commit f373cb8d37
45 changed files with 5332 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"regexp"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
// 因此 handler 可傳入 nil db。
func TestAdminApplicationsHandlersRequireLogin(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"GET 列表": adminApplicationsPageHandler(nil),
"GET 註冊頁": adminApplicationNewPageHandler(nil),
"POST 註冊": adminApplicationsCreateHandler(nil),
"POST 輪替": adminApplicationsRotateSecretHandler(nil),
"POST 刪除": adminApplicationsDeleteHandler(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{
{
ID: 1, ClientID: "cid-a", Name: "官方網站", Type: ClientConfidential,
RedirectURIs: RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"},
GrantTypes: GrantTypes{GrantAuthorizationCode, GrantRefreshToken},
Scope: "openid offline_access", CreatedAt: time.Now(),
},
{
ID: 2, ClientID: "cid-b", Name: "行動 App", Type: ClientPublic,
RedirectURIs: RedirectURIs{"com.example.app:/cb"},
GrantTypes: GrantTypes{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(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 := []GrantType{GrantRefreshToken, 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(ClientConfidential) {
t.Errorf("非法類型應回復 confidential,得到 %q", f.Type)
}
}
// 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()
renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data)
body := rec.Body.String()
for _, want := range []string{
"應用程式管理", // 標題
`href="/admin/applications/new"`, // 註冊新應用程式按鈕(獨立頁)
`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()
renderHTML(rec, http.StatusOK, 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()
renderHTML(rec, http.StatusOK, 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()
renderHTML(rec, http.StatusOK, 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)
}
}
}
// 輪替成功的一次性明文面板(渲染於管理列表頁)。
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()
renderHTML(rec, http.StatusOK, 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()
renderHTML(rec, http.StatusOK, 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 := newTestDB(t)
admin := &User{Username: "appadmin", Email: "appadmin@example.com", Role: RoleAdmin}
if err := admin.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(admin).Error; err != nil {
t.Fatal(err)
}
member := &User{Username: "appuser", Email: "appuser@example.com", Role: 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 := createSession(db, admin.ID)
if err != nil {
t.Fatal(err)
}
memberSess, err := createSession(db, member.ID)
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
r.Get("/admin/applications", adminApplicationsPageHandler(db))
r.Get("/admin/applications/new", adminApplicationNewPageHandler(db))
r.Post("/admin/applications/new", adminApplicationsCreateHandler(db))
r.Post("/admin/applications/{id}/secret", adminApplicationsRotateSecretHandler(db))
r.Post("/admin/applications/{id}/delete", adminApplicationsDeleteHandler(db))
appCount := func(t *testing.T) int64 {
t.Helper()
var n int64
if err := db.Model(&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"} {
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
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(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
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 = ?", 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
if err := db.Where("type = ?", 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
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("應顯示應用程式不存在")
}
})
}