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`, // 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:面板以 /
包裹 // 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 應可驗證") } }) }