//go:build integration // Package integration contains Sprint 11 E2E tests (E11-01b). // Batch 2 covers: admin policies CRUD, feature flags admin API, audit log // query, cost aggregation, compliance entry CRUD, and GDPR erasure. // // Run with: go test -tags integration -v ./test/integration/ -run TestE2E_Admin // All tests use httptest.Server + in-memory stubs — no external services needed. package integration import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "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/auditlog" "github.com/veylant/ia-gateway/internal/compliance" "github.com/veylant/ia-gateway/internal/flags" "github.com/veylant/ia-gateway/internal/middleware" "github.com/veylant/ia-gateway/internal/routing" ) // ─── Admin server builder ───────────────────────────────────────────────────── type adminServerOptions struct { store routing.RuleStore auditLog auditlog.Logger flagStore flags.FlagStore verifier middleware.TokenVerifier // compStore: if nil, a memComplianceStore is created automatically compStore compliance.ComplianceStore } // buildAdminServer creates a fully wired httptest.Server exposing /v1/admin/* // and /v1/admin/compliance/* with in-memory stores. No external services needed. func buildAdminServer(t *testing.T, opts adminServerOptions) *httptest.Server { t.Helper() if opts.store == nil { opts.store = routing.NewMemStore() } if opts.auditLog == nil { opts.auditLog = auditlog.NewMemLogger() } if opts.flagStore == nil { opts.flagStore = flags.NewMemFlagStore() } if opts.verifier == nil { opts.verifier = &middleware.MockVerifier{Claims: e2eAdminClaims()} } if opts.compStore == nil { opts.compStore = newMemComplianceStore() } cache := routing.NewRuleCache(opts.store, 5*time.Minute, zap.NewNop()) adminHandler := admin.NewWithAudit(opts.store, cache, opts.auditLog, zap.NewNop()). WithFlagStore(opts.flagStore) compHandler := compliance.New(opts.compStore, zap.NewNop()). WithAudit(opts.auditLog) r := chi.NewRouter() r.Use(chimiddleware.Recoverer) r.Route("/v1", func(r chi.Router) { r.Use(middleware.Auth(opts.verifier)) r.Route("/admin", adminHandler.Routes) r.Route("/admin/compliance", compHandler.Routes) }) srv := httptest.NewServer(r) t.Cleanup(srv.Close) return srv } // ─── In-memory ComplianceStore for tests ───────────────────────────────────── type memComplianceStore struct { mu sync.Mutex entries map[string]compliance.ProcessingEntry seq int } func newMemComplianceStore() *memComplianceStore { return &memComplianceStore{entries: make(map[string]compliance.ProcessingEntry)} } func (m *memComplianceStore) List(_ context.Context, tenantID string) ([]compliance.ProcessingEntry, error) { m.mu.Lock() defer m.mu.Unlock() var out []compliance.ProcessingEntry for _, e := range m.entries { if e.TenantID == tenantID { out = append(out, e) } } return out, nil } func (m *memComplianceStore) Get(_ context.Context, id, tenantID string) (compliance.ProcessingEntry, error) { m.mu.Lock() defer m.mu.Unlock() e, ok := m.entries[id] if !ok || e.TenantID != tenantID { return compliance.ProcessingEntry{}, compliance.ErrNotFound } return e, nil } func (m *memComplianceStore) Create(_ context.Context, entry compliance.ProcessingEntry) (compliance.ProcessingEntry, error) { m.mu.Lock() defer m.mu.Unlock() m.seq++ entry.ID = fmt.Sprintf("comp-%d", m.seq) entry.CreatedAt = time.Now() entry.UpdatedAt = time.Now() m.entries[entry.ID] = entry return entry, nil } func (m *memComplianceStore) Update(_ context.Context, entry compliance.ProcessingEntry) (compliance.ProcessingEntry, error) { m.mu.Lock() defer m.mu.Unlock() existing, ok := m.entries[entry.ID] if !ok || existing.TenantID != entry.TenantID { return compliance.ProcessingEntry{}, compliance.ErrNotFound } entry.CreatedAt = existing.CreatedAt entry.UpdatedAt = time.Now() m.entries[entry.ID] = entry return entry, nil } func (m *memComplianceStore) Delete(_ context.Context, id, tenantID string) error { m.mu.Lock() defer m.mu.Unlock() e, ok := m.entries[id] if !ok || e.TenantID != tenantID { return compliance.ErrNotFound } delete(m.entries, id) return nil } // ─── Helper: doAdminJSON ────────────────────────────────────────────────────── // doAdminJSON sends an authenticated JSON request to the test server. func doAdminJSON(t *testing.T, method, url string, body interface{}) *http.Response { t.Helper() return doJSON(t, http.DefaultClient, method, url, "test-token", body) } // decodeBody decodes JSON body into dst, draining the response body. func decodeBody(t *testing.T, resp *http.Response, dst interface{}) { t.Helper() defer resp.Body.Close() require.NoError(t, json.NewDecoder(resp.Body).Decode(dst)) } // ─── E2E tests: Admin Policies ──────────────────────────────────────────────── // TestE2E_Admin_Policies_List_Empty verifies GET /v1/admin/policies returns // 200 with an empty data array when no policies have been created. func TestE2E_Admin_Policies_List_Empty(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/policies", nil) assert.Equal(t, http.StatusOK, resp.StatusCode) var body struct { Data []interface{} `json:"data"` } decodeBody(t, resp, &body) assert.Empty(t, body.Data) } // TestE2E_Admin_Policies_Create_Returns201 verifies POST /v1/admin/policies // creates a policy and returns 201 with a non-empty ID. func TestE2E_Admin_Policies_Create_Returns201(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) payload := map[string]interface{}{ "name": "HR policy", "priority": 10, "is_enabled": true, "conditions": []map[string]interface{}{}, "action": map[string]string{"provider": "openai", "model": "gpt-4o"}, } resp := doAdminJSON(t, http.MethodPost, srv.URL+"/v1/admin/policies", payload) assert.Equal(t, http.StatusCreated, resp.StatusCode) var created routing.RoutingRule decodeBody(t, resp, &created) assert.NotEmpty(t, created.ID) assert.Equal(t, "HR policy", created.Name) assert.Equal(t, e2eTenantID, created.TenantID) } // TestE2E_Admin_Policies_Update_Returns200 creates a policy then updates it // and verifies the returned rule contains the new name. func TestE2E_Admin_Policies_Update_Returns200(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) url := srv.URL + "/v1/admin/policies" // Create payload := map[string]interface{}{ "name": "initial", "is_enabled": true, "conditions": []map[string]interface{}{}, "action": map[string]string{"provider": "openai", "model": "gpt-4o"}, } createResp := doAdminJSON(t, http.MethodPost, url, payload) require.Equal(t, http.StatusCreated, createResp.StatusCode) var created routing.RoutingRule decodeBody(t, createResp, &created) // Update payload["name"] = "updated" updateResp := doAdminJSON(t, http.MethodPut, url+"/"+created.ID, payload) assert.Equal(t, http.StatusOK, updateResp.StatusCode) var updated routing.RoutingRule decodeBody(t, updateResp, &updated) assert.Equal(t, "updated", updated.Name) assert.Equal(t, created.ID, updated.ID) } // TestE2E_Admin_Policies_Delete_Returns204 creates a policy then deletes it, // verifying 204 No Content on delete and 404 on subsequent GET. func TestE2E_Admin_Policies_Delete_Returns204(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) url := srv.URL + "/v1/admin/policies" // Create payload := map[string]interface{}{ "name": "to-delete", "is_enabled": true, "conditions": []map[string]interface{}{}, "action": map[string]string{"provider": "openai", "model": "gpt-4o"}, } createResp := doAdminJSON(t, http.MethodPost, url, payload) require.Equal(t, http.StatusCreated, createResp.StatusCode) var created routing.RoutingRule decodeBody(t, createResp, &created) // Delete delResp := doAdminJSON(t, http.MethodDelete, url+"/"+created.ID, nil) assert.Equal(t, http.StatusNoContent, delResp.StatusCode) delResp.Body.Close() // GET should be 404 getResp := doAdminJSON(t, http.MethodGet, url+"/"+created.ID, nil) assert.Equal(t, http.StatusNotFound, getResp.StatusCode) getResp.Body.Close() } // TestE2E_Admin_Policies_SeedTemplate_HR verifies that POST // /v1/admin/policies/seed/hr returns 201 and creates a rule in the store. func TestE2E_Admin_Policies_SeedTemplate_HR(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) resp := doAdminJSON(t, http.MethodPost, srv.URL+"/v1/admin/policies/seed/hr", nil) assert.Equal(t, http.StatusCreated, resp.StatusCode) var created routing.RoutingRule decodeBody(t, resp, &created) assert.NotEmpty(t, created.ID) assert.NotEmpty(t, created.Name) assert.Equal(t, e2eTenantID, created.TenantID) } // ─── E2E tests: Feature Flags Admin API ────────────────────────────────────── // TestE2E_Admin_Flags_List_Set_Delete exercises the full flag lifecycle: // GET (empty), PUT to set, GET (present), DELETE, GET (gone). func TestE2E_Admin_Flags_List_Set_Delete(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) flagsURL := srv.URL + "/v1/admin/flags" // GET initial state — may contain global defaults; check 200 at minimum. listResp := doAdminJSON(t, http.MethodGet, flagsURL, nil) assert.Equal(t, http.StatusOK, listResp.StatusCode) listResp.Body.Close() // PUT — disable pii_enabled for this tenant putResp := doAdminJSON(t, http.MethodPut, flagsURL+"/pii_enabled", map[string]bool{"enabled": false}) assert.Equal(t, http.StatusOK, putResp.StatusCode) var flag flags.FeatureFlag decodeBody(t, putResp, &flag) assert.Equal(t, "pii_enabled", flag.Name) assert.False(t, flag.IsEnabled) // GET list — should now include pii_enabled for this tenant listResp2 := doAdminJSON(t, http.MethodGet, flagsURL, nil) assert.Equal(t, http.StatusOK, listResp2.StatusCode) var listBody struct { Data []flags.FeatureFlag `json:"data"` } decodeBody(t, listResp2, &listBody) found := false for _, f := range listBody.Data { if f.Name == "pii_enabled" && f.TenantID == e2eTenantID { found = true assert.False(t, f.IsEnabled) } } assert.True(t, found, "pii_enabled should be in the flag list after PUT") // DELETE delResp := doAdminJSON(t, http.MethodDelete, flagsURL+"/pii_enabled", nil) assert.Equal(t, http.StatusNoContent, delResp.StatusCode) delResp.Body.Close() // After DELETE, the tenant-specific flag should be gone. // A subsequent GET /flags should not include the tenant-specific pii_enabled. listResp3 := doAdminJSON(t, http.MethodGet, flagsURL, nil) assert.Equal(t, http.StatusOK, listResp3.StatusCode) var listBody3 struct { Data []flags.FeatureFlag `json:"data"` } decodeBody(t, listResp3, &listBody3) for _, f := range listBody3.Data { if f.Name == "pii_enabled" { assert.NotEqual(t, e2eTenantID, f.TenantID, "tenant-specific pii_enabled should be deleted") } } } // ─── E2E tests: Audit Logs ──────────────────────────────────────────────────── // TestE2E_Admin_Logs_Query_ReturnsEntries pre-seeds a MemLogger with one // entry and verifies GET /v1/admin/logs returns it. func TestE2E_Admin_Logs_Query_ReturnsEntries(t *testing.T) { memLog := auditlog.NewMemLogger() memLog.Log(auditlog.AuditEntry{ RequestID: "req-e2e-01", TenantID: e2eTenantID, UserID: e2eUserID, Timestamp: time.Now(), ModelRequested: "gpt-4o", Provider: "openai", TokenTotal: 42, Status: "ok", }) srv := buildAdminServer(t, adminServerOptions{auditLog: memLog}) resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/logs", nil) assert.Equal(t, http.StatusOK, resp.StatusCode) var result auditlog.AuditResult decodeBody(t, resp, &result) require.Equal(t, 1, result.Total, "should have exactly one log entry") assert.Equal(t, "req-e2e-01", result.Data[0].RequestID) } // ─── E2E tests: Costs ───────────────────────────────────────────────────────── // TestE2E_Admin_Costs_GroupBy_Provider verifies GET /v1/admin/costs?group_by=provider // returns a data array that includes an openai cost summary. func TestE2E_Admin_Costs_GroupBy_Provider(t *testing.T) { memLog := auditlog.NewMemLogger() // Seed two entries with different providers for i := 0; i < 3; i++ { memLog.Log(auditlog.AuditEntry{ RequestID: fmt.Sprintf("req-%d", i), TenantID: e2eTenantID, Provider: "openai", TokenTotal: 100, CostUSD: 0.002, Status: "ok", }) } memLog.Log(auditlog.AuditEntry{ RequestID: "req-mistral", TenantID: e2eTenantID, Provider: "mistral", TokenTotal: 50, CostUSD: 0.001, Status: "ok", }) srv := buildAdminServer(t, adminServerOptions{auditLog: memLog}) resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/costs?group_by=provider", nil) assert.Equal(t, http.StatusOK, resp.StatusCode) var result auditlog.CostResult decodeBody(t, resp, &result) require.NotEmpty(t, result.Data) keySet := make(map[string]bool) for _, s := range result.Data { keySet[s.Key] = true } assert.True(t, keySet["openai"], "openai should appear in cost breakdown") assert.True(t, keySet["mistral"], "mistral should appear in cost breakdown") } // ─── E2E tests: Compliance Entry CRUD ──────────────────────────────────────── // TestE2E_Compliance_Entry_CRUD exercises the full processing entry lifecycle: // POST → GET → PUT → DELETE. func TestE2E_Compliance_Entry_CRUD(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) baseURL := srv.URL + "/v1/admin/compliance/entries" // POST — create entry createPayload := map[string]interface{}{ "use_case_name": "Chatbot RH", "legal_basis": "legitimate_interest", "purpose": "Automatisation des réponses RH internes", "data_categories": []string{"identifiers", "professional"}, "recipients": []string{"HR team"}, "processors": []string{"OpenAI Inc."}, "retention_period": "12 months", "security_measures": "AES-256 encryption, access control", "controller_name": "Veylant E2E Corp", } createResp := doAdminJSON(t, http.MethodPost, baseURL, createPayload) require.Equal(t, http.StatusCreated, createResp.StatusCode) var created compliance.ProcessingEntry decodeBody(t, createResp, &created) require.NotEmpty(t, created.ID) assert.Equal(t, "Chatbot RH", created.UseCaseName) assert.Equal(t, e2eTenantID, created.TenantID) // GET — retrieve entry getResp := doAdminJSON(t, http.MethodGet, baseURL+"/"+created.ID, nil) assert.Equal(t, http.StatusOK, getResp.StatusCode) var fetched compliance.ProcessingEntry decodeBody(t, getResp, &fetched) assert.Equal(t, created.ID, fetched.ID) // PUT — update entry createPayload["use_case_name"] = "Chatbot RH v2" updateResp := doAdminJSON(t, http.MethodPut, baseURL+"/"+created.ID, createPayload) assert.Equal(t, http.StatusOK, updateResp.StatusCode) var updated compliance.ProcessingEntry decodeBody(t, updateResp, &updated) assert.Equal(t, "Chatbot RH v2", updated.UseCaseName) // DELETE — remove entry delResp := doAdminJSON(t, http.MethodDelete, baseURL+"/"+created.ID, nil) assert.Equal(t, http.StatusNoContent, delResp.StatusCode) delResp.Body.Close() // GET after delete — should be 404 get2Resp := doAdminJSON(t, http.MethodGet, baseURL+"/"+created.ID, nil) assert.Equal(t, http.StatusNotFound, get2Resp.StatusCode) get2Resp.Body.Close() } // ─── E2E tests: GDPR Erasure ───────────────────────────────────────────────── // TestE2E_Compliance_GDPR_Erase_Returns200 verifies DELETE // /v1/admin/compliance/gdpr/erase/{user_id} returns 200 with status "completed". // The handler works without a DB connection (db=nil path is exercised). func TestE2E_Compliance_GDPR_Erase_Returns200(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) targetUser := "user-to-erase@corp.example" resp, err := makeRequest(t, http.MethodDelete, srv.URL+"/v1/admin/compliance/gdpr/erase/"+targetUser+"?reason=test-erase", "test-token", nil, ) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) var erasure compliance.ErasureRecord decodeBody(t, resp, &erasure) assert.Equal(t, "completed", erasure.Status) assert.Equal(t, targetUser, erasure.TargetUser) assert.Equal(t, e2eTenantID, erasure.TenantID) } // makeRequest is a low-level helper for requests that don't have a JSON body. func makeRequest(t *testing.T, method, url, token string, body []byte) (*http.Response, error) { t.Helper() var bodyReader *bytes.Reader if body != nil { bodyReader = bytes.NewReader(body) } else { bodyReader = bytes.NewReader(nil) } req, err := http.NewRequestWithContext(context.Background(), method, url, bodyReader) require.NoError(t, err) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return http.DefaultClient.Do(req) } // ─── E2E tests: Admin server — no-auth guard ────────────────────────────────── // TestE2E_Admin_NoToken_Returns401 verifies that all admin routes are // protected: a request without a token receives 401. func TestE2E_Admin_NoToken_Returns401(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/v1/admin/policies", nil) require.NoError(t, err) // No Authorization header resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } // ─── E2E tests: Routing flag via proxy ──────────────────────────────────────── // TestE2E_Admin_Flags_Routing_Disabled_StillRoutes verifies that when // routing_enabled is set to false for a tenant, the proxy falls back to // static prefix rules and still returns 200 (not an error). func TestE2E_Admin_Flags_Routing_Disabled_StillRoutes(t *testing.T) { fs := flags.NewMemFlagStore() // Explicitly disable routing for the E2E tenant _, err := fs.Set(context.Background(), e2eTenantID, "routing_enabled", false) require.NoError(t, err) // Build proxy server using the flag store with routing disabled srv := buildProxyServer(t, proxyServerOptions{flagStore: fs}) resp := doJSON(t, http.DefaultClient, http.MethodPost, srv.URL+"/v1/chat/completions", "test-token", chatBody("gpt-4o", false), ) defer resp.Body.Close() // Should still succeed — static prefix fallback routes gpt-4o → openai stub assert.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) choices, _ := body["choices"].([]interface{}) assert.NotEmpty(t, choices) } // ─── E2E tests: Compliance list empty ──────────────────────────────────────── // TestE2E_Compliance_Entries_List_Empty verifies that GET /v1/admin/compliance/entries // returns 200 with an empty data array on a fresh store. func TestE2E_Compliance_Entries_List_Empty(t *testing.T) { srv := buildAdminServer(t, adminServerOptions{}) resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/compliance/entries", nil) assert.Equal(t, http.StatusOK, resp.StatusCode) var body struct { Data []interface{} `json:"data"` } decodeBody(t, resp, &body) assert.Empty(t, body.Data) }