forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/jwk"
|
||||
)
|
||||
|
||||
// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
|
||||
// 因此 handler 可傳入 nil db。
|
||||
func TestAdminKeysHandlersRequireLogin(t *testing.T) {
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"GET 列表": adminKeysPageHandler(nil),
|
||||
"POST 產生": adminKeysCreateHandler(nil),
|
||||
"POST 退休": adminKeysRetireHandler(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()
|
||||
renderHTML(rec, http.StatusOK, 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()
|
||||
renderHTML(rec, http.StatusOK, 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()
|
||||
renderHTML(rec, http.StatusOK, adminKeysTmpl, data)
|
||||
if !strings.Contains(rec.Body.String(), "目前沒有使用中的金鑰") {
|
||||
t.Error("無使用中金鑰時應顯示警告")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
|
||||
|
||||
// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫
|
||||
// (與開發資料庫 alterminal 隔離),供整合測試使用。
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
admin, err := gorm.Open(postgres.Open(fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC",
|
||||
envOr("DB_HOST", "localhost"), envOr("DB_PORT", "5432"),
|
||||
envOr("DB_USER", "postgres"), envOr("DB_PASSWORD", "postgres"),
|
||||
)), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err)
|
||||
}
|
||||
if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Skipf("無法建立測試資料庫:%v", err)
|
||||
}
|
||||
t.Setenv("DB_NAME", "alterminal_test")
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
t.Skipf("連線測試資料庫失敗:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.Exec("TRUNCATE users, sessions, signing_keys, applications RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("清空測試資料失敗:%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == csrfCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("回應未設定 CSRF Cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminGet 建立帶 Session Cookie 的 GET 請求(管理頁共用)。
|
||||
func adminGet(path string, sess *Session) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.ID})
|
||||
return req
|
||||
}
|
||||
|
||||
// adminPost 建立帶 Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。
|
||||
func adminPost(path, body string, sess *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: sessionCookieName, Value: sess.ID})
|
||||
if csrf != nil {
|
||||
req.AddCookie(csrf)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestAdminKeysIntegration(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
admin := &User{Username: "keyadmin", Email: "keyadmin@example.com", Role: RoleAdmin}
|
||||
if err := admin.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member := &User{Username: "keyuser", Email: "keyuser@example.com", Role: RoleUser}
|
||||
if err := member.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(member).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSess, err := createSession(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberSess, err := createSession(db, member.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/admin/keys", adminKeysPageHandler(db))
|
||||
r.Post("/admin/keys", adminKeysCreateHandler(db))
|
||||
r.Post("/admin/keys/{id}/retire", adminKeysRetireHandler(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("應顯示已退休訊息")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user