veylant/test/integration/e2e_admin_test.go
2026-02-23 13:35:04 +01:00

579 lines
20 KiB
Go

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