package httpapi import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type fakeOIDCUserResolver struct { result store.ResolveOrProvisionOIDCUserResult err error calls int input store.ResolveOrProvisionOIDCUserInput } func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) { f.calls++ f.input = input return f.result, f.err } func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{ ID: "platform-user", Username: "alice", Roles: []string{"basic"}, TenantID: "external-tenant", Source: "oidc", GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3", GatewayTenantID: "8f17f3ac-136e-4d0f-b097-655e2a6240a3", TenantKey: "default", UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0", }}} server := &Server{ cfg: config.Config{ OIDCIssuer: "https://auth.test.example/realms/easyai", OIDCGatewayTenantKey: "default", OIDCJITProvisioningEnabled: true, }, oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok || user.GatewayUserID == "" || user.GatewayTenantID == "" || user.UserGroupID == "" { t.Fatalf("resolved Gateway context missing: %+v", user) } writeJSON(w, http.StatusOK, user) }) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ ID: "platform-user", Username: "alice", Roles: []string{"basic"}, TenantID: "external-tenant", Source: "oidc", })) recorder := httptest.NewRecorder() server.resolveGatewayUser(next).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want 200", recorder.Code) } if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled { t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input) } } func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) { for _, source := range []string{"gateway", "api_key", "server-main"} { t.Run(source, func(t *testing.T) { resolver := &fakeOIDCUserResolver{} server := &Server{oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) original := &auth.User{ID: "local-user", Source: source, GatewayUserID: "local-user"} request = request.WithContext(auth.WithUser(request.Context(), original)) recorder := httptest.NewRecorder() server.resolveGatewayUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resolved, _ := auth.UserFromContext(r.Context()) if resolved != original { t.Fatalf("non-OIDC identity context was replaced: %+v", resolved) } w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request) if recorder.Code != http.StatusNoContent || resolver.calls != 0 { t.Fatalf("status=%d resolver calls=%d", recorder.Code, resolver.calls) } }) } } func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) { tests := []struct { name string err error status int code string }{ {name: "not provisioned", err: store.ErrOIDCUserNotProvisioned, status: http.StatusForbidden, code: "GATEWAY_USER_NOT_PROVISIONED"}, {name: "disabled", err: store.ErrOIDCUserDisabled, status: http.StatusForbidden, code: "GATEWAY_USER_DISABLED"}, {name: "tenant unavailable", err: store.ErrOIDCTenantUnavailable, status: http.StatusServiceUnavailable, code: "GATEWAY_TENANT_UNAVAILABLE"}, {name: "storage failure", err: errors.New("database unavailable"), status: http.StatusServiceUnavailable, code: "GATEWAY_USER_PROVISIONING_FAILED"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := &Server{ cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"}, oidcUserResolver: &fakeOIDCUserResolver{err: test.err}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ ID: "platform-user", Source: "oidc", TenantID: "external-tenant", })) recorder := httptest.NewRecorder() server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("next handler must not run") })).ServeHTTP(recorder, request) if recorder.Code != test.status { t.Fatalf("status = %d, want %d", recorder.Code, test.status) } var envelope struct { Error struct { Code string `json:"code"` Message string `json:"message"` Status int `json:"status"` } `json:"error"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode error envelope: %v", err) } if envelope.Error.Code != test.code || envelope.Error.Status != test.status || envelope.Error.Message == "" { t.Fatalf("unexpected error envelope: %+v", envelope) } if envelope.Error.Message == test.err.Error() { t.Fatalf("internal error leaked to response: %q", envelope.Error.Message) } }) } }