veylant/internal/admin/handler_test.go
2026-02-23 13:35:04 +01:00

246 lines
8.0 KiB
Go

package admin_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/veylant/ia-gateway/internal/admin"
"github.com/veylant/ia-gateway/internal/middleware"
"github.com/veylant/ia-gateway/internal/routing"
)
const testTenantID = "tenant-test"
// ─── Test fixtures ────────────────────────────────────────────────────────────
func setupHandler(t *testing.T) (*admin.Handler, *routing.MemStore, *routing.RuleCache) {
t.Helper()
store := routing.NewMemStore()
cache := routing.NewRuleCache(store, 30*time.Second, zap.NewNop())
h := admin.New(store, cache, zap.NewNop())
return h, store, cache
}
// authCtx returns a request context with tenant JWT claims.
func authCtx(tenantID string) context.Context {
return middleware.WithClaims(context.Background(), &middleware.UserClaims{
UserID: "admin-user",
TenantID: tenantID,
Roles: []string{"admin"},
})
}
// newRouter builds a chi.Router with the handler routes mounted.
func newRouter(h *admin.Handler) chi.Router {
r := chi.NewRouter()
h.Routes(r)
return r
}
// postJSON sends a POST with JSON body.
func postJSON(t *testing.T, router http.Handler, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func getReq(t *testing.T, router http.Handler, path string, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func deleteReq(t *testing.T, router http.Handler, path string, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, path, nil)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// ─── Tests ────────────────────────────────────────────────────────────────────
func TestAdminHandler_Create_ReturnsCreated(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
body := map[string]interface{}{
"name": "finance rule",
"priority": 10,
"is_enabled": true,
"conditions": []map[string]interface{}{
{"field": "user.department", "operator": "eq", "value": "finance"},
},
"action": map[string]interface{}{"provider": "ollama"},
}
rec := postJSON(t, r, "/policies", body, authCtx(testTenantID))
assert.Equal(t, http.StatusCreated, rec.Code)
var got routing.RoutingRule
require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
assert.Equal(t, "finance rule", got.Name)
assert.Equal(t, testTenantID, got.TenantID)
}
func TestAdminHandler_Create_InvalidCondition_Returns400(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
body := map[string]interface{}{
"name": "bad rule",
"conditions": []map[string]interface{}{
{"field": "user.unknown_field", "operator": "eq", "value": "x"},
},
"action": map[string]interface{}{"provider": "openai"},
}
rec := postJSON(t, r, "/policies", body, authCtx(testTenantID))
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAdminHandler_Create_MissingName_Returns400(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
body := map[string]interface{}{
"conditions": []map[string]interface{}{},
"action": map[string]interface{}{"provider": "openai"},
}
rec := postJSON(t, r, "/policies", body, authCtx(testTenantID))
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAdminHandler_List_ReturnsTenantRules(t *testing.T) {
h, store, _ := setupHandler(t)
r := newRouter(h)
// Seed two rules: one for testTenantID, one for another tenant.
_, _ = store.Create(context.Background(), routing.RoutingRule{
TenantID: testTenantID, Name: "r1", IsEnabled: true,
Conditions: []routing.Condition{}, Action: routing.Action{Provider: "openai"},
})
_, _ = store.Create(context.Background(), routing.RoutingRule{
TenantID: "other-tenant", Name: "r2", IsEnabled: true,
Conditions: []routing.Condition{}, Action: routing.Action{Provider: "openai"},
})
rec := getReq(t, r, "/policies", authCtx(testTenantID))
assert.Equal(t, http.StatusOK, rec.Code)
var resp map[string][]routing.RoutingRule
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
// Only the rule for testTenantID should be visible.
assert.Len(t, resp["data"], 1)
assert.Equal(t, "r1", resp["data"][0].Name)
}
func TestAdminHandler_Get_ExistingRule(t *testing.T) {
h, store, _ := setupHandler(t)
r := newRouter(h)
rule, _ := store.Create(context.Background(), routing.RoutingRule{
TenantID: testTenantID, Name: "my-rule", IsEnabled: true,
Conditions: []routing.Condition{}, Action: routing.Action{Provider: "openai"},
})
rec := getReq(t, r, "/policies/"+rule.ID, authCtx(testTenantID))
assert.Equal(t, http.StatusOK, rec.Code)
var got routing.RoutingRule
require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
assert.Equal(t, "my-rule", got.Name)
}
func TestAdminHandler_Get_NotFound_Returns404(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
rec := getReq(t, r, "/policies/nonexistent-id", authCtx(testTenantID))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAdminHandler_Delete_RemovesRule(t *testing.T) {
h, store, _ := setupHandler(t)
r := newRouter(h)
rule, _ := store.Create(context.Background(), routing.RoutingRule{
TenantID: testTenantID, Name: "to-delete", IsEnabled: true,
Conditions: []routing.Condition{}, Action: routing.Action{Provider: "openai"},
})
rec := deleteReq(t, r, "/policies/"+rule.ID, authCtx(testTenantID))
assert.Equal(t, http.StatusNoContent, rec.Code)
// Second delete should return 404.
rec2 := deleteReq(t, r, "/policies/"+rule.ID, authCtx(testTenantID))
assert.Equal(t, http.StatusNotFound, rec2.Code)
}
func TestAdminHandler_TenantIsolation_CannotDeleteOtherTenantRule(t *testing.T) {
h, store, _ := setupHandler(t)
r := newRouter(h)
// Rule belongs to another tenant.
rule, _ := store.Create(context.Background(), routing.RoutingRule{
TenantID: "other-tenant", Name: "private-rule", IsEnabled: true,
Conditions: []routing.Condition{}, Action: routing.Action{Provider: "openai"},
})
// testTenantID cannot delete a rule that belongs to other-tenant — returns 404.
rec := deleteReq(t, r, "/policies/"+rule.ID, authCtx(testTenantID))
assert.Equal(t, http.StatusNotFound, rec.Code, "cannot delete another tenant's rule")
}
func TestAdminHandler_SeedTemplate_Catchall(t *testing.T) {
h, _, cache := setupHandler(t)
r := newRouter(h)
// Pre-populate cache to verify it gets invalidated.
_, _ = cache.Get(context.Background(), testTenantID)
rec := postJSON(t, r, "/policies/seed/catchall", nil, authCtx(testTenantID))
assert.Equal(t, http.StatusCreated, rec.Code)
var got routing.RoutingRule
require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
assert.Equal(t, 9999, got.Priority)
assert.Equal(t, "openai", got.Action.Provider)
}
func TestAdminHandler_SeedTemplate_UnknownTemplate_Returns400(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
rec := postJSON(t, r, "/policies/seed/unknown", nil, authCtx(testTenantID))
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAdminHandler_NoAuth_Returns401(t *testing.T) {
h, _, _ := setupHandler(t)
r := newRouter(h)
req := httptest.NewRequest(http.MethodGet, "/policies", nil)
// No claims in context.
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}