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) }