mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 16:21:23 +00:00
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
725 lines
20 KiB
Go
725 lines
20 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ---- stub WebhookStore for admin tests ----
|
|
// webhooks_auth_test.go already defines stubWebhookStore but only covers the
|
|
// authentication surface. We need a richer version for CRUD: Create stores rows,
|
|
// List / GetByID return them, Update / RotateSecret / Revoke mutate in-memory.
|
|
|
|
type adminWebhookStore struct {
|
|
mu sync.Mutex
|
|
rows map[uuid.UUID]*store.WebhookData
|
|
}
|
|
|
|
func newAdminWebhookStore(rows ...*store.WebhookData) *adminWebhookStore {
|
|
s := &adminWebhookStore{rows: make(map[uuid.UUID]*store.WebhookData)}
|
|
for _, r := range rows {
|
|
cp := *r
|
|
s.rows[r.ID] = &cp
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *adminWebhookStore) Create(_ context.Context, w *store.WebhookData) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cp := *w
|
|
s.rows[w.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) GetByID(ctx context.Context, id uuid.UUID) (*store.WebhookData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row, ok := s.rows[id]
|
|
if !ok {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
// Tenant-scope enforcement mirrors real store behaviour.
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil && row.TenantID != tid && !store.IsOwnerRole(ctx) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
cp := *row
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) GetByHash(_ context.Context, h string) (*store.WebhookData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, r := range s.rows {
|
|
if r.SecretHash == h {
|
|
cp := *r
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *adminWebhookStore) List(ctx context.Context, f store.WebhookListFilter) ([]store.WebhookData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tid := store.TenantIDFromContext(ctx)
|
|
var out []store.WebhookData
|
|
for _, r := range s.rows {
|
|
if !store.IsOwnerRole(ctx) && r.TenantID != tid {
|
|
continue
|
|
}
|
|
if f.AgentID != nil && (r.AgentID == nil || *r.AgentID != *f.AgentID) {
|
|
continue
|
|
}
|
|
out = append(out, *r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row, ok := s.rows[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
if v, ok := updates["name"]; ok {
|
|
row.Name = v.(string)
|
|
}
|
|
if v, ok := updates["require_hmac"]; ok {
|
|
row.RequireHMAC = v.(bool)
|
|
}
|
|
if v, ok := updates["localhost_only"]; ok {
|
|
row.LocalhostOnly = v.(bool)
|
|
}
|
|
row.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) RotateSecret(_ context.Context, id uuid.UUID, newHash, newPrefix, newEncryptedSecret string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row, ok := s.rows[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
row.SecretHash = newHash
|
|
row.SecretPrefix = newPrefix
|
|
row.EncryptedSecret = newEncryptedSecret
|
|
row.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) Revoke(_ context.Context, id uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row, ok := s.rows[id]
|
|
if !ok {
|
|
return sql.ErrNoRows
|
|
}
|
|
row.Revoked = true
|
|
row.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *adminWebhookStore) TouchLastUsed(_ context.Context, _ uuid.UUID) error { return nil }
|
|
|
|
// GetByHashUnscoped and GetByIDUnscoped are auth-middleware-only unscoped lookups.
|
|
// In admin tests the middleware is not exercised, so these are no-ops.
|
|
func (s *adminWebhookStore) GetByHashUnscoped(ctx context.Context, h string) (*store.WebhookData, error) {
|
|
return s.GetByHash(ctx, h)
|
|
}
|
|
func (s *adminWebhookStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.WebhookData, error) {
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// ---- stub TenantStore for admin tests ----
|
|
// Delegates GetUserRole to a configurable map; stubs everything else.
|
|
|
|
type adminTenantStore struct {
|
|
roles map[string]string // key = tenantID+":"+userID
|
|
}
|
|
|
|
func (a *adminTenantStore) key(tid uuid.UUID, uid string) string {
|
|
return tid.String() + ":" + uid
|
|
}
|
|
|
|
func (a *adminTenantStore) GetUserRole(_ context.Context, tid uuid.UUID, uid string) (string, error) {
|
|
if r, ok := a.roles[a.key(tid, uid)]; ok {
|
|
return r, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// Remaining store.TenantStore methods — no-op stubs.
|
|
func (a *adminTenantStore) CreateTenant(context.Context, *store.TenantData) error { return nil }
|
|
func (a *adminTenantStore) GetTenant(_ context.Context, _ uuid.UUID) (*store.TenantData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (a *adminTenantStore) GetTenantBySlug(_ context.Context, _ string) (*store.TenantData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (a *adminTenantStore) ListTenants(context.Context) ([]store.TenantData, error) { return nil, nil }
|
|
func (a *adminTenantStore) UpdateTenant(context.Context, uuid.UUID, map[string]any) error {
|
|
return nil
|
|
}
|
|
func (a *adminTenantStore) AddUser(context.Context, uuid.UUID, string, string) error { return nil }
|
|
func (a *adminTenantStore) RemoveUser(context.Context, uuid.UUID, string) error { return nil }
|
|
func (a *adminTenantStore) ListUsers(context.Context, uuid.UUID) ([]store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
func (a *adminTenantStore) ListUserTenants(context.Context, string) ([]store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
func (a *adminTenantStore) GetTenantsByIDs(context.Context, []uuid.UUID) ([]store.TenantData, error) {
|
|
return nil, nil
|
|
}
|
|
func (a *adminTenantStore) ResolveUserTenant(context.Context, string) (uuid.UUID, error) {
|
|
return uuid.Nil, sql.ErrNoRows
|
|
}
|
|
func (a *adminTenantStore) GetTenantUser(context.Context, uuid.UUID) (*store.TenantUserData, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
func (a *adminTenantStore) CreateTenantUserReturning(context.Context, uuid.UUID, string, string, string) (*store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
// webhookTenantAdminCtx builds a tenant-admin context for webhook admin tests.
|
|
// Named distinctly to avoid colliding with the packages_updates_test.go helper
|
|
// which has a different signature (base context.Context param).
|
|
func webhookTenantAdminCtx(tenantID uuid.UUID, userID string) context.Context {
|
|
ctx := context.Background()
|
|
ctx = store.WithTenantID(ctx, tenantID)
|
|
ctx = store.WithUserID(ctx, userID)
|
|
ctx = store.WithRole(ctx, "admin")
|
|
return ctx
|
|
}
|
|
|
|
func webhookTenantCtxWithRole(tenantID uuid.UUID, userID, role string) context.Context {
|
|
ctx := context.Background()
|
|
ctx = store.WithTenantID(ctx, tenantID)
|
|
ctx = store.WithUserID(ctx, userID)
|
|
ctx = store.WithRole(ctx, role)
|
|
return ctx
|
|
}
|
|
|
|
// testAdminEncKey is a 32-byte (256-bit) AES key used only in tests.
|
|
const testAdminEncKey = "00000000000000000000000000000000"
|
|
|
|
func newAdminHandler(ws *adminWebhookStore, ts *adminTenantStore) *WebhooksAdminHandler {
|
|
h := NewWebhooksAdminHandler(ws, ts, nil)
|
|
h.SetEncKey(testAdminEncKey) // required since K6 guard rejects empty encKey
|
|
return h
|
|
}
|
|
|
|
func doRequest(t *testing.T, h *WebhooksAdminHandler, method, path string, body any, ctx context.Context) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
|
t.Fatalf("encode body: %v", err)
|
|
}
|
|
}
|
|
r := httptest.NewRequest(method, path, &buf)
|
|
r = r.WithContext(ctx)
|
|
r.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// ---- tests ----
|
|
|
|
func TestWebhookAdmin_RouteRequiresHTTPAuth(t *testing.T) {
|
|
oldToken := pkgGatewayToken
|
|
oldFallback := pkgNoAuthFallbackAllowed
|
|
InitGatewayToken("required-token")
|
|
InitGatewayNoAuthFallbackAllowed(false)
|
|
defer func() {
|
|
InitGatewayToken(oldToken)
|
|
InitGatewayNoAuthFallbackAllowed(oldFallback)
|
|
}()
|
|
|
|
h := newAdminHandler(newAdminWebhookStore(), &adminTenantStore{})
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks", nil)
|
|
w := httptest.NewRecorder()
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated admin route, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Create_HappyPath verifies POST /v1/webhooks returns secret once.
|
|
func TestWebhookAdmin_Create_HappyPath(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "user-1"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "my webhook",
|
|
"kind": "llm",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webhookCreateResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Secret == "" {
|
|
t.Fatal("secret must be present in create response")
|
|
}
|
|
if resp.HMACSigningKey == "" {
|
|
t.Fatal("hmac_signing_key must be present in create response")
|
|
}
|
|
if resp.SecretPrefix == "" {
|
|
t.Fatal("secret_prefix must be present in create response")
|
|
}
|
|
// secret must start with wh_
|
|
if len(resp.Secret) < 3 || resp.Secret[:3] != "wh_" {
|
|
t.Fatalf("secret must start with wh_, got %q", resp.Secret)
|
|
}
|
|
// verify prefix matches first 8 chars of raw secret
|
|
if resp.SecretPrefix != resp.Secret[:8] {
|
|
t.Fatalf("prefix %q != first 8 chars of secret %q", resp.SecretPrefix, resp.Secret[:8])
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Create_NonAdmin_403 verifies non-admin cannot create.
|
|
func TestWebhookAdmin_Create_NonAdmin_403(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "user-2"
|
|
|
|
// operator role, not admin/owner
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: "operator",
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "x",
|
|
"kind": "llm",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhookAdmin_Create_ContextOperatorRoleDeniedBeforeTenantAdmin(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "operator-context"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantCtxWithRole(tenantID, userID, "operator")
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "x",
|
|
"kind": "llm",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Create_InvalidKind_400 verifies unknown kind is rejected.
|
|
func TestWebhookAdmin_Create_InvalidKind_400(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "user-3"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "x",
|
|
"kind": "unknown",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Create_LiteMessageKind_403 verifies Lite rejects kind=message.
|
|
func TestWebhookAdmin_Create_LiteMessageKind_403(t *testing.T) {
|
|
// Set Lite edition for this test, restore Standard after.
|
|
edition.SetCurrent(edition.Lite)
|
|
t.Cleanup(func() { edition.SetCurrent(edition.Standard) })
|
|
|
|
tenantID := uuid.New()
|
|
userID := "user-4"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "x",
|
|
"kind": "message",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403 for message kind on Lite, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Create_LiteForcesLocalhostOnly verifies Lite forces localhost_only=true.
|
|
func TestWebhookAdmin_Create_LiteForcesLocalhostOnly(t *testing.T) {
|
|
edition.SetCurrent(edition.Lite)
|
|
t.Cleanup(func() { edition.SetCurrent(edition.Standard) })
|
|
|
|
tenantID := uuid.New()
|
|
userID := "user-5"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
// Client sends localhost_only=false — server must override to true.
|
|
w := doRequest(t, h, http.MethodPost, "/v1/webhooks", map[string]any{
|
|
"name": "x",
|
|
"kind": "llm",
|
|
"localhost_only": false,
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webhookCreateResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !resp.LocalhostOnly {
|
|
t.Fatal("Lite edition must force localhost_only=true regardless of client input")
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Get_CrossTenant_404 verifies tenant A cannot see tenant B's webhook.
|
|
func TestWebhookAdmin_Get_CrossTenant_404(t *testing.T) {
|
|
tenantA := uuid.New()
|
|
tenantB := uuid.New()
|
|
userA := "user-a"
|
|
|
|
// Webhook owned by tenant B.
|
|
webhookID := uuid.New()
|
|
whB := &store.WebhookData{
|
|
ID: webhookID,
|
|
TenantID: tenantB,
|
|
Name: "b-webhook",
|
|
Kind: "llm",
|
|
}
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantA.String() + ":" + userA: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore(whB)
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
// Request from tenant A.
|
|
ctx := webhookTenantAdminCtx(tenantA, userA)
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks/"+webhookID.String(), nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404 for cross-tenant get, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_FullFlow_CreateListGetRotateRevoke exercises the happy path for all 6 endpoints.
|
|
func TestWebhookAdmin_FullFlow_CreateListGetRotateRevoke(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "user-flow"
|
|
|
|
ts := &adminTenantStore{
|
|
roles: map[string]string{
|
|
tenantID.String() + ":" + userID: store.TenantRoleAdmin,
|
|
},
|
|
}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// 1. Create.
|
|
var createResp webhookCreateResp
|
|
{
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(map[string]any{"name": "flow-wh", "kind": "llm"})
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks", &buf)
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
|
|
t.Fatalf("create decode: %v", err)
|
|
}
|
|
}
|
|
id := createResp.ID
|
|
originalSecret := createResp.Secret
|
|
|
|
// 2. List — must include newly created webhook.
|
|
{
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks", nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var rows []store.WebhookData
|
|
if err := json.NewDecoder(w.Body).Decode(&rows); err != nil {
|
|
t.Fatalf("list decode: %v", err)
|
|
}
|
|
found := false
|
|
for _, row := range rows {
|
|
if row.ID == id {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("list: newly created webhook not found")
|
|
}
|
|
}
|
|
|
|
// 3. Get.
|
|
{
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks/"+id.String(), nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var row store.WebhookData
|
|
if err := json.NewDecoder(w.Body).Decode(&row); err != nil {
|
|
t.Fatalf("get decode: %v", err)
|
|
}
|
|
// Secret must NOT be in normal GET response.
|
|
if row.SecretHash != "" {
|
|
// SecretHash has json:"-" tag so it should never appear.
|
|
// This check uses the decoded struct; field is blank as expected.
|
|
}
|
|
if row.ID != id {
|
|
t.Fatalf("get: wrong id %s", row.ID)
|
|
}
|
|
}
|
|
|
|
// 4. Rotate.
|
|
var rotateResp map[string]any
|
|
{
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/"+id.String()+"/rotate", nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("rotate: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&rotateResp); err != nil {
|
|
t.Fatalf("rotate decode: %v", err)
|
|
}
|
|
newSecret, _ := rotateResp["secret"].(string)
|
|
if newSecret == "" {
|
|
t.Fatal("rotate: new secret must be present")
|
|
}
|
|
if newSecret == originalSecret {
|
|
t.Fatal("rotate: new secret must differ from original")
|
|
}
|
|
}
|
|
|
|
// 5. Revoke.
|
|
{
|
|
r := httptest.NewRequest(http.MethodDelete, "/v1/webhooks/"+id.String(), nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("revoke: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 6. Get after revoke — row still exists (soft-delete) but is marked revoked.
|
|
{
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/webhooks/"+id.String(), nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get-after-revoke: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var row store.WebhookData
|
|
if err := json.NewDecoder(w.Body).Decode(&row); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !row.Revoked {
|
|
t.Fatal("row must be marked revoked after DELETE")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Patch_NonAdmin_403 verifies non-admin cannot patch.
|
|
func TestWebhookAdmin_Patch_NonAdmin_403(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "viewer"
|
|
|
|
ts := &adminTenantStore{roles: map[string]string{
|
|
tenantID.String() + ":" + userID: "viewer",
|
|
}}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
w := doRequest(t, h, http.MethodPatch, "/v1/webhooks/"+uuid.New().String(), map[string]any{
|
|
"name": "new name",
|
|
}, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Rotate_NonAdmin_403 verifies non-admin cannot rotate.
|
|
func TestWebhookAdmin_Rotate_NonAdmin_403(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "viewer2"
|
|
|
|
ts := &adminTenantStore{roles: map[string]string{
|
|
tenantID.String() + ":" + userID: "viewer",
|
|
}}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/"+uuid.New().String()+"/rotate", nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookAdmin_Revoke_NonAdmin_403 verifies non-admin cannot revoke.
|
|
func TestWebhookAdmin_Revoke_NonAdmin_403(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
userID := "viewer3"
|
|
|
|
ts := &adminTenantStore{roles: map[string]string{
|
|
tenantID.String() + ":" + userID: "viewer",
|
|
}}
|
|
ws := newAdminWebhookStore()
|
|
h := newAdminHandler(ws, ts)
|
|
|
|
ctx := webhookTenantAdminCtx(tenantID, userID)
|
|
r := httptest.NewRequest(http.MethodDelete, "/v1/webhooks/"+uuid.New().String(), nil)
|
|
r = r.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
mux.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGenerateWebhookSecret verifies the format and properties of generated secrets.
|
|
func TestGenerateWebhookSecret(t *testing.T) {
|
|
raw, hash, prefix, err := generateWebhookSecret()
|
|
if err != nil {
|
|
t.Fatalf("generate: %v", err)
|
|
}
|
|
if len(raw) < 3 || raw[:3] != "wh_" {
|
|
t.Fatalf("raw must start with wh_, got %q", raw)
|
|
}
|
|
if len(prefix) != 8 {
|
|
t.Fatalf("prefix must be 8 chars, got %d: %q", len(prefix), prefix)
|
|
}
|
|
if prefix != raw[:8] {
|
|
t.Fatalf("prefix %q != raw[:8] %q", prefix, raw[:8])
|
|
}
|
|
if len(hash) != 64 {
|
|
t.Fatalf("hash must be 64 hex chars (SHA-256), got %d", len(hash))
|
|
}
|
|
// Two calls must produce different secrets.
|
|
raw2, _, _, _ := generateWebhookSecret()
|
|
if raw == raw2 {
|
|
t.Fatal("secrets must be unique per generation")
|
|
}
|
|
}
|