242 lines
7.4 KiB
Go
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 }
|