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

242 lines
7.4 KiB
Go

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