// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschränkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using FakeItEasy; using Microsoft.Extensions.Options; using Microsoft.OData; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Shared.Identity; using Xunit; #pragma warning disable SA1401 // Fields must be private namespace Squidex.Domain.Apps.Entities.Contents { public class ContentQueryServiceTests { private readonly IContentRepository contentRepository = A.Fake(); private readonly IContentVersionLoader contentVersionLoader = A.Fake(); private readonly IScriptEngine scriptEngine = A.Fake(); private readonly ISchemaEntity schema = A.Fake(); private readonly IAppEntity app = A.Fake(); private readonly IAppProvider appProvider = A.Fake(); private readonly IAssetUrlGenerator urlGenerator = A.Fake(); private readonly Guid appId = Guid.NewGuid(); private readonly Guid schemaId = Guid.NewGuid(); private readonly Guid contentId = Guid.NewGuid(); private readonly string appName = "my-app"; private readonly string schemaName = "my-schema"; private readonly string script = ""; private readonly NamedContentData contentData = new NamedContentData(); private readonly NamedContentData contentTransformed = new NamedContentData(); private readonly ClaimsPrincipal user; private readonly ClaimsIdentity identity = new ClaimsIdentity(); private readonly EdmModelBuilder modelBuilder = A.Fake(); private readonly QueryContext context; private readonly ContentQueryService sut; public ContentQueryServiceTests() { user = new ClaimsPrincipal(identity); A.CallTo(() => app.Id).Returns(appId); A.CallTo(() => app.Name).Returns(appName); A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.English); A.CallTo(() => schema.AppId).Returns(new NamedId(appId, appName)); A.CallTo(() => schema.Id).Returns(schemaId); A.CallTo(() => schema.Name).Returns(schemaName); A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaName)); A.CallTo(() => schema.ScriptQuery).Returns(script); context = QueryContext.Create(app, user); sut = new ContentQueryService( appProvider, urlGenerator, contentRepository, contentVersionLoader, scriptEngine, Options.Create(new ContentOptions()), modelBuilder); } [Fact] public async Task Should_return_schema_from_id_if_string_is_guid() { SetupSchema(); var result = await sut.GetSchemaAsync(context, schemaId.ToString()); Assert.Equal(schema, result); } [Fact] public async Task Should_return_schema_from_name_if_string_not_guid() { SetupSchema(); var result = await sut.GetSchemaAsync(context, schemaName); Assert.Equal(schema, result); } [Fact] public async Task Should_throw_404_if_schema_not_found() { SetupSchemaNotFound(); var ctx = context; await Assert.ThrowsAsync(() => sut.GetSchemaAsync(ctx, schemaName)); } [Fact] public async Task Should_throw_404_if_schema_not_found_in_check() { SetupSchemaNotFound(); var ctx = context; await Assert.ThrowsAsync(() => sut.ThrowIfSchemaNotExistsAsync(ctx, schemaName)); } public static IEnumerable SingleDataFrontend = new[] { new object[] { true, new[] { Status.Archived, Status.Draft, Status.Published } }, new object[] { false, new[] { Status.Archived, Status.Draft, Status.Published } } }; public static IEnumerable SingleDataApi = new[] { new object[] { true, new[] { Status.Draft, Status.Published } }, new object[] { false, new[] { Status.Published } } }; [Fact] public async Task Should_throw_for_single_content_if_no_permission() { SetupClaims(false, false); SetupSchema(); var ctx = context; await Assert.ThrowsAsync(() => sut.FindContentAsync(ctx, schemaId.ToString(), contentId)); } [Fact] public async Task Should_throw_404_for_single_content_if_content_not_found() { SetupClaims(); SetupSchema(); A.CallTo(() => contentRepository.FindContentAsync(app, schema, new[] { Status.Published }, contentId)) .Returns((IContentEntity)null); var ctx = context; await Assert.ThrowsAsync(async () => await sut.FindContentAsync(ctx, schemaId.ToString(), contentId)); } [Theory] [MemberData(nameof(SingleDataFrontend))] public async Task Should_return_single_content_for_frontend_without_transform(bool unpublished, params Status[] status) { var content = CreateContent(contentId); SetupClaims(true); SetupSchema(); SetupScripting(contentId); A.CallTo(() => contentRepository.FindContentAsync(app, schema, A.That.IsSameSequenceAs(status), contentId)) .Returns(content); var ctx = context.WithUnpublished(unpublished); var result = await sut.FindContentAsync(ctx, schemaId.ToString(), contentId); Assert.Equal(contentTransformed, result.Data); Assert.Equal(content.Id, result.Id); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } [Theory] [MemberData(nameof(SingleDataApi))] public async Task Should_return_single_content_for_api_with_transform(bool unpublished, params Status[] status) { var content = CreateContent(contentId); SetupClaims(); SetupSchema(); SetupScripting(contentId); A.CallTo(() => contentRepository.FindContentAsync(app, schema, A.That.IsSameSequenceAs(status), contentId)) .Returns(content); var ctx = context.WithUnpublished(unpublished); var result = await sut.FindContentAsync(ctx, schemaId.ToString(), contentId); Assert.Equal(contentTransformed, result.Data); Assert.Equal(content.Id, result.Id); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustHaveHappened(Repeated.Exactly.Once); } [Fact] public async Task Should_return_versioned_content_from_repository_and_transform() { var content = CreateContent(contentId); SetupClaims(true); SetupSchema(); SetupScripting(contentId); A.CallTo(() => contentVersionLoader.LoadAsync(contentId, 10)) .Returns(content); var ctx = context; var result = await sut.FindContentAsync(ctx, schemaId.ToString(), contentId, 10); Assert.Equal(contentTransformed, result.Data); Assert.Equal(content.Id, result.Id); } public static IEnumerable ManyDataFrontend = new[] { new object[] { true, true, new[] { Status.Archived } }, new object[] { true, false, new[] { Status.Archived } }, new object[] { false, true, new[] { Status.Draft, Status.Published } }, new object[] { false, false, new[] { Status.Draft, Status.Published } } }; public static IEnumerable ManyDataApi = new[] { new object[] { true, true, new[] { Status.Draft, Status.Published } }, new object[] { false, true, new[] { Status.Draft, Status.Published } }, new object[] { false, false, new[] { Status.Published } }, new object[] { true, false, new[] { Status.Published } } }; [Fact] public async Task Should_throw_for_query_if_no_permission() { SetupClaims(false, false); SetupSchema(); var ctx = context; await Assert.ThrowsAsync(() => sut.QueryAsync(ctx, schemaId.ToString(), Q.Empty)); } [Theory] [MemberData(nameof(ManyDataFrontend))] public async Task Should_query_contents_by_query_for_frontend_without_transform(bool archive, bool unpublished, params Status[] status) { const int count = 5, total = 200; var content = CreateContent(contentId); SetupClaims(true); SetupSchema(); SetupScripting(contentId); SetupContents(status, count, total, content); var ctx = context.WithArchived(archive).WithUnpublished(unpublished); var result = await sut.QueryAsync(ctx, schemaId.ToString(), Q.Empty); Assert.Equal(contentData, result[0].Data); Assert.Equal(content.Id, result[0].Id); Assert.Equal(total, result.Total); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } [Theory] [MemberData(nameof(ManyDataApi))] public async Task Should_query_contents_by_query_for_api_and_transform(bool archive, bool unpublished, params Status[] status) { const int count = 5, total = 200; var content = CreateContent(contentId); SetupClaims(); SetupSchema(); SetupScripting(contentId); SetupContents(status, count, total, content); var ctx = context.WithArchived(archive).WithUnpublished(unpublished); var result = await sut.QueryAsync(ctx, schemaId.ToString(), Q.Empty); Assert.Equal(contentData, result[0].Data); Assert.Equal(contentId, result[0].Id); Assert.Equal(total, result.Total); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustHaveHappened(Repeated.Exactly.Times(count)); } [Fact] public Task Should_throw_if_query_is_invalid() { SetupClaims(); SetupSchema(); A.CallTo(() => modelBuilder.BuildEdmModel(schema, app)) .Throws(new ODataException()); return Assert.ThrowsAsync(() => sut.QueryAsync(context, schemaId.ToString(), Q.Empty.WithODataQuery("query"))); } public static IEnumerable ManyIdDataFrontend = new[] { new object[] { true, true, new[] { Status.Archived } }, new object[] { true, false, new[] { Status.Archived } }, new object[] { false, true, new[] { Status.Draft, Status.Published } }, new object[] { false, false, new[] { Status.Draft, Status.Published } } }; public static IEnumerable ManyIdDataApi = new[] { new object[] { true, true, new[] { Status.Draft, Status.Published } }, new object[] { false, true, new[] { Status.Draft, Status.Published } }, new object[] { false, false, new[] { Status.Published } }, new object[] { true, false, new[] { Status.Published } } }; [Theory] [MemberData(nameof(ManyIdDataFrontend))] public async Task Should_query_contents_by_id_for_frontend_and_transform(bool archive, bool unpublished, params Status[] status) { const int count = 5, total = 200; var ids = Enumerable.Range(0, count).Select(x => Guid.NewGuid()).ToList(); SetupClaims(true); SetupSchema(); SetupScripting(ids.ToArray()); SetupContents(status, total, ids); var ctx = context.WithArchived(archive).WithUnpublished(unpublished); var result = await sut.QueryAsync(ctx, schemaId.ToString(), Q.Empty.WithIds(ids)); Assert.Equal(ids, result.Select(x => x.Id).ToList()); Assert.Equal(total, result.Total); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustNotHaveHappened(); } [Theory] [MemberData(nameof(ManyIdDataApi))] public async Task Should_query_contents_by_id_from_repository_and_transform(bool archive, bool unpublished, params Status[] status) { const int count = 5, total = 200; var ids = Enumerable.Range(0, count).Select(x => Guid.NewGuid()).ToList(); SetupClaims(); SetupSchema(); SetupScripting(ids.ToArray()); SetupContents(status, total, ids); var ctx = context.WithArchived(archive).WithUnpublished(unpublished); var result = await sut.QueryAsync(ctx, schemaId.ToString(), Q.Empty.WithIds(ids)); Assert.Equal(ids, result.Select(x => x.Id).ToList()); Assert.Equal(total, result.Total); A.CallTo(() => scriptEngine.Transform(A.Ignored, A.Ignored)) .MustHaveHappened(Repeated.Exactly.Times(count)); } private void SetupClaims(bool isFrontend = false, bool allowSchema = true) { if (isFrontend) { identity.AddClaim(new Claim(OpenIdClaims.ClientId, "squidex-frontend")); } if (allowSchema) { identity.AddClaim(new Claim(SquidexClaimTypes.Permissions, Permissions.ForApp(Permissions.AppContentsRead, app.Name, schema.Name).Id)); } } private void SetupScripting(params Guid[] ids) { foreach (var id in ids) { A.CallTo(() => scriptEngine.Transform(A.That.Matches(x => x.User == user && x.ContentId == id && x.Data == contentData), script)) .Returns(contentTransformed); } } private void SetupContents(Status[] status, int count, int total, IContentEntity content) { A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A.Ignored)) .Returns(ResultList.Create(total, Enumerable.Repeat(content, count))); } private void SetupContents(Status[] status, int total, List ids) { A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A>.Ignored)) .Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle())); } private void SetupSchema() { A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) .Returns(schema); A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaName)) .Returns(schema); } private void SetupSchemaNotFound() { A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaName)) .Returns((ISchemaEntity)null); A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) .Returns((ISchemaEntity)null); } private IContentEntity CreateContent(Guid id, Status status = Status.Published) { var content = A.Fake(); A.CallTo(() => content.Id).Returns(id); A.CallTo(() => content.Data).Returns(contentData); A.CallTo(() => content.DataDraft).Returns(contentData); A.CallTo(() => content.Status).Returns(status); A.CallTo(() => content.SchemaId).Returns(new NamedId(schemaId, schemaName)); return content; } } }