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("應顯示已退休訊息") } }) }