1
0
Fork 0
mirror of https://github.com/adnanh/webhook.git synced 2026-07-22 09:48:14 +00:00
webhook/admin_test.go
jason.liao 8272126474 feat: 添加管理配置功能
默认不启用,需要通过启动参数开启
2026-04-28 10:35:11 +08:00

391 lines
11 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/adnanh/webhook/internal/hook"
"github.com/gorilla/mux"
)
const testAdminBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
func setupAdminTestState(t *testing.T) func() {
t.Helper()
savedSecure := *secure
savedAsTemplate := *asTemplate
savedAdminEnabled := *adminEnabled
savedAdminAuth := currentAdminAuth
loadedHooksMu.RLock()
savedHooksFiles := append(hook.HooksFiles(nil), hooksFiles...)
savedLoaded := cloneLoadedHooksMapLocked()
loadedHooksMu.RUnlock()
decodedSecret, err := decodeTOTPSecret(testAdminBase32Secret)
if err != nil {
t.Fatalf("decode TOTP secret: %v", err)
}
currentAdminAuth = &adminAuthConfig{
basePath: "/admin",
totpSecret: decodedSecret,
jwtSecret: []byte("test-jwt-secret"),
sessionTTL: time.Hour,
}
*secure = false
*asTemplate = false
*adminEnabled = false
loadedHooksMu.Lock()
loadedHooksFromFiles = make(map[string]hook.Hooks)
hooksFiles = nil
loadedHooksMu.Unlock()
return func() {
currentAdminAuth = savedAdminAuth
*secure = savedSecure
*asTemplate = savedAsTemplate
*adminEnabled = savedAdminEnabled
loadedHooksMu.Lock()
loadedHooksFromFiles = savedLoaded
hooksFiles = savedHooksFiles
loadedHooksMu.Unlock()
}
}
func setLoadedHooksForTest(path string, hooksInFile hook.Hooks) {
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
loadedHooksFromFiles = map[string]hook.Hooks{
path: cloneHooks(hooksInFile),
}
hooksFiles = hook.HooksFiles{path}
}
func readHooksFileForTest(t *testing.T, path string) hook.Hooks {
t.Helper()
var hooksInFile hook.Hooks
if err := hooksInFile.LoadFromFile(path, false); err != nil {
t.Fatalf("load hooks file %s: %v", path, err)
}
return hooksInFile
}
func loginCookieForTest(t *testing.T) *http.Cookie {
t.Helper()
code := hotpCode(currentAdminAuth.totpSecret, uint64(time.Now().Unix()/30), 6)
req := httptest.NewRequest(http.MethodPost, "/admin/api/auth/login", strings.NewReader(`{"code":"`+code+`"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
adminLoginHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == adminTokenCookieName {
return cookie
}
}
t.Fatal("admin login did not issue session cookie")
return nil
}
func TestVerifyTOTP(t *testing.T) {
secret, err := decodeTOTPSecret(testAdminBase32Secret)
if err != nil {
t.Fatalf("decode TOTP secret: %v", err)
}
if got := hotpCode(secret, 1, 6); got != "287082" {
t.Fatalf("hotpCode(...) = %q, want %q", got, "287082")
}
if !verifyTOTP(secret, "287082", time.Unix(59, 0)) {
t.Fatal("verifyTOTP should accept the RFC test vector")
}
if verifyTOTP(secret, "287083", time.Unix(59, 0)) {
t.Fatal("verifyTOTP should reject an invalid code")
}
}
func TestAdminJWTRoundTrip(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
now := time.Unix(1700000000, 0)
token, err := signAdminJWT(now)
if err != nil {
t.Fatalf("signAdminJWT: %v", err)
}
claims, err := parseAdminJWT(token, now.Add(10*time.Minute))
if err != nil {
t.Fatalf("parseAdminJWT: %v", err)
}
if claims.Subject != "webhook-admin" {
t.Fatalf("claims.Subject = %q, want %q", claims.Subject, "webhook-admin")
}
if _, err := parseAdminJWT(token, now.Add(2*time.Hour)); err == nil {
t.Fatal("parseAdminJWT should reject expired tokens")
}
}
func TestAdminConfigRequiresAuth(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
handler := adminRequireAuth(adminConfigHandler)
req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestAdminCRUDHandlers(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
initialHooks := hook.Hooks{
{
ID: "deploy",
ExecuteCommand: "/bin/echo",
ResponseMessage: "ok",
HTTPMethods: []string{"POST"},
},
}
if err := writeHooksFile(hooksPath, initialHooks); err != nil {
t.Fatalf("writeHooksFile: %v", err)
}
setLoadedHooksForTest(hooksPath, initialHooks)
cookie := loginCookieForTest(t)
configReq := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
configReq.AddCookie(cookie)
configRec := httptest.NewRecorder()
adminRequireAuth(adminConfigHandler)(configRec, configReq)
if configRec.Code != http.StatusOK {
t.Fatalf("config status = %d, want %d", configRec.Code, http.StatusOK)
}
var config adminHooksState
if err := json.Unmarshal(configRec.Body.Bytes(), &config); err != nil {
t.Fatalf("decode config response: %v", err)
}
if len(config.Files) != 1 || len(config.Files[0].Hooks) != 1 {
t.Fatalf("unexpected config payload: %+v", config)
}
createReq := httptest.NewRequest(http.MethodPost, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"hook": {
"id": "build",
"execute-command": "/usr/bin/env",
"response-message": "created",
"http-methods": [" post ", "get"]
}
}`))
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(cookie)
createRec := httptest.NewRecorder()
adminRequireAuth(adminCreateHookHandler)(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String())
}
hooksAfterCreate := readHooksFileForTest(t, hooksPath)
if len(hooksAfterCreate) != 2 {
t.Fatalf("hook count after create = %d, want %d", len(hooksAfterCreate), 2)
}
if hooksAfterCreate.Match("build") == nil {
t.Fatal("expected created hook to be written to file")
}
if got := hooksAfterCreate.Match("build").HTTPMethods; len(got) != 2 || got[0] != "POST" || got[1] != "GET" {
t.Fatalf("created hook methods = %#v, want %#v", got, []string{"POST", "GET"})
}
updateReq := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "build",
"hook": {
"id": "build-renamed",
"execute-command": "/bin/echo",
"response-message": "updated",
"http-methods": ["PATCH"]
}
}`))
updateReq.Header.Set("Content-Type", "application/json")
updateReq.AddCookie(cookie)
updateRec := httptest.NewRecorder()
adminRequireAuth(adminUpdateHookHandler)(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update status = %d, want %d, body=%s", updateRec.Code, http.StatusOK, updateRec.Body.String())
}
hooksAfterUpdate := readHooksFileForTest(t, hooksPath)
if hooksAfterUpdate.Match("build") != nil {
t.Fatal("expected old hook ID to be removed after rename")
}
renamed := hooksAfterUpdate.Match("build-renamed")
if renamed == nil {
t.Fatal("expected renamed hook to be persisted")
}
if renamed.ResponseMessage != "updated" {
t.Fatalf("updated response message = %q, want %q", renamed.ResponseMessage, "updated")
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "build-renamed"
}`))
deleteReq.Header.Set("Content-Type", "application/json")
deleteReq.AddCookie(cookie)
deleteRec := httptest.NewRecorder()
adminRequireAuth(adminDeleteHookHandler)(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusOK, deleteRec.Body.String())
}
hooksAfterDelete := readHooksFileForTest(t, hooksPath)
if len(hooksAfterDelete) != 1 {
t.Fatalf("hook count after delete = %d, want %d", len(hooksAfterDelete), 1)
}
if hooksAfterDelete.Match("build-renamed") != nil {
t.Fatal("expected deleted hook to be removed from file")
}
}
func TestAdminConfigEmptyHooksUsesArray(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
hooksPath := filepath.Join(t.TempDir(), "hooks.json")
setLoadedHooksForTest(hooksPath, nil)
cookie := loginCookieForTest(t)
req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
adminRequireAuth(adminConfigHandler)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if strings.Contains(body, `"hooks":null`) {
t.Fatalf("config response should not contain null hooks: %s", body)
}
if !strings.Contains(body, `"hooks":[]`) {
t.Fatalf("config response should contain empty hooks array: %s", body)
}
}
func TestAdminUpdateHookWithSlashIDViaRouter(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
*adminEnabled = true
defer func() {
*adminEnabled = false
}()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
initialHooks := hook.Hooks{
{
ID: "iot/thjk/web",
ExecuteCommand: "/bin/echo",
ResponseMessage: "ok",
HTTPMethods: []string{"POST"},
},
}
if err := writeHooksFile(hooksPath, initialHooks); err != nil {
t.Fatalf("writeHooksFile: %v", err)
}
setLoadedHooksForTest(hooksPath, initialHooks)
router := mux.NewRouter()
registerAdminRoutes(router)
cookie := loginCookieForTest(t)
req := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "iot/thjk/web",
"hook": {
"id": "iot/thjk/web",
"execute-command": "/usr/bin/env",
"response-message": "updated slash id"
}
}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("slash id update status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updatedHooks := readHooksFileForTest(t, hooksPath)
updated := updatedHooks.Match("iot/thjk/web")
if updated == nil {
t.Fatal("expected slash-id hook to still exist after update")
}
if updated.ExecuteCommand != "/usr/bin/env" {
t.Fatalf("updated execute-command = %q, want %q", updated.ExecuteCommand, "/usr/bin/env")
}
}
func TestAdminWritesBlockedInTemplateMode(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
setLoadedHooksForTest(hooksPath, hook.Hooks{})
*asTemplate = true
err := upsertHookInFile(hooksPath, "", hook.Hook{ID: "blocked"})
if err == nil || !strings.Contains(err.Error(), errAdminReadOnly.Error()) {
t.Fatalf("upsertHookInFile error = %v, want %v", err, errAdminReadOnly)
}
}