From e1e20b37f4ae7caabc682e0f0ca8918d1eb3025e Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Thu, 16 Apr 2026 14:03:47 +0800 Subject: [PATCH 1/2] Fix composite field must have selection set rule --- .../Rules/LeafFieldSelectionsRule.cs | 4 +- .../Fusion.AspNetCore.Tests/FusionTestBase.cs | 16 +++ .../IntrospectionTests.cs | 70 +++++++++++ ...ction_Field_Type_Without_SelectionSet.yaml | 115 ++++++++++++++++++ ...ospection_OfType_Without_SelectionSet.yaml | 115 ++++++++++++++++++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Field_Type_Without_SelectionSet.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_OfType_Without_SelectionSet.yaml diff --git a/src/HotChocolate/Core/src/Validation/Rules/LeafFieldSelectionsRule.cs b/src/HotChocolate/Core/src/Validation/Rules/LeafFieldSelectionsRule.cs index 53eac70e857..0a36c3ff464 100644 --- a/src/HotChocolate/Core/src/Validation/Rules/LeafFieldSelectionsRule.cs +++ b/src/HotChocolate/Core/src/Validation/Rules/LeafFieldSelectionsRule.cs @@ -80,7 +80,9 @@ private void ValidateField( FieldNode field, IType parentType) { - if (parentType is not IComplexTypeDefinition complex + var namedParentType = parentType.NamedType(); + + if (namedParentType is not IComplexTypeDefinition complex || !complex.Fields.TryGetField(field.Name.Value, out var fieldDef)) { // handled by other rules like KnownFieldNames diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs index 69013eb7249..7d1d59fe41e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs @@ -21,6 +21,8 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Xunit.Sdk; @@ -179,6 +181,12 @@ protected async Task CreateCompositeSchemaAsync( services.Add(serviceDescriptor); } + // Default to Development so security policies added by AddGraphQLGatewayServer + // do not interfere with tests. Individual tests that need production behavior + // can override IHostEnvironment via their own configureServices callback. + services.AddSingleton( + new TestHostEnvironment(Environments.Development)); + configureServices?.Invoke(services); }, configureApplication); @@ -398,4 +406,12 @@ private class EmptyMemoryOwner : IMemoryOwner public void Dispose() { } } + + internal sealed class TestHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + public string ApplicationName { get; set; } = "TestApp"; + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/IntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/IntrospectionTests.cs index ce21f3dabc8..36dff816edb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/IntrospectionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/IntrospectionTests.cs @@ -358,6 +358,76 @@ public async Task Typename_On_Introspection_Types() await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Introspection_OfType_Without_SelectionSet() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + TestSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + __schema { + types { + ofType + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Introspection_Field_Type_Without_SelectionSet() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + TestSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + __type(name: "Query") { + fields { + type + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + [Theory] [InlineData("SchemaCapabilitiesQuery")] [InlineData("InputValueCapabilitiesQuery")] diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Field_Type_Without_SelectionSet.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Field_Type_Without_SelectionSet.yaml new file mode 100644 index 00000000000..58715cd79f2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Field_Type_Without_SelectionSet.yaml @@ -0,0 +1,115 @@ +title: Introspection_Field_Type_Without_SelectionSet +request: + document: | + { + __type(name: "Query") { + fields { + type + } + } + } +response: + body: | + { + "errors": [ + { + "message": "Field \u0022type\u0022 of type \u0022__Type!\u0022 must have a selection of subfields. Did you mean \u0022type { ... }\u0022?", + "locations": [ + { + "line": 4, + "column": 7 + } + ], + "extensions": { + "declaringType": "__Field", + "field": "type", + "type": "__Type!", + "responseName": "type", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } +sourceSchemas: + - name: A + schema: | + "Schema description" + schema @test(arg: "value") { + query: Query + mutation: Mutation + subscription: Subscription + } + + interface Node @test(arg: "value") { + id: ID! + } + + "Interface description" + interface Votable implements Node { + "Interface field description" + id: ID! + } + + type Mutation @test(arg: "value") { + postReview(input: PostReviewInput): Review @test(arg: "value") + } + + type Post implements Votable & Node @key(fields: "id") { + id: ID! + postKind: PostKind @shareable + location: String @inaccessible + } + + "Object type description" + type Query @test(arg: "value") { + "Object field description" + posts( + "Argument description" + filter: PostsFilter + first: Int! = 5 @test(arg: "value") + hidden: Boolean @deprecated(reason: "No longer supported") + ): [Post] + userCreation: UserCreation + votables: [Votable]! @deprecated(reason: "No longer supported") + postById(postId: ID! @is(field: "id")): Post @lookup + node(id: ID!): Node @lookup + } + + type Review implements Votable & Node @test(arg: "value") { + id: ID! + } + + type Subscription @test(arg: "value") { + onNewReview: Review + } + + "Union description" + union UserCreation @test(arg: "value") = Post | Review + + input PostReviewInput @oneOf { + scalar: String @deprecated(reason: "No longer supported") + pros: [PostReviewPro] + } + + input PostReviewPro { + scalar: Int! + } + + "Input object type description" + input PostsFilter @test(arg: "value") { + "Input field description" + scalar: String = "test" @test(arg: "value") + } + + "Enum description" + enum PostKind @test(arg: "value") { + "Enum value description" + STORY @test(arg: "value") + PHOTO @deprecated(reason: "No longer supported") + } + + "The `@oneOf` directive is used within the type system definition language to indicate that an Input Object is a OneOf Input Object." + directive @oneOf on INPUT_OBJECT + + "Directive description" + directive @test("Directive argument description" arg: String! = "default") repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION | SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_OfType_Without_SelectionSet.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_OfType_Without_SelectionSet.yaml new file mode 100644 index 00000000000..2f2b03110f9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_OfType_Without_SelectionSet.yaml @@ -0,0 +1,115 @@ +title: Introspection_OfType_Without_SelectionSet +request: + document: | + { + __schema { + types { + ofType + } + } + } +response: + body: | + { + "errors": [ + { + "message": "Field \u0022ofType\u0022 of type \u0022__Type\u0022 must have a selection of subfields. Did you mean \u0022ofType { ... }\u0022?", + "locations": [ + { + "line": 4, + "column": 7 + } + ], + "extensions": { + "declaringType": "__Type", + "field": "ofType", + "type": "__Type", + "responseName": "ofType", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } +sourceSchemas: + - name: A + schema: | + "Schema description" + schema @test(arg: "value") { + query: Query + mutation: Mutation + subscription: Subscription + } + + interface Node @test(arg: "value") { + id: ID! + } + + "Interface description" + interface Votable implements Node { + "Interface field description" + id: ID! + } + + type Mutation @test(arg: "value") { + postReview(input: PostReviewInput): Review @test(arg: "value") + } + + type Post implements Votable & Node @key(fields: "id") { + id: ID! + postKind: PostKind @shareable + location: String @inaccessible + } + + "Object type description" + type Query @test(arg: "value") { + "Object field description" + posts( + "Argument description" + filter: PostsFilter + first: Int! = 5 @test(arg: "value") + hidden: Boolean @deprecated(reason: "No longer supported") + ): [Post] + userCreation: UserCreation + votables: [Votable]! @deprecated(reason: "No longer supported") + postById(postId: ID! @is(field: "id")): Post @lookup + node(id: ID!): Node @lookup + } + + type Review implements Votable & Node @test(arg: "value") { + id: ID! + } + + type Subscription @test(arg: "value") { + onNewReview: Review + } + + "Union description" + union UserCreation @test(arg: "value") = Post | Review + + input PostReviewInput @oneOf { + scalar: String @deprecated(reason: "No longer supported") + pros: [PostReviewPro] + } + + input PostReviewPro { + scalar: Int! + } + + "Input object type description" + input PostsFilter @test(arg: "value") { + "Input field description" + scalar: String = "test" @test(arg: "value") + } + + "Enum description" + enum PostKind @test(arg: "value") { + "Enum value description" + STORY @test(arg: "value") + PHOTO @deprecated(reason: "No longer supported") + } + + "The `@oneOf` directive is used within the type system definition language to indicate that an Input Object is a OneOf Input Object." + directive @oneOf on INPUT_OBJECT + + "Directive description" + directive @test("Directive argument description" arg: String! = "default") repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION | SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION From aa15b1d67454bef8f43c90db4f033773ebd488f5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Thu, 16 Apr 2026 14:48:50 +0800 Subject: [PATCH 2/2] fixes --- .../Fusion.AspNetCore.Tests/FusionTestBase.cs | 21 ++++--------------- .../TestServerSession.cs | 8 ++++++- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs index 7d1d59fe41e..7022f7c27fb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs @@ -21,7 +21,6 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Xunit.Sdk; @@ -38,7 +37,8 @@ protected async Task CreateCompositeSchemaAsync( Action? configureServices = null, Action? configureApplication = null, Action? configureGatewayBuilder = null, - [StringSyntax("json")] string? gatewaySettings = null) + [StringSyntax("json")] string? gatewaySettings = null, + string? environmentName = "Development") { var sourceSchemas = new List(); var gatewayServices = new ServiceCollection(); @@ -181,15 +181,10 @@ protected async Task CreateCompositeSchemaAsync( services.Add(serviceDescriptor); } - // Default to Development so security policies added by AddGraphQLGatewayServer - // do not interfere with tests. Individual tests that need production behavior - // can override IHostEnvironment via their own configureServices callback. - services.AddSingleton( - new TestHostEnvironment(Environments.Development)); - configureServices?.Invoke(services); }, - configureApplication); + configureApplication, + environmentName); return new Gateway(gatewayTestServer, sourceSchemas, interactions); @@ -406,12 +401,4 @@ private class EmptyMemoryOwner : IMemoryOwner public void Dispose() { } } - - internal sealed class TestHostEnvironment(string environmentName) : IHostEnvironment - { - public string EnvironmentName { get; set; } = environmentName; - public string ApplicationName { get; set; } = "TestApp"; - public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); - public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); - } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/TestServerSession.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/TestServerSession.cs index 4c17a23c69f..89794bd9fed 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/TestServerSession.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/TestServerSession.cs @@ -13,12 +13,18 @@ internal sealed class TestServerSession : IDisposable public TestServer CreateServer( Action configureServices, - Action configureApplication) + Action configureApplication, + string? environmentName = null) { var builder = new WebHostBuilder() .Configure(configureApplication) .ConfigureServices(configureServices); + if (environmentName is not null) + { + builder.UseEnvironment(environmentName); + } + var server = new TestServer(builder); if (!_cleanupPipeline.Writer.TryWrite(server))