mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-22 17:58:12 +00:00
391 lines
11 KiB
Go
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)
|
|
}
|
|
}
|