diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index 346f8af0a..3a83227d2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -16,44 +16,14 @@ using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Validation; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { internal static class ContentActions { - private static readonly QueryArgument Id = new QueryArgument(AllTypes.None) - { - Name = "id", - Description = "The id of the content (usually GUID).", - DefaultValue = null, - ResolvedType = AllTypes.NonNullDomainId - }; - - private static readonly QueryArgument NewId = new QueryArgument(AllTypes.None) - { - Name = "id", - Description = "The optional custom content id.", - DefaultValue = null, - ResolvedType = AllTypes.String - }; - - private static readonly QueryArgument ExpectedVersion = new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - }; - - private static readonly QueryArgument Publish = new QueryArgument(AllTypes.None) - { - Name = "publish", - Description = "Set to true to autopublish content on create.", - DefaultValue = false, - ResolvedType = AllTypes.Boolean - }; - public static class Json { public static readonly QueryArguments Arguments = new QueryArguments @@ -95,7 +65,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public static readonly QueryArguments Arguments = new QueryArguments { - Id, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The id of the content (usually GUID).", + DefaultValue = null, + ResolvedType = AllTypes.NonNullDomainId + }, new QueryArgument(AllTypes.None) { Name = "version", @@ -191,11 +167,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents DefaultValue = null, ResolvedType = new NonNullGraphType(inputType) }, - Publish, NewId + new QueryArgument(AllTypes.None) + { + Name = "publish", + Description = "Set to true to autopublish content on create.", + DefaultValue = false, + ResolvedType = AllTypes.Boolean + }, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The optional custom content id.", + DefaultValue = null, + ResolvedType = AllTypes.String + } }; } - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsCreate, c => { var contentPublish = c.GetArgument("publish"); var contentData = GetContentData(c); @@ -220,7 +209,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { return new QueryArguments { - Id, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The id of the content (usually GUID).", + DefaultValue = null, + ResolvedType = AllTypes.NonNullDomainId + }, new QueryArgument(AllTypes.None) { Name = "data", @@ -228,12 +223,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents DefaultValue = null, ResolvedType = new NonNullGraphType(inputType) }, - Publish, - ExpectedVersion + new QueryArgument(AllTypes.None) + { + Name = "publish", + Description = "Set to true to autopublish content on create.", + DefaultValue = false, + ResolvedType = AllTypes.Boolean + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } }; } - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsUpsert, c => { var contentPublish = c.GetArgument("publish"); var contentData = GetContentData(c); @@ -251,7 +258,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { return new QueryArguments { - Id, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The optional custom content id.", + DefaultValue = null, + ResolvedType = AllTypes.String + }, new QueryArgument(AllTypes.None) { Name = "data", @@ -259,11 +272,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents DefaultValue = null, ResolvedType = new NonNullGraphType(inputType) }, - ExpectedVersion + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } }; } - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsUpdateOwn, c => { var contentId = c.GetArgument("id"); var contentData = GetContentData(c); @@ -278,7 +297,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { return new QueryArguments { - Id, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The optional custom content id.", + DefaultValue = null, + ResolvedType = AllTypes.String + }, new QueryArgument(AllTypes.None) { Name = "data", @@ -286,11 +311,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents DefaultValue = null, ResolvedType = new NonNullGraphType(inputType) }, - ExpectedVersion + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } }; } - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsUpdateOwn, c => { var contentId = c.GetArgument("id"); var contentData = GetContentData(c); @@ -303,7 +334,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public static readonly QueryArguments Arguments = new QueryArguments { - Id, + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The id of the content (usually GUID).", + DefaultValue = null, + ResolvedType = AllTypes.NonNullDomainId + }, new QueryArgument(AllTypes.None) { Name = "status", @@ -318,10 +355,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents DefaultValue = null, ResolvedType = AllTypes.Date }, - ExpectedVersion + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } }; - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsChangeStatusOwn, c => { var contentId = c.GetArgument("id"); var contentStatus = c.GetArgument("status"); @@ -335,11 +378,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public static readonly QueryArguments Arguments = new QueryArguments { - Id, - ExpectedVersion + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The id of the content (usually GUID).", + DefaultValue = null, + ResolvedType = AllTypes.NonNullDomainId + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } }; - public static readonly IFieldResolver Resolver = ResolveAsync(c => + public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsDeleteOwn, c => { var contentId = c.GetArgument("id"); @@ -354,35 +409,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return source.ToContentData((IComplexGraphType)c.FieldDefinition.Arguments.Find("data").Flatten()); } - private static IFieldResolver ResolveAsync(Func action) + private static IFieldResolver ResolveAsync(string permissionId, Func action) { return Resolvers.Async(async (source, fieldContext, context) => { - try - { - var command = action(fieldContext); + var schemaId = fieldContext.FieldDefinition.SchemaNamedId(); - command.AppId = fieldContext.FieldDefinition.AppId(); - command.SchemaId = fieldContext.FieldDefinition.SchemaNamedId(); - command.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); + CheckPermission(permissionId, context, schemaId); - var commandContext = await context.CommandBus.PublishAsync(command); + var contentCommand = action(fieldContext); - return commandContext.PlainResult!; - } - catch (ValidationException ex) - { - fieldContext.Errors.Add(new ExecutionError(ex.Message)); + contentCommand.SchemaId = schemaId; + contentCommand.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); - throw; - } - catch (DomainException ex) - { - fieldContext.Errors.Add(new ExecutionError(ex.Message)); + var commandContext = await context.CommandBus.PublishAsync(contentCommand); - throw; - } + return commandContext.PlainResult!; }); } + + private static void CheckPermission(string permissionId, GraphQLExecutionContext context, NamedId schemaId) + { + var requestContext = context.Context; + var requestPermission = Permissions.ForApp(permissionId, requestContext.App.Name, schemaId.Name); + + if (!requestContext.Permissions.Allows(requestPermission)) + { + throw new DomainForbiddenException(T.Get("common.errorNoPermission")); + } + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs index 012148141..05346e589 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs @@ -88,16 +88,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return field.GetMetadata>(nameof(SchemaNamedId)); } - public static FieldType WithAppId(this FieldType field, NamedId value) - { - return field.WithMetadata(nameof(AppId), value); - } - - public static NamedId AppId(this FieldType field) - { - return field.GetMetadata>(nameof(AppId)); - } - private static FieldType WithMetadata(this FieldType field, string key, object value) { if (field is MetadataProvider metadataProvider) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs index 8e9dc39aa..83cf76ed6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs @@ -16,8 +16,10 @@ using Newtonsoft.Json.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Shared; using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL @@ -37,10 +39,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } [Fact] - public async Task Should_return_single_content_when_creating_content() + public async Task Should_return_error_when_user_has_no_permission_to_create() { - var f = new FloatGraphType().ParseValue("12.0"); + var query = @" + mutation { + createMySchemaContent(data: { myNumber: { iv: 42 } }) { + id + } + }"; + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = new + { + createMySchemaContent = (object?)null, + }, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_return_single_content_when_creating_content() + { var query = @" mutation { createMySchemaContent(data: , publish: true) { @@ -50,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsCreate); var expected = new { @@ -83,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsCreate); var expected = new { @@ -117,7 +158,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query, Inputs = GetInput() }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsCreate); var expected = new { @@ -138,6 +179,47 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .MustHaveHappened(); } + [Fact] + public async Task Should_return_error_when_user_has_no_permission_to_update() + { + var query = @" + mutation { + updateMySchemaContent(id: """", data: { myNumber: { iv: 42 } }) { + id + } + }".Replace("", contentId.ToString()); + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = new + { + updateMySchemaContent = (object?)null, + }, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_return_single_content_when_updating_content() { @@ -150,7 +232,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpdateOwn); var expected = new { @@ -182,7 +264,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query, Inputs = GetInput() }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn); var expected = new { @@ -202,6 +284,47 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .MustHaveHappened(); } + [Fact] + public async Task Should_return_error_when_user_has_no_permission_to_upsert() + { + var query = @" + mutation { + upsertMySchemaContent(id: """", data: { myNumber: { iv: 42 } }) { + id + } + }".Replace("", contentId.ToString()); + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = new + { + upsertMySchemaContent = (object?)null, + }, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_return_single_content_when_upserting_content() { @@ -214,7 +337,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpsert); var expected = new { @@ -247,7 +370,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query, Inputs = GetInput() }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpsert); var expected = new { @@ -268,6 +391,47 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .MustHaveHappened(); } + [Fact] + public async Task Should_return_error_when_user_has_no_permission_to_patch() + { + var query = @" + mutation { + patchMySchemaContent(id: """", data: { myNumber: { iv: 42 } }) { + id + } + }".Replace("", contentId.ToString()); + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = new + { + patchMySchemaContent = (object?)null, + }, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_return_single_content_when_patching_content() { @@ -280,7 +444,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpdateOwn); var expected = new { @@ -312,7 +476,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query, Inputs = GetInput() }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn); var expected = new { @@ -333,7 +497,48 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } [Fact] - public async Task Should_publish_command_for_status_change() + public async Task Should_return_error_when_user_has_no_permission_to_change_status() + { + var query = @" + mutation { + changeMySchemaContent(id: """", status: ""Published"") { + id + } + }".Replace("", contentId.ToString()); + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = new + { + changeMySchemaContent = (object?)null, + }, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_return_single_content_when_changing_status() { var dueTime = SystemClock.Instance.GetCurrentInstant().WithoutMs(); @@ -346,7 +551,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var expected = new { @@ -368,7 +573,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } [Fact] - public async Task Should_publish_command_for_status_change_without_due_time() + public async Task Should_return_single_content_when_changing_status_without_due_time() { var query = @" mutation { @@ -379,7 +584,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var expected = new { @@ -401,7 +606,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } [Fact] - public async Task Should_publish_command_for_status_change_with_null_due_time() + public async Task Should_return_single_content_when_changing_status_with_null_due_time() { var query = @" mutation { @@ -412,7 +617,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(content); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var expected = new { @@ -434,7 +639,45 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } [Fact] - public async Task Should_publish_command_for_delete() + public async Task Should_return_error_when_user_has_no_permission_to_delete() + { + var query = @" + mutation { + deleteMySchemaContent(id: """") { + version + } + }".Replace("", contentId.ToString()); + + var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); + + var expected = new + { + data = (object?)null, + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + } + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_return_new_version_when_deleting_content() { var query = @" mutation { @@ -445,7 +688,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL commandContext.Complete(new EntitySavedResult(13)); - var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsDeleteOwn); var expected = new { @@ -467,6 +710,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .MustHaveHappened(); } + private Task<(bool HasError, object Response)> ExecuteAsync(GraphQLQuery query, string permissionId) + { + var permission = Permissions.ForApp(permissionId, app.Name, schemaId.Name).Id; + + var withPermission = new Context(Mocks.FrontendUser(permission: permission), app); + + return sut.QueryAsync(withPermission, query); + } + private Inputs GetInput() { var input = new diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index c1ec5c7b5..c9d7d974c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -33,7 +33,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public class GraphQLTestBase + public class GraphQLTestBase : IClassFixture { protected readonly IAppEntity app; protected readonly IAssetQueryService assetQuery = A.Fake();