first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+469
View File
@@ -0,0 +1,469 @@
package admin
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// adminApplicationRow 為應用程式管理頁表格的單列視圖。
type adminApplicationRow struct {
ID uint
ClientID string
Name string
Type string // confidential / public
RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現)
GrantTypes string // 以頓號分隔
Scope string
CreatedAt string // 本地時間顯示
Confidential bool // 機密式才可輪替 client secret
}
// newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。
func newAdminApplicationRows(apps []application.Application) []adminApplicationRow {
rows := make([]adminApplicationRow, 0, len(apps))
for _, a := range apps {
grants := make([]string, len(a.GrantTypes))
for i, g := range a.GrantTypes {
grants[i] = string(g)
}
rows = append(rows, adminApplicationRow{
ID: a.ID,
ClientID: a.ClientID,
Name: a.Name,
Type: string(a.Type),
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
GrantTypes: strings.Join(grants, "、"),
Scope: a.Scope,
CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Confidential: !a.IsPublic(),
})
}
return rows
}
// applicationForm 為註冊表單的視圖狀態:驗證失敗重繪時保留使用者輸入
// (含核取方塊),初次顯示(GET)採 newApplicationForm 的預設值。
type applicationForm struct {
Name string
Type string // confidential / public
RedirectURIs string // textarea 原始內容(每行一個 URI)
Scope string // 留空時使用預設
GrantAuthCode bool
GrantRefresh bool
GrantClientCred bool
}
// newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。
func newApplicationForm() applicationForm {
return applicationForm{Type: string(application.ClientConfidential), GrantAuthCode: true}
}
// applicationFormFromPost 由已解析的表單還原視圖狀態。類型限選單兩值,
// 其餘一律回復為 confidential;grant type 僅接受已知值。
func applicationFormFromPost(r *http.Request) applicationForm {
f := applicationForm{
Name: r.PostFormValue("name"),
Type: r.PostFormValue("type"),
RedirectURIs: r.PostFormValue("redirect_uris"),
Scope: r.PostFormValue("scope"),
}
if f.Type != string(application.ClientPublic) {
f.Type = string(application.ClientConfidential)
}
for _, g := range r.PostForm["grant_types"] {
switch application.GrantType(g) {
case application.GrantAuthorizationCode:
f.GrantAuthCode = true
case application.GrantRefreshToken:
f.GrantRefresh = true
case application.GrantClientCredentials:
f.GrantClientCred = true
}
}
return f
}
// applicationFormFromApp 由既有註冊資料預填表單狀態(GET 編輯頁),
// redirect URI 以每行一個還原為 textarea 內容。
func applicationFormFromApp(a *application.Application) applicationForm {
f := applicationForm{
Name: a.Name,
Type: string(a.Type),
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
Scope: a.Scope,
}
for _, g := range a.GrantTypes {
switch g {
case application.GrantAuthorizationCode:
f.GrantAuthCode = true
case application.GrantRefreshToken:
f.GrantRefresh = true
case application.GrantClientCredentials:
f.GrantClientCred = true
}
}
return f
}
// redirectURIList 解析 textarea 內容:每行一個 URI,去首尾空白(含瀏覽器
// 送出的 \r)後略過空行。
func (f applicationForm) redirectURIList() []string {
var uris []string
for _, line := range strings.Split(f.RedirectURIs, "\n") {
if u := strings.TrimSpace(line); u != "" {
uris = append(uris, u)
}
}
return uris
}
// grantTypeList 依核取狀態列出要啟用的 grant type。
func (f applicationForm) grantTypeList() []application.GrantType {
var gts []application.GrantType
if f.GrantAuthCode {
gts = append(gts, application.GrantAuthorizationCode)
}
if f.GrantRefresh {
gts = append(gts, application.GrantRefreshToken)
}
if f.GrantClientCred {
gts = append(gts, application.GrantClientCredentials)
}
return gts
}
// secretPanel 為註冊與輪替成功的一次性成果面板:直接渲染於 POST 回應
// (資料庫僅存雜湊,明文無法重現,故不採 PRG)。Rotated 區分輪替與註冊
// 的標題文案;公開式 Client 註冊時 Public 為 true 且 Secret 為空,面板
// 改顯示 PKCE 提示而非明文。
type secretPanel struct {
Name string
ClientID string
Secret string // 明文,僅顯示這一次;公開式註冊時為空
Rotated bool // true=client secret 輪替;false=應用程式註冊
Public bool // 公開式 Client(不持有 secret)
}
// adminApplicationsPageData 為應用程式管理頁(列表)的模板資料。
type adminApplicationsPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 輪替/刪除表單的 CSRF token
Apps []adminApplicationRow
Secret *secretPanel // 非空時顯示一次性明文 client secret 面板(輪替)
}
// adminApplicationNewPageData 為註冊新應用程式頁的模板資料。
type adminApplicationNewPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 註冊表單的 CSRF token
Form applicationForm // 表單狀態(重繪時保留輸入)
Secret *secretPanel // 非空時顯示一次性成果面板(註冊)
}
// adminApplicationEditPageData 為編輯應用程式頁的模板資料。ClientID 與
// 建立時間為唯讀顯示(client_id 不可變更);Success 於更新成功後 PRG
// 回本頁時顯示(?saved=1)。
type adminApplicationEditPageData struct {
Error string
Success string // PRG 後的成功訊息;空字串表示不顯示
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 編輯表單的 CSRF token
ID uint // 表單 action 的路徑參數
ClientID string // 唯讀顯示(不可變更)
Created string // 建立時間顯示
Form applicationForm // 表單狀態(重繪時保留輸入)
}
// ApplicationsPageHandler 處理 GET /admin/applications:列出已註冊
// 應用程式,僅管理員可存取;註冊表單獨立於 /admin/applications/new。
func ApplicationsPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "", nil)
}
}
// ApplicationNewPageHandler 處理 GET /admin/applications/new:顯示
// 註冊表單(預設值),僅管理員可存取。
func ApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), nil)
}
}
// renderAdminApplicationNewPage 輸出註冊頁;errMsg 非空時以指定 status
// 重繪表單並顯示錯誤(此時 form 保留使用者輸入);secret 非空時顯示一次
// 性成果面板(機密式含明文 client secret)。頁面不查詢列表,不需資料庫。
func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg string, form applicationForm, secret *secretPanel) {
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("admin applications: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
auth.RenderHTML(w, status, auth.AdminApplicationNewTmpl, adminApplicationNewPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Form: form,
Secret: secret,
})
}
// ApplicationEditPageHandler 處理 GET /admin/applications/{id}:顯示編輯
// 表單(預填既有註冊內容),僅管理員可存取。帶 ?saved=1 時顯示儲存成功
// 訊息(Update 成功後 PRG 回本頁)。
func ApplicationEditPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
var success string
if r.URL.Query().Get("saved") == "1" {
success = "已儲存變更"
}
renderAdminApplicationEditPage(w, r, http.StatusOK, s, "", success, app, applicationFormFromApp(app))
}
}
// renderAdminApplicationEditPage 輸出編輯頁;errMsg 非空時以指定 status
// 重繪表單並顯示錯誤(此時 form 保留使用者輸入),success 非空時顯示
// PRG 後的成功訊息。a 僅取 ID、client_id 與建立時間(皆不受 Update 的
// 驗證失敗影響)。頁面無一次性資料,不需資料庫。
func renderAdminApplicationEditPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg, success string, a *application.Application, form applicationForm) {
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("admin applications: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
auth.RenderHTML(w, status, auth.AdminApplicationEditTmpl, adminApplicationEditPageData{
Error: errMsg,
Success: success,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
ID: a.ID,
ClientID: a.ClientID,
Created: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Form: form,
})
}
// renderAdminApplicationsPage 查詢應用程式並輸出管理列表頁;errMsg 非空時
// 以指定 status 重繪頁面並顯示錯誤,secret 非空時顯示一次性明文面板
// (輪替)。s 供側欄頁尾顯示使用者資訊。新註冊的排前。
func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string, secret *secretPanel) {
var apps []application.Application
if err := db.Order("created_at DESC").Find(&apps).Error; err != nil {
log.Printf("admin applications: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("csrf token: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
auth.RenderHTML(w, status, auth.AdminApplicationsTmpl, adminApplicationsPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Apps: newAdminApplicationRows(apps),
Secret: secret,
})
}
// ApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並
// 儲存新註冊。成功時直接渲染註冊頁(200)顯示 client_id 與明文 client
// secret——secret 只在本次回應出現,重新整理後即無法再查看,故不適用 PRG。
func ApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", newApplicationForm(), nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationNewPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", newApplicationForm(), nil)
return
}
form := applicationFormFromPost(r)
app, secret, err := application.NewApplication(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope)
if err != nil {
renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil)
return
}
if err := db.Create(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationNewPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", form, nil)
return
}
// 公開式 Client 不發配 secret,面板改以 PKCE 提示。
panel := &secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Public: secret == ""}
renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), panel)
}
}
// ApplicationUpdateHandler 處理 POST /admin/applications/{id}:驗證並儲存
// 編輯後的註冊內容——client_id 與 client secret 不在此變更(輪替另經
// /{id}/secret);由機密式改為公開式時一併清除 secret 雜湊。成功後 PRG
// 回編輯頁顯示成功訊息(編輯無一次性資料,適用 PRG)。
func ApplicationUpdateHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
// 先載入應用程式:本頁的錯誤重繪需要 client_id 等唯讀欄位。
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "", app, applicationFormFromApp(app))
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationEditPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "", app, applicationFormFromApp(app))
return
}
form := applicationFormFromPost(r)
if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope); err != nil {
renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, err.Error(), "", app, form)
return
}
if err := db.Save(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationEditPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", "", app, form)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin/applications/%d?saved=1", app.ID), http.StatusSeeOther)
}
}
// ApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret:
// 輪替機密式 Client 的 client secret(舊 secret 立即失效),並同面板直接
// 渲染一次性明文;公開式 Client 不持有 secret,回 409。
func ApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil)
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if app.IsPublic() {
renderAdminApplicationsPage(w, r, db, http.StatusConflict, s, "公開式 Client 不持有 client secret,無法輪替", nil)
return
}
secret, err := app.GenerateSecret()
if err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil)
return
}
if err := db.Model(app).Update("client_secret_hash", app.ClientSecretHash).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "client secret 更新失敗,請稍後再試", nil)
return
}
renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "",
&secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Rotated: true})
}
}
// ApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete:
// 刪除應用程式註冊(連同其 client_id/secret 一併失效),成功後 PRG 導回
// 管理頁。
func ApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil)
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if err := db.Delete(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "應用程式刪除失敗,請稍後再試", nil)
return
}
http.Redirect(w, r, "/admin/applications", http.StatusSeeOther)
}
}
// applicationByID 依路徑參數 {id} 查詢應用程式;id 格式錯誤或查無資料時
// 以 404 重繪管理頁(訊息「應用程式不存在」),其他錯誤以 500 重繪。
// 回傳應用程式與是否繼續處理。
func applicationByID(w http.ResponseWriter, r *http.Request, db *gorm.DB, s *auth.Session) (*application.Application, bool) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
if err != nil {
renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil)
return nil, false
}
var a application.Application
switch err := db.First(&a, id).Error; {
case errors.Is(err, gorm.ErrRecordNotFound):
renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil)
return nil, false
case err != nil:
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil)
return nil, false
}
return &a, true
}
+827
View File
@@ -0,0 +1,827 @@
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 應可驗證")
}
})
}
+210
View File
@@ -0,0 +1,210 @@
package admin
import (
"errors"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"alterminal/internal/auth"
"alterminal/internal/jwk"
)
// adminKeyRow 為金鑰管理頁表格的單列視圖。
type adminKeyRow struct {
ID uint
Kid string
Algorithm string
CreatedAt string // 本地時間顯示
Active bool // 使用中(未退休)
LastActive bool // 使用中且為最後一把:退休按鈕停用
}
// adminKeysPageData 為金鑰管理頁的模板資料。
type adminKeysPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 產生/退休表單的 CSRF token
Keys []adminKeyRow
ActiveCount int // 使用中金鑰數;0 時頁面顯示警告
}
// newAdminKeyRows 將金鑰模型轉為表格視圖,並回傳使用中的金鑰數。
// 純函式,便於單元測試。
func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) {
for _, k := range keys {
if k.Active() {
active++
}
}
rows = make([]adminKeyRow, 0, len(keys))
for _, k := range keys {
rows = append(rows, adminKeyRow{
ID: k.ID,
Kid: k.Kid,
Algorithm: k.Algorithm,
CreatedAt: k.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Active: k.Active(),
// 僅剩一把使用中金鑰時禁止退休,確保隨時都有金鑰可簽發 JWT。
LastActive: k.Active() && active == 1,
})
}
return rows, active
}
// requireAdmin 驗證請求來自持有效 auth.Session 的管理員:未登入或 auth.Session
// 過期時導向 /login(登入後可再試),已登入但非管理員回 403。
// 回傳 auth.Session(含 auth.User)與是否繼續處理。
func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) {
c, err := r.Cookie(auth.CookieName)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return nil, false
}
s, err := auth.GetSession(db, c.Value)
switch {
case errors.Is(err, auth.ErrSessionExpired):
http.Redirect(w, r, "/login", http.StatusSeeOther)
return nil, false
case err != nil:
log.Printf("admin: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return nil, false
}
if s.User.Role != auth.RoleAdmin {
log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username)
http.Error(w, "需要管理員權限", http.StatusForbidden)
return nil, false
}
return s, true
}
// KeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、
// 建立時間、狀態)與產生/退休表單,僅管理員可存取。
func KeysPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminKeysPage(w, r, db, http.StatusOK, s, "")
}
}
// renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status
// 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。
// 使用中的金鑰排前、新者在前。
func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string) {
var keys []jwk.SigningKey
if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil {
log.Printf("admin keys: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("csrf token: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
rows, active := newAdminKeyRows(keys)
auth.RenderHTML(w, status, auth.AdminKeysTmpl, adminKeysPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Keys: rows,
ActiveCount: active,
})
}
// KeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA
// 簽章金鑰,成功後 PRG 導回管理頁。
func KeysCreateHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
return
}
if !auth.VerifyCSRF(r) {
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
return
}
k, err := jwk.NewSigningKey()
if err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰產生失敗,請稍後再試")
return
}
if err := db.Create(k).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰儲存失敗,請稍後再試")
return
}
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
}
}
// KeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。
// 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在
// 的金鑰以錯誤訊息重繪頁面。
func KeysRetireHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
return
}
if !auth.VerifyCSRF(r) {
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
return
}
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
if err != nil {
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
return
}
var k jwk.SigningKey
switch err := db.First(&k, id).Error; {
case errors.Is(err, gorm.ErrRecordNotFound):
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
return
case err != nil:
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
return
}
if !k.Active() {
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "金鑰已處於退休狀態")
return
}
var active int64
if err := db.Model(&jwk.SigningKey{}).Where("retired_at IS NULL").Count(&active).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
return
}
if active <= 1 {
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "至少須保留一把使用中的金鑰")
return
}
if err := db.Model(&k).Update("retired_at", time.Now()).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰更新失敗,請稍後再試")
return
}
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
}
}
+347
View File
@@ -0,0 +1,347 @@
package admin
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"alterminal/internal/auth"
"alterminal/internal/jwk"
"alterminal/internal/testdb"
)
// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
// 因此 handler 可傳入 nil db。
func TestAdminKeysHandlersRequireLogin(t *testing.T) {
handlers := map[string]http.HandlerFunc{
"GET 列表": KeysPageHandler(nil),
"POST 產生": KeysCreateHandler(nil),
"POST 退休": KeysRetireHandler(nil),
}
for name, h := range handlers {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/admin/keys", 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 TestNewAdminKeyRows(t *testing.T) {
retired := time.Now().Add(-24 * time.Hour)
keys := []jwk.SigningKey{
{ID: 1, Kid: "kid-a", Algorithm: "RS256", RetiredAt: &retired},
{ID: 2, Kid: "kid-b", Algorithm: "RS256"},
{ID: 3, Kid: "kid-c", Algorithm: "RS256"},
}
rows, active := newAdminKeyRows(keys)
if active != 2 {
t.Fatalf("active = %d, want 2", active)
}
if len(rows) != 3 {
t.Fatalf("rows = %d 筆, want 3", len(rows))
}
for _, r := range rows {
if r.Active != (r.Kid != "kid-a") {
t.Errorf("row %q Active = %v 與退休狀態不符", r.Kid, r.Active)
}
if r.LastActive {
t.Errorf("兩把使用中金鑰時 row %q 不應標記 LastActive", r.Kid)
}
}
rows, active = newAdminKeyRows(keys[:2]) // 一把使用中+一把退休
if active != 1 {
t.Fatalf("active = %d, want 1", active)
}
if !rows[1].LastActive {
t.Error("僅剩一把使用中金鑰時應標記 LastActive")
}
}
// renderAdminKeysPage 需要資料庫,模板輸出直接以假資料渲染測試。
func TestAdminKeysTemplate(t *testing.T) {
data := adminKeysPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 2,
Keys: []adminKeyRow{
{ID: 7, Kid: "kid-active", Algorithm: "RS256", CreatedAt: "2026-10-02 12:00:00 +08:00", Active: true},
{ID: 3, Kid: "kid-retired", Algorithm: "RS256", CreatedAt: "2026-09-01 12:00:00 +08:00"},
},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
body := rec.Body.String()
for _, want := range []string{
"金鑰管理", // 標題
`action="/admin/keys"`, // 產生新金鑰表單
`value="token-A"`, // CSRF 隱藏欄位
`action="/admin/keys/7/retire"`, // 使用中金鑰的退休表單
"kid-active", "kid-retired", // kid 欄
"使用中", "已退休", // 狀態徽章
`href="/admin/keys" aria-current="page"`, // 導覽(目前頁)
`href="/login"`, // 導覽(帳號資訊)
`action="/logout"`, // 側欄頁尾登出表單(版面預設)
"alice@example.com",
} {
if !strings.Contains(body, want) {
t.Errorf("金鑰管理頁缺少 %s", want)
}
}
if strings.Contains(body, "/admin/keys/3/retire") {
t.Error("已退休的金鑰不應出現退休表單")
}
if !strings.Contains(body, "使用中 2 把 / 共 2 把") {
t.Error("應顯示使用中/總數統計")
}
}
// LastActive(唯一使用中金鑰)不輸出退休表單,改顯示提示。
func TestAdminKeysTemplateLastActive(t *testing.T) {
data := adminKeysPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 1,
Keys: []adminKeyRow{{ID: 7, Kid: "kid-only", Algorithm: "RS256", Active: true, LastActive: true}},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
body := rec.Body.String()
if strings.Contains(body, "/retire") {
t.Error("唯一使用中金鑰不應出現退休表單")
}
if !strings.Contains(body, "唯一使用中金鑰") {
t.Error("應顯示無法退休的提示")
}
}
// 無使用中金鑰時顯示警告。
func TestAdminKeysTemplateNoActiveWarning(t *testing.T) {
data := adminKeysPageData{
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
Keys: []adminKeyRow{{ID: 7, Kid: "kid-old", Algorithm: "RS256"}},
}
rec := httptest.NewRecorder()
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
if !strings.Contains(rec.Body.String(), "目前沒有使用中的金鑰") {
t.Error("無使用中金鑰時應顯示警告")
}
}
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
// 測試資料庫(alterminal_test)的準備見 internal/testdb。
func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CSRFCookieName {
return c
}
}
t.Fatal("回應未設定 CSRF Cookie")
return nil
}
// adminGet 建立帶 auth.Session Cookie 的 GET 請求(管理頁共用)。
func adminGet(path string, sess *auth.Session) *http.Request {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID})
return req
}
// adminPost 建立帶 auth.Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。
func adminPost(path, body string, sess *auth.Session, csrf *http.Cookie) *http.Request {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID})
if csrf != nil {
req.AddCookie(csrf)
}
return req
}
func TestAdminKeysIntegration(t *testing.T) {
db := testdb.New(t)
admin := &auth.User{Username: "keyadmin", Email: "keyadmin@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: "keyuser", Email: "keyuser@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/keys", KeysPageHandler(db))
r.Post("/admin/keys", KeysCreateHandler(db))
r.Post("/admin/keys/{id}/retire", KeysRetireHandler(db))
t.Run("非 admin 存取回 403", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/keys", memberSess))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
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/keys", adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "尚無簽章金鑰") {
t.Fatalf("應顯示空狀態提示:%s", rec.Body.String())
}
})
// currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次
// 渲染都會輪替)。
currentCSRF := func(t *testing.T) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/keys", adminSess))
if rec.Code != http.StatusOK {
t.Fatalf("GET /admin/keys status = %d", rec.Code)
}
return csrfCookieOf(t, rec)
}
t.Run("CSRF 不符回 403", func(t *testing.T) {
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost("/admin/keys", "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 錯誤訊息")
}
})
t.Run("產生新金鑰", func(t *testing.T) {
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie))
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/keys" {
t.Fatalf("Location = %q, want /admin/keys", loc)
}
var count int64
db.Model(&jwk.SigningKey{}).Count(&count)
if count != 1 {
t.Fatalf("資料庫金鑰數 = %d, want 1", count)
}
})
t.Run("退休最後一把使用中金鑰回 409", func(t *testing.T) {
var k jwk.SigningKey
if err := db.First(&k).Error; err != nil {
t.Fatal(err)
}
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", k.ID), "csrf_token="+cookie.Value, adminSess, cookie))
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if !strings.Contains(rec.Body.String(), "至少須保留一把使用中的金鑰") {
t.Fatal("應顯示最後一把不可退休的訊息")
}
})
t.Run("產生第二把後可退休舊金鑰", func(t *testing.T) {
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie))
if rec.Code != http.StatusSeeOther {
t.Fatalf("產生第二把 status = %d, body = %s", rec.Code, rec.Body.String())
}
var old, latest jwk.SigningKey
if err := db.Order("id").First(&old).Error; err != nil {
t.Fatal(err)
}
if err := db.Order("id DESC").First(&latest).Error; err != nil {
t.Fatal(err)
}
cookie = currentCSRF(t)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie))
if rec.Code != http.StatusSeeOther {
t.Fatalf("退休 status = %d, body = %s", rec.Code, rec.Body.String())
}
if err := db.First(&old, old.ID).Error; err != nil {
t.Fatal(err)
}
if old.RetiredAt == nil {
t.Fatal("退休後 RetiredAt 應有值")
}
// 頁面顯示兩種狀態與統計。
rec = httptest.NewRecorder()
r.ServeHTTP(rec, adminGet("/admin/keys", adminSess))
body := rec.Body.String()
for _, want := range []string{old.Kid, latest.Kid, "使用中 1 把 / 共 2 把", "已退休", "唯一使用中金鑰"} {
if !strings.Contains(body, want) {
t.Errorf("管理頁缺少 %s", want)
}
}
})
t.Run("退休不存在的金鑰回 404", func(t *testing.T) {
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost("/admin/keys/99999/retire", "csrf_token="+cookie.Value, adminSess, cookie))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), "金鑰不存在") {
t.Fatal("應顯示金鑰不存在")
}
})
t.Run("再退休已退休金鑰回 409", func(t *testing.T) {
var old jwk.SigningKey
if err := db.Where("retired_at IS NOT NULL").First(&old).Error; err != nil {
t.Fatal(err)
}
cookie := currentCSRF(t)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie))
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if !strings.Contains(rec.Body.String(), "已處於退休狀態") {
t.Fatal("應顯示已退休訊息")
}
})
}