//go:build integration // Package integration contains E2E tests that require external services. // Run with: go test -tags integration ./test/integration/ // The tests spin up a real Keycloak instance via testcontainers-go, obtain // a JWT token, and verify that the proxy auth middleware accepts / rejects it. package integration import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" tc "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/veylant/ia-gateway/internal/middleware" "github.com/veylant/ia-gateway/internal/provider" "github.com/veylant/ia-gateway/internal/proxy" "go.uber.org/zap" ) const ( keycloakImage = "quay.io/keycloak/keycloak:24.0" testRealm = "veylant-test" testClientID = "test-client" testClientSecret = "test-secret" testUsername = "testuser" testPassword = "testpass" ) // startKeycloak starts a Keycloak container and returns its base URL. func startKeycloak(t *testing.T) (baseURL string, cleanup func()) { t.Helper() ctx := context.Background() req := tc.ContainerRequest{ Image: keycloakImage, ExposedPorts: []string{"8080/tcp"}, Env: map[string]string{ "KC_BOOTSTRAP_ADMIN_USERNAME": "admin", "KC_BOOTSTRAP_ADMIN_PASSWORD": "admin", }, Cmd: []string{"start-dev"}, WaitingFor: wait.ForHTTP("/health/ready"). WithPort("8080/tcp"). WithStartupTimeout(120 * time.Second), } container, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ ContainerRequest: req, Started: true, }) require.NoError(t, err, "starting Keycloak container") host, err := container.Host(ctx) require.NoError(t, err) port, err := container.MappedPort(ctx, "8080") require.NoError(t, err) base := fmt.Sprintf("http://%s:%s", host, port.Port()) // Provision realm + client + test user via Keycloak Admin REST API. provisionKeycloak(t, base) return base, func() { _ = container.Terminate(ctx) } } // provisionKeycloak creates the test realm, client, and user via the Admin API. func provisionKeycloak(t *testing.T, baseURL string) { t.Helper() ctx := context.Background() // Obtain admin access token. adminToken := getAdminToken(t, baseURL) adminAPI := baseURL + "/admin/realms" client := &http.Client{Timeout: 10 * time.Second} doJSON := func(method, path string, body interface{}) { t.Helper() var r io.Reader if body != nil { b, _ := json.Marshal(body) r = strings.NewReader(string(b)) } req, _ := http.NewRequestWithContext(ctx, method, adminAPI+path, r) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Less(t, resp.StatusCode, 300, "Keycloak admin API %s %s returned %d", method, path, resp.StatusCode) } // Create realm. doJSON(http.MethodPost, "", map[string]interface{}{ "realm": testRealm, "enabled": true, }) // Create public client with direct access grants (password flow for testing). doJSON(http.MethodPost, "/"+testRealm+"/clients", map[string]interface{}{ "clientId": testClientID, "enabled": true, "publicClient": false, "secret": testClientSecret, "directAccessGrantsEnabled": true, "standardFlowEnabled": false, }) // Create test user. doJSON(http.MethodPost, "/"+testRealm+"/users", map[string]interface{}{ "username": testUsername, "email": testUsername + "@example.com", "enabled": true, "emailVerified": true, "credentials": []map[string]interface{}{ {"type": "password", "value": testPassword, "temporary": false}, }, }) } func getAdminToken(t *testing.T, baseURL string) string { t.Helper() resp, err := http.PostForm( baseURL+"/realms/master/protocol/openid-connect/token", url.Values{ "grant_type": {"password"}, "client_id": {"admin-cli"}, "username": {"admin"}, "password": {"admin"}, }, ) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck var tok struct { AccessToken string `json:"access_token"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok)) return tok.AccessToken } func getUserToken(t *testing.T, baseURL string) string { t.Helper() resp, err := http.PostForm( baseURL+"/realms/"+testRealm+"/protocol/openid-connect/token", url.Values{ "grant_type": {"password"}, "client_id": {testClientID}, "client_secret": {testClientSecret}, "username": {testUsername}, "password": {testPassword}, }, ) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck var tok struct { AccessToken string `json:"access_token"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok)) require.NotEmpty(t, tok.AccessToken, "could not obtain user token") return tok.AccessToken } // TestAuth_Integration_ValidToken_Returns200 obtains a real JWT from a live // Keycloak instance and verifies that the Auth middleware + proxy handler // accept it and call the next handler. func TestAuth_Integration_ValidToken_Returns200(t *testing.T) { keycloakBase, cleanup := startKeycloak(t) defer cleanup() issuerURL := keycloakBase + "/realms/" + testRealm verifier, err := middleware.NewOIDCVerifier(context.Background(), issuerURL, testClientID) require.NoError(t, err) // Build a minimal proxy handler backed by a stub adapter. stub := &stubIntegrationAdapter{} handler := proxy.New(stub, zap.NewNop(), nil) // Wire up the auth middleware. protected := middleware.Auth(verifier)(handler) // Obtain a real JWT. token := getUserToken(t, keycloakBase) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`)) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() protected.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) } // TestAuth_Integration_NoToken_Returns401 verifies that the Auth middleware // rejects requests without a token with 401. func TestAuth_Integration_NoToken_Returns401(t *testing.T) { // We do not need a running Keycloak for this test — the missing token is // caught before any JWKS call. verifier := &middleware.MockVerifier{} // won't be called handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`)) rec := httptest.NewRecorder() middleware.Auth(verifier)(handler).ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) } // stubIntegrationAdapter is a minimal provider.Adapter for integration tests. type stubIntegrationAdapter struct{} func (s *stubIntegrationAdapter) Validate(req *provider.ChatRequest) error { return nil } func (s *stubIntegrationAdapter) Send(_ context.Context, _ *provider.ChatRequest) (*provider.ChatResponse, error) { return &provider.ChatResponse{ID: "integ-test", Model: "gpt-4o"}, nil } func (s *stubIntegrationAdapter) Stream(_ context.Context, _ *provider.ChatRequest, w http.ResponseWriter) error { fmt.Fprintf(w, "data: [DONE]\n\n") //nolint:errcheck return nil } func (s *stubIntegrationAdapter) HealthCheck(_ context.Context) error { return nil }