From e526d1256ee180f032eba503644dbad22c6d864f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 14:24:48 -0500 Subject: [PATCH 1/4] Add the content service with optimistic concurrency --- .../Authorization/AuthorizationEvaluator.cs | 2 +- .../Authorization/EffectivePermissions.cs | 5 +- .../Authorization/FieldAccess.cs | 44 +- .../Content/ContentEntryMapper.cs | 525 +++++++++++++ .../Content/ContentErrorCodes.cs | 6 + src/FluentCMS.Core/Content/ContentService.cs | 674 ++++++++++++++++ .../Content/EntryCanonicalizer.cs | 465 +++++++++++ src/FluentCMS.Core/Content/EntryChange.cs | 19 + src/FluentCMS.Core/Content/EntryComposer.cs | 210 +++++ src/FluentCMS.Core/Content/EntryJson.cs | 117 +++ src/FluentCMS.Core/Content/EntryStore.cs | 438 +++++++++++ .../Content/Hooks/HookPipeline.cs | 9 +- .../Content/Query/ContentQueryEngine.cs | 14 +- src/FluentCMS.Core/Content/Query/EntryPlan.cs | 6 +- .../Content/ResponseRichText.cs | 89 +++ .../Content/TypedContentService.cs | 79 ++ .../Content/Validation/ValidationPipeline.cs | 10 +- .../FluentCmsServiceCollectionExtensions.cs | 16 + .../Localization/LocaleFallbackReader.cs | 50 ++ .../Content/ContentAccessDeniedException.cs | 21 + .../Content/EntryNotFoundException.cs | 21 + src/FluentCMS.Sdk/Content/GetOptions.cs | 26 + src/FluentCMS.Sdk/Content/IContentService.cs | 173 ++++ src/FluentCMS.Sdk/Content/PagedResult.cs | 38 + src/FluentCMS.Sdk/Content/SystemOperation.cs | 50 ++ .../Content/ValidationErrorCodes.cs | 3 + .../Content/VersionConflictException.cs | 26 + src/FluentCMS.Sdk/PublicAPI.Unshipped.txt | 65 ++ .../LayeringRulesTests.cs | 54 ++ .../Content/ContentServiceAccessTests.cs | 300 +++++++ .../Content/ContentServiceHookTests.cs | 329 ++++++++ .../Content/ContentServiceReadTests.cs | 231 ++++++ .../Content/ContentServiceWriteTests.cs | 738 ++++++++++++++++++ .../Content/ServiceDatabase.cs | 174 +++++ .../Content/TypedContentServiceTests.cs | 195 +++++ .../Query/QueryDatabase.cs | 29 + .../Content/ValidationTypesTests.cs | 2 +- 37 files changed, 5238 insertions(+), 15 deletions(-) create mode 100644 src/FluentCMS.Core/Content/ContentEntryMapper.cs create mode 100644 src/FluentCMS.Core/Content/ContentErrorCodes.cs create mode 100644 src/FluentCMS.Core/Content/ContentService.cs create mode 100644 src/FluentCMS.Core/Content/EntryCanonicalizer.cs create mode 100644 src/FluentCMS.Core/Content/EntryChange.cs create mode 100644 src/FluentCMS.Core/Content/EntryComposer.cs create mode 100644 src/FluentCMS.Core/Content/EntryJson.cs create mode 100644 src/FluentCMS.Core/Content/EntryStore.cs create mode 100644 src/FluentCMS.Core/Content/ResponseRichText.cs create mode 100644 src/FluentCMS.Core/Content/TypedContentService.cs create mode 100644 src/FluentCMS.Sdk/Content/ContentAccessDeniedException.cs create mode 100644 src/FluentCMS.Sdk/Content/EntryNotFoundException.cs create mode 100644 src/FluentCMS.Sdk/Content/GetOptions.cs create mode 100644 src/FluentCMS.Sdk/Content/IContentService.cs create mode 100644 src/FluentCMS.Sdk/Content/PagedResult.cs create mode 100644 src/FluentCMS.Sdk/Content/SystemOperation.cs create mode 100644 src/FluentCMS.Sdk/Content/VersionConflictException.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceAccessTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceHookTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ServiceDatabase.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index fb21e2927..430aab5bc 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -141,7 +141,7 @@ private async Task IsInSiteAsync(SchemaType type, ContentOperation operati private async Task MatchesAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, QueryStatus status, ContentFilter rule, CancellationToken cancellationToken) { var state = operation == ContentOperation.Read && status == QueryStatus.Published ? ContentState.Published : ContentState.Draft; - var scope = new QueryScope(database, schema, dialect, new ContentQueryAccess.RuleOnly(type, rule), options, state, target.Locale ?? "", target.Site); + var scope = new QueryScope(database, schema, dialect, new ContentQueryAccess.RuleOnly(type, rule), options, state, target.RowLocale ?? target.Locale ?? "", target.Site); var row = Expression.Parameter(typeof(QueryRow), "row"); var condition = new FilterCompiler(scope).Where(type, null, row)!; diff --git a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs index dc2eecaf7..74e4dd32c 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -6,8 +6,9 @@ namespace FluentCMS.Core.Authorization; // Where an operation applies. A null site is every site, so only a grant with no site limit holds for it; an empty -// locale is no locale, so only a grant with no locale limit holds for it. -internal readonly record struct AccessTarget(SiteId? Site, string? Locale); +// locale is no locale, so only a grant with no locale limit holds for it. RowLocale is the row a row rule reads when +// that is not Locale's: a write that adds a locale to an entry has no row of its own there yet. +internal readonly record struct AccessTarget(SiteId? Site, string? Locale, string? RowLocale = null); // One row of cms_role_permission, with the site of its role: a role of one site grants nothing in another. internal sealed record EffectiveGrant(string Pattern, PermissionConstraints Constraints, Guid? RoleSite) diff --git a/src/FluentCMS.Core/Authorization/FieldAccess.cs b/src/FluentCMS.Core/Authorization/FieldAccess.cs index 992f9c873..95f91496f 100644 --- a/src/FluentCMS.Core/Authorization/FieldAccess.cs +++ b/src/FluentCMS.Core/Authorization/FieldAccess.cs @@ -52,7 +52,7 @@ public void RejectUnwritable(ContentDocument changes, SchemaType type, WritableF } else if (SystemField.Find(key) is null) { - if (type.FindField(key) is not { Hidden: false } field || !writable.Includes(key)) + if (type.FindField(key) is not { Hidden: false, ReadOnly: false } field || !writable.Includes(key)) { Refuse(errors, key, type); } @@ -69,6 +69,46 @@ public void RejectUnwritable(ContentDocument changes, SchemaType type, WritableF } } + // Whether a value of a field names nothing inside it that no client may write, whoever is asking. + public bool IsClean(SchemaField field, object? value, SchemaType type) + { + ArgumentNullException.ThrowIfNull(field); + ArgumentNullException.ThrowIfNull(type); + + List errors = []; + CheckInside(errors, field.Name, value, field, type); + return errors.Count == 0; + } + + // What a before hook changed is held to the same grants as the client it runs for, so that a hook cannot be the way + // to a field the principal may not write. A hidden or read-only field is outside this: no client reaches it, and a + // hook is what fills it. Fields that are not there at all never get this far: the canonicalizer refuses them. + public void RejectHookChanges(IEnumerable changed, SchemaType type, WritableFields writable) + { + ArgumentNullException.ThrowIfNull(changed); + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(writable); + + if (permissions.IsSystem) + { + return; + } + + List errors = []; + foreach (var name in changed) + { + if (type.FindField(name) is { Hidden: false, ReadOnly: false } && !writable.Includes(name)) + { + Refuse(errors, name, type); + } + } + + if (errors.Count > 0) + { + throw new ValidationException(errors); + } + } + private void OmitInEntry(ContentDocument document, SchemaType type, AccessTarget target, bool isTarget) { foreach (var key in document.Keys.ToList()) @@ -220,7 +260,7 @@ private void CheckEntries(List errors, string path, IEnumerable continue; } - if (owner.FindField(key) is { Hidden: false } inner) + if (owner.FindField(key) is { Hidden: false, ReadOnly: false } inner) { CheckInside(errors, $"{path}.{key}", value, inner, root); } diff --git a/src/FluentCMS.Core/Content/ContentEntryMapper.cs b/src/FluentCMS.Core/Content/ContentEntryMapper.cs new file mode 100644 index 000000000..715af3f5d --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentEntryMapper.cs @@ -0,0 +1,525 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Media; +using FluentCMS.Publishing; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// Maps a content class to and from ContentDocument by reflection, until the source generator of M6 takes over. A +// document that goes back to the service holds plain ids, documents and lists, which RejectUnwritable can read inside. +// A value that does not fit a property leaves it at its default: validation reports it from the document, not from here. +internal sealed class ContentEntryMapper(SchemaModel schema) : IHookEntryMapper +{ + private const string Discriminator = "type"; + + private readonly ConcurrentDictionary _classes = new(); + + public object ToEntry(Type clrType, ContentDocument document) + { + ArgumentNullException.ThrowIfNull(clrType); + ArgumentNullException.ThrowIfNull(document); + + return ToInstance(clrType, document); + } + + // Writes only what a hook changed: a property that still holds what the document gave it says nothing, even where + // the class cannot tell an empty text from no value. + public void CopyTo(object entry, ContentDocument document) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(document); + + var empty = Activator.CreateInstance(entry.GetType()); + foreach (var (field, property) in MapOf(entry.GetType()).Fields) + { + document.TryGetValue(field.Name, out var stored); + var before = stored is not null && TryConvert(stored, property.PropertyType, out var converted) && converted is not null + ? converted + : property.GetValue(empty); + var after = property.GetValue(entry); + + if (!EntryJson.Same(ToApi(before), ToApi(after))) + { + document[field.Name] = ToApi(after); + } + } + } + + // The document of a typed write: a property with no value is not named, because a class cannot leave a field out. + public ContentDocument ToDocument(object entry) + { + ArgumentNullException.ThrowIfNull(entry); + return ToFields(entry); + } + + // What validation reads: nested objects as documents and each value as the property of its class declares it, where + // it fits, so that a custom validator of the class gets its own types. What does not fit stays, for the rules to report. + public IReadOnlyDictionary ValidationValues(SchemaType type, ContentDocument entry) => View(type, entry); + + private ContentDocument View(SchemaType owner, IEnumerable> values) + { + ContentDocument view = []; + foreach (var (key, value) in values) + { + if (owner.FindField(key) is { } field) + { + view[key] = ViewValue(owner, field, value); + } + else if (key == Discriminator) + { + view[key] = RawValue.Unwrap(value); + } + } + + return view; + } + + private object? ViewValue(SchemaType owner, SchemaField field, object? value) + { + switch (field.FieldType) + { + case FieldTypeNames.Component when schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component: + return field.Multiple + ? ItemsOf(value).Select(item => (object?)View(component, Pairs(item))).ToList() + : value is null ? null : View(component, Pairs(value)); + case FieldTypeNames.Blocks: + return ItemsOf(value).Select(item => (object?)ViewBlock(item)).ToList(); + } + + var plain = schema.FieldTypes.Find(field.FieldType)!.StorageKind(field) == FieldStorageKind.Reference ? PlainReference(field, value) : Plain(field, value); + return owner.ClrType is { } clrType + && MapOf(clrType).Fields.FirstOrDefault(item => item.Field == field).Property is { } property + && TryConvert(plain, property.PropertyType, out var typed) && typed is not null + ? typed + : plain; + } + + private ContentDocument ViewBlock(object? item) + { + var pairs = Pairs(item); + var name = pairs.FirstOrDefault(pair => pair.Key == Discriminator).Value; + return RawValue.Unwrap(name) is string blockName && schema.FindBlock(blockName) is { } block ? View(block, pairs) : new ContentDocument(pairs); + } + + private static object? PlainReference(SchemaField field, object? value) + { + if (!field.Multiple) + { + return value is Guid ? value : EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) && id != Guid.Empty ? id : null; + } + + return ItemsOf(value).Select(item => EntryCanonicalizer.TryReadId(item is Guid ? item : RawValue.Unwrap(item), out var id) ? id : Guid.Empty).Where(id => id != Guid.Empty).ToArray(); + } + + private static object? Plain(SchemaField field, object? value) + { + if (field.FieldType == FieldTypeNames.RichText && value is JsonObject document) + { + return new RichText(EntryJson.Text(document)); + } + + return value switch + { + JsonArray items when field.FieldType != FieldTypeNames.Json => items.Select(RawValue.Unwrap).ToList(), + JsonValue => RawValue.Unwrap(value), + _ => value, + }; + } + + private object ToInstance(Type clrType, IEnumerable> values) + { + var instance = Activator.CreateInstance(clrType) + ?? throw new InvalidOperationException($"{clrType} cannot be created."); + + var known = values as ContentDocument ?? new ContentDocument(values); + if (instance is ContentItem item) + { + ReadSystemFields(item, known); + } + + foreach (var (field, property) in MapOf(clrType).Fields) + { + if (property.CanWrite && known.TryGetValue(field.Name, out var value) && value is not null + && TryConvert(value, property.PropertyType, out var converted) && converted is not null) + { + property.SetValue(instance, converted); + } + } + + return instance; + } + + private static void ReadSystemFields(ContentItem item, ContentDocument values) + { + if (values.TryGetValue("id", out var id) && id is Guid entry) + { + item.Id = entry; + } + + if (values.TryGetValue("status", out var status) && status is EntryStatus state) + { + item.Status = state; + } + + if (values.TryGetValue("locale", out var locale) && locale is string tag) + { + item.Locale = tag; + } + + if (values.TryGetValue("version", out var version) && version is int number) + { + item.Version = number; + } + + if (values.TryGetValue("createdAt", out var createdAt) && createdAt is DateTimeOffset created) + { + item.CreatedAt = created; + } + + if (values.TryGetValue("createdBy", out var createdBy) && createdBy is Guid creator) + { + item.CreatedBy = creator; + } + + if (values.TryGetValue("updatedAt", out var updatedAt) && updatedAt is DateTimeOffset updated) + { + item.UpdatedAt = updated; + } + + if (values.TryGetValue("updatedBy", out var updatedBy) && updatedBy is Guid updater) + { + item.UpdatedBy = updater; + } + + if (values.TryGetValue("publishedAt", out var publishedAt) && publishedAt is DateTimeOffset published) + { + item.PublishedAt = published; + } + } + + private bool TryConvert(object? value, Type declared, out object? result) + { + result = null; + if (value is null) + { + return true; + } + + var target = Nullable.GetUnderlyingType(declared) ?? declared; + if (target.IsInstanceOfType(value)) + { + result = value; + return true; + } + + if (target.IsGenericType && target.GetGenericTypeDefinition() == typeof(Ref<>)) + { + return TryReadRef(value, target, out result); + } + + if (target == typeof(MediaRef)) + { + if (EntryCanonicalizer.TryReadId(value is Guid ? value : RawValue.Unwrap(value), out var id)) + { + result = new MediaRef(id); + return true; + } + + return false; + } + + if (target == typeof(RichText) && value is JsonObject document) + { + result = new RichText(EntryJson.Text(document)); + return true; + } + + if (target == typeof(JsonElement) || target == typeof(JsonNode)) + { + return TryConvertJson(value, target, out result); + } + + var raw = RawValue.Unwrap(value); + if (raw is null) + { + return true; + } + + if (target == typeof(BlockList)) + { + return TryBuildBlockList(value, out result); + } + + if (Attribute.IsDefined(target, typeof(ComponentAttribute)) || Attribute.IsDefined(target, typeof(BlockAttribute))) + { + if (Pairs(value) is { Count: > 0 } pairs) + { + result = ToInstance(target, pairs); + return true; + } + + return false; + } + + if (ListItemType(target) is { } itemType) + { + return TryBuildList(value, target, itemType, out result); + } + + return TryConvertScalar(raw, target, out result); + } + + private static bool TryReadRef(object value, Type target, out object? result) + { + result = null; + if (!EntryCanonicalizer.TryReadId(value is Guid ? value : RawValue.Unwrap(value), out var id)) + { + return false; + } + + result = Activator.CreateInstance(target, id); + return true; + } + + private static bool TryConvertJson(object value, Type target, out object? result) + { + result = null; + var node = EntryJson.ToNode(value); + if (node is null) + { + return true; + } + + result = target == typeof(JsonNode) ? node : JsonSerializer.SerializeToElement(node); + return true; + } + + private bool TryBuildBlockList(object value, out object? result) + { + List blocks = []; + foreach (var item in ItemsOf(value)) + { + var pairs = Pairs(item); + var name = RawValue.Unwrap(pairs.FirstOrDefault(pair => pair.Key == Discriminator).Value) as string; + if (name is not null && schema.FindBlock(name)?.ClrType is { } clrType) + { + blocks.Add(ToInstance(clrType, pairs)); + } + else if (item is not null) + { + blocks.Add(item); + } + } + + result = new BlockList(blocks); + return true; + } + + private bool TryBuildList(object value, Type target, Type itemType, out object? result) + { + result = null; + List items = []; + foreach (var item in ItemsOf(value)) + { + if (!TryConvert(item, itemType, out var converted)) + { + return false; + } + + items.Add(converted); + } + + if (target == typeof(MediaRefList)) + { + result = new MediaRefList(items.Cast()); + return true; + } + + var array = Array.CreateInstance(itemType, items.Count); + for (var index = 0; index < items.Count; index++) + { + array.SetValue(items[index], index); + } + + if (target.IsAssignableFrom(array.GetType())) + { + result = array; + return true; + } + + // A RefList or a List: each has a constructor that takes the items. + result = Activator.CreateInstance(target, array); + return result is not null; + } + + private static Type? ListItemType(Type target) + { + if (target == typeof(string) || !typeof(IEnumerable).IsAssignableFrom(target) && !target.IsInterface) + { + return null; + } + + if (target.IsArray) + { + return target.GetElementType(); + } + + return new[] { target }.Concat(target.GetInterfaces()) + .FirstOrDefault(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + ?.GetGenericArguments()[0]; + } + + private static bool TryConvertScalar(object raw, Type target, out object? result) + { + result = null; + try + { + switch (raw) + { + case var _ when target.IsInstanceOfType(raw): + result = raw; + return true; + case string text when target.IsEnum: + if (text.Length > 0 && !char.IsAsciiDigit(text[0]) && Enum.TryParse(target, text, ignoreCase: true, out var member)) + { + result = member; + return true; + } + + return false; + case string text when target == typeof(DateOnly): + if (DateOnly.TryParse(text, CultureInfo.InvariantCulture, out var day)) + { + result = day; + return true; + } + + return false; + case string text when target == typeof(DateTimeOffset): + if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var moment)) + { + result = moment; + return true; + } + + return false; + case not string when RawValue.IsNumber(raw) && Type.GetTypeCode(target) is not (TypeCode.Object or TypeCode.String or TypeCode.Boolean or TypeCode.DateTime): + result = Convert.ChangeType(raw, target, CultureInfo.InvariantCulture); + return true; + default: + return false; + } + } + catch (OverflowException) + { + return false; + } + } + + private ContentDocument ToFields(object instance) + { + ContentDocument fields = []; + var type = instance.GetType(); + if (schema.Blocks.FirstOrDefault(block => block.ClrType == type) is { } named) + { + fields[Discriminator] = named.Name; + } + + foreach (var (field, property) in MapOf(type).Fields) + { + var value = ToApi(property.GetValue(instance)); + if (!IsEmpty(value)) + { + fields[field.Name] = value; + } + } + + return fields; + } + + private static bool IsEmpty(object? value) => value switch + { + null or "" => true, + RichText document => document == RichText.Empty, + ContentDocument group => group.Values.All(IsEmpty), + ICollection list => list.Count == 0, + _ => false, + }; + + private object? ToApi(object? value) + { + switch (value) + { + case null or string or bool or JsonNode or RichText or DateOnly or DateTimeOffset or JsonElement or Guid: + return value; + case Enum member: + return SchemaNaming.ToCamelCase(member.ToString()); + case MediaRef media: + return media.IsEmpty ? null : media.Id; + case MediaRefList medias: + return medias.Where(media => !media.IsEmpty).Select(media => media.Id).ToArray(); + case BlockList blocks: + return blocks.Select(block => (object?)ToFields(block)).ToList(); + case not null when RawValue.IsNumber(value): + return value; + } + + var type = value!.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Ref<>)) + { + return EntryCanonicalizer.TryReadId(value, out var id) && id != Guid.Empty ? id : null; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(RefList<>)) + { + return ((IEnumerable)value).Cast().Select(item => EntryCanonicalizer.TryReadId(item, out var id) ? id : Guid.Empty).Where(id => id != Guid.Empty).ToArray(); + } + + if (Attribute.IsDefined(type, typeof(ComponentAttribute)) || Attribute.IsDefined(type, typeof(BlockAttribute))) + { + return ToFields(value); + } + + return value is IEnumerable items and not IEnumerable> ? items.Cast().Select(ToApi).ToList() : value; + } + + private static List ItemsOf(object? value) => value switch + { + null => [], + JsonArray array => [.. array], + string or IEnumerable> => [], + IEnumerable items => [.. items.Cast()], + _ => [], + }; + + private static List> Pairs(object? value) => + EntryCanonicalizer.TryReadMap(value, out var map) && map is not null ? [.. map] : []; + + private ClassMap MapOf(Type clrType) => _classes.GetOrAdd(clrType, type => + { + var described = schema.FindContentType(type) + ?? schema.Components.Concat(schema.Blocks).FirstOrDefault(item => item.ClrType == type); + if (described is null) + { + return new ClassMap([]); + } + + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetIndexParameters().Length == 0) + .ToDictionary(property => SchemaNaming.ToCamelCase(property.Name), StringComparer.Ordinal); + + return new ClassMap([.. described.Fields + .Where(field => properties.ContainsKey(field.Name)) + .Select(field => (field, properties[field.Name]))]); + }); + + private sealed record ClassMap(IReadOnlyList<(SchemaField Field, PropertyInfo Property)> Fields); +} diff --git a/src/FluentCMS.Core/Content/ContentErrorCodes.cs b/src/FluentCMS.Core/Content/ContentErrorCodes.cs new file mode 100644 index 000000000..a110ec06b --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentErrorCodes.cs @@ -0,0 +1,6 @@ +namespace FluentCMS.Core.Content; + +internal static class ContentErrorCodes +{ + public const string SingletonExists = "FCMS2401"; +} diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs new file mode 100644 index 000000000..0904848db --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -0,0 +1,674 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Localization; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Localization; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Content; + +// Where permissions, field limits, hooks, validation and versions meet. A write goes: the permission on the type, then +// in one transaction the entry and its row rule, the expected version, the field limits, BeforeValidate, validation, +// BeforeCreate or BeforeUpdate, the write and the AfterX hooks, and only after the commit the change sink and the +// response, which is read back like any other read so that a hook's values cannot reach it. A call from a hook joins the transaction. +internal sealed class ContentService( + CmsDbContext context, + SchemaModel schema, + IOperationContextAccessor accessor, + IAuthorizationEvaluator evaluator, + ContentQueryEngine engine, + LocaleFallbackReader fallback, + ILocaleResolver locales, + HookPipeline hooks, + ValidationPipeline validation, + EntryStore store, + EntryCanonicalizer canonicalizer, + EntryComposer composer, + ContentEntryMapper mapper, + ResponseRichText richText, + IEnumerable sinks) : IContentService +{ + private readonly List _pending = []; + + public Task GetAsync(string type, Guid id, GetOptions? options = null, CancellationToken cancellationToken = default) => + GetAsync(type, id, options, everySystemField: false, cancellationToken); + + public async Task GetAsync(string type, Guid id, GetOptions? options, bool everySystemField, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(type); + + options ??= new GetOptions(); + var query = new ContentQuery + { + Filter = new FieldFilter("id", FilterOperator.Equal, id), + Page = new PageRequest { Size = 1, WithTotal = false }, + Select = options.Select, + Populate = options.Populate, + PopulateDepth = options.PopulateDepth, + Status = options.Status, + Locale = options.Locale, + Fallback = options.Fallback, + RichTextFormat = options.RichTextFormat, + }; + + var items = (await ReadAsync(type, query, id, everySystemField, cancellationToken)).Items; + return items.Count > 0 ? items[0] : null; + } + + public Task> QueryAsync(string type, ContentQuery query, CancellationToken cancellationToken = default) => + QueryAsync(type, query, everySystemField: false, cancellationToken); + + public Task> QueryAsync(string type, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(query); + + return ReadAsync(type, query, null, everySystemField, cancellationToken); + } + + public async Task CountAsync(string type, ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var operation = Current(); + var query = new ContentQuery + { + Filter = filter, + Page = new PageRequest { Size = 1, WithTotal = true }, + Select = ["id"], + Status = status, + Site = operation.Site, + Locale = await ResolveLocaleAsync(operation.Site, operation.Locale, cancellationToken), + Fallback = LocaleFallback.None, + }; + + return (await engine.ExecuteAsync(type, query, cancellationToken)).Total ?? 0; + } + + public async Task CreateAsync(string type, ContentDocument entry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(entry); + + var write = await BeginAsync(type, ContentOperation.Create, cancellationToken); + var id = Guid.CreateVersion7(); + var row = await InTransactionAsync( + async () => + { + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, null, write.Target, cancellationToken); + Reject(write, entry, writable); + + if (write.Type.Kind == SchemaTypeKind.Singleton && await store.AnyEntryAsync(write.Type, write.Site, cancellationToken)) + { + throw new ValidationException([new ValidationError("", ContentErrorCodes.SingletonExists, $"{write.Type.Name} is a singleton and already has its entry.")]); + } + + var document = new Document + { + DocumentId = id, + Locale = write.DocumentLocale, + Type = write.Type.Name, + SiteId = write.DocumentSite, + Status = EntryStatus.Draft, + DraftVersion = 1, + CreatedAt = write.Operation.Timestamp, + CreatedBy = write.Operation.Principal.Id, + UpdatedAt = write.Operation.Timestamp, + UpdatedBy = write.Operation.Principal.Id, + }; + + ContentDocument composed = new(entry); + foreach (var field in write.Type.Fields) + { + if (field.DefaultValue is { } value && !composed.ContainsKey(field.Name)) + { + composed[field.Name] = value; + } + } + + var working = Prepare(write, composed, document); + working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, null, writable, document, cancellationToken); + + store.AddDocument(document); + await store.WriteDraftAsync(write.Type, document, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); + + await hooks.RunAsync(HookKind.AfterCreate, write.Type, write.Operation, working, null, cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.DraftSaved, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return await RespondAsync(write, row, cancellationToken); + } + + public Task UpdateAsync(string type, Guid id, ContentDocument entry, int expectedVersion, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(entry); + + return SaveAsync(type, id, entry, expectedVersion, isPatch: false, cancellationToken); + } + + public Task PatchAsync(string type, Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(patch); + + return SaveAsync(type, id, patch, expectedVersion, isPatch: true, cancellationToken); + } + + public async Task DeleteAsync(string type, Guid id, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Delete, cancellationToken); + await InTransactionAsync( + async () => + { + var (row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Delete, id, trashed: false, cancellationToken); + + var working = ContentCopy.Of(stored); + await hooks.RunAsync(HookKind.BeforeDelete, write.Type, write.Operation, working, stored, cancellationToken); + await store.SetTrashedAsync(id, write.Operation.Timestamp, write.Operation.Principal.Id, cancellationToken); + await hooks.RunAsync(HookKind.AfterDelete, write.Type, write.Operation, working, stored, cancellationToken); + + _pending.Add(new EntryChange(EntryChangeKind.Trashed, write.Type.Name, id, row.Locale, row.DraftVersion, write.Operation)); + return row; + }, + cancellationToken); + } + + public async Task RestoreAsync(string type, Guid id, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Restore, cancellationToken); + var restored = await InTransactionAsync( + async () => + { + var (row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Restore, id, trashed: true, cancellationToken); + + var working = ContentCopy.Of(stored); + await hooks.RunAsync(HookKind.BeforeRestore, write.Type, write.Operation, working, stored, cancellationToken); + await store.SetTrashedAsync(id, null, null, cancellationToken); + await hooks.RunAsync(HookKind.AfterRestore, write.Type, write.Operation, working, stored, cancellationToken); + + _pending.Add(new EntryChange(EntryChangeKind.Restored, write.Type.Name, id, row.Locale, row.DraftVersion, write.Operation)); + return row; + }, + cancellationToken); + + return await RespondAsync(write, restored, cancellationToken); + } + + // No ambient operation is not an anonymous one: it is a bug in the entry point, and nothing runs. A client's origin + // never runs as the system principal, whatever set it. + private OperationContext Current() + { + var operation = accessor.Current + ?? throw new InvalidOperationException("The content service runs inside an operation; the entry point sets IOperationContextAccessor.Current."); + + if (operation.Principal.IsSystem && !SystemOperation.Allows(operation.Origin)) + { + throw new InvalidOperationException($"The system principal cannot come from the origin {operation.Origin}."); + } + + return operation; + } + + private async Task ResolveLocaleAsync(SiteId site, string? locale, CancellationToken cancellationToken) => + await locales.ResolveAsync(new LocaleRequest(site) { Parameter = locale }, cancellationToken); + + // Reads ask the engine, which holds the permission on the type, the row rule, the site and the field limits as one query. + private async Task> ReadAsync(string typeName, ContentQuery query, Guid? id, bool everySystemField, CancellationToken cancellationToken) + { + richText.ThrowIfUnknown(query.RichTextFormat); + + var operation = Current(); + var site = query.Site ?? operation.Site; + var locale = await ResolveLocaleAsync(site, query.Locale ?? operation.Locale, cancellationToken); + var everyLocale = locale == ILocaleResolver.All; + var oneLocale = everyLocale ? await ResolveLocaleAsync(site, null, cancellationToken) : locale; + + var bound = query with { Site = site, Locale = oneLocale }; + IReadOnlyList found = []; + ContentQueryResult? page = null; + if (!everyLocale || id is null) + { + page = await engine.ExecuteAsync(typeName, bound, everySystemField, cancellationToken); + found = page.Items; + } + + // The type is known here, or the engine refused it; a read of every locale by id has not asked the engine yet. + var type = schema.FindContentType(typeName) + ?? throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{typeName}' is not a content type that can be read.")]); + + var access = new FieldAccess(await evaluator.ResolveAsync(operation, cancellationToken), schema); + if (everyLocale) + { + var ids = id is { } single ? [single] : found.Select(entry => (Guid)entry["id"]!).ToList(); + var items = await ReadEveryLocaleAsync(type, bound, ids, site, operation, access, cancellationToken); + return new PagedResult(items, page?.Total, page?.Page, page?.PageSize ?? 1, page?.NextCursor); + } + + var target = new AccessTarget(type.Scope == ContentScope.Global ? operation.Site : site, locale); + foreach (var entry in found) + { + access.OmitUnreadable(entry, type, target); + } + + if (type.Localized) + { + found = await ApplyFallbackAsync(type, bound, found, id, site, locale, access, target, cancellationToken); + } + + if (!ResponseRichText.IsJson(query.RichTextFormat)) + { + foreach (var entry in found) + { + richText.Apply(type, entry, query.RichTextFormat!); + } + } + + return new PagedResult(found, page?.Total, page?.Page, page?.PageSize ?? found.Count, page?.NextCursor); + } + + private async Task> ApplyFallbackAsync(SchemaType type, ContentQuery query, IReadOnlyList found, Guid? id, SiteId site, string locale, FieldAccess access, AccessTarget target, CancellationToken cancellationToken) + { + var chain = await fallback.ChainAsync(site, locale, query.Fallback, cancellationToken); + if (found.Count > 0 || id is not { } single || query.Fallback == LocaleFallback.None) + { + await fallback.ApplyAsync(type.Name, query, found, chain, cancellationToken); + return found; + } + + // An entry read by id that has no row in this locale is served by the first locale of the chain that has one, + // and every localized field it holds then counts as fallen back. A list does not do this: decision 44. + if (await fallback.ReadFromChainAsync(type.Name, query, single, chain, cancellationToken) is not { } served) + { + return []; + } + + // Read under the locale that served it, and limited as well by what the requested locale lets the principal read. + served.Remove(LocaleFallbackReader.FallbacksKey, out var map); + access.OmitUnreadable(served, type, target); + if (map is ContentDocument fields) + { + foreach (var name in fields.Keys.Where(name => !served.ContainsKey(name)).ToList()) + { + fields.Remove(name); + } + + served[LocaleFallbackReader.FallbacksKey] = fields; + } + + return [served]; + } + + private async Task> ReadEveryLocaleAsync(SchemaType type, ContentQuery query, IReadOnlyList ids, SiteId site, OperationContext operation, FieldAccess access, CancellationToken cancellationToken) + { + var byEntry = await fallback.ReadAllAsync(type.Name, query with { Fallback = LocaleFallback.None }, ids, site, cancellationToken); + List items = []; + foreach (var id in ids) + { + var map = byEntry[id]; + foreach (var (code, value) in map) + { + var entry = (ContentDocument)value!; + access.OmitUnreadable(entry, type, new AccessTarget(type.Scope == ContentScope.Global ? operation.Site : site, code)); + if (!ResponseRichText.IsJson(query.RichTextFormat)) + { + richText.Apply(type, entry, query.RichTextFormat!); + } + } + + if (map.Count > 0) + { + items.Add(new ContentDocument { ["id"] = id, ["locales"] = map }); + } + } + + return items; + } + + private async Task BeginAsync(string typeName, ContentOperation operationKind, CancellationToken cancellationToken) + { + var operation = Current(); + var type = schema.FindContentType(typeName) + ?? throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{typeName}' is not a content type.")]); + + var locale = await ResolveLocaleAsync(operation.Site, operation.Locale, cancellationToken); + if (locale == ILocaleResolver.All) + { + throw new ValidationException([new ValidationError("locale", QueryErrorCodes.InvalidParameter, "A write needs one locale.")]); + } + + var target = new AccessTarget(operation.Site, locale); + var permissions = await evaluator.ResolveAsync(operation, cancellationToken); + if (!permissions.Allows(type, operationKind, target)) + { + throw new ContentAccessDeniedException(type.Name, CorePermissions.OperationName(operationKind)); + } + + return new WriteScope(operation, type, operation.Site, locale, target, permissions, new FieldAccess(permissions, schema)); + } + + private async Task InTransactionAsync(Func> work, CancellationToken cancellationToken) + { + if (context.Database.CurrentTransaction is not null) + { + return await work(); + } + + // On SQLite this takes the write lock at once, so a second writer waits here and then sees the first one's version. + var committed = false; + T result; + var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + await using (transaction) + { + try + { + result = await work(); + await transaction.CommitAsync(cancellationToken); + committed = true; + } + finally + { + if (!committed) + { + context.ChangeTracker.Clear(); + _pending.Clear(); + } + } + } + + List changes = [.. _pending]; + _pending.Clear(); + foreach (var change in changes) + { + foreach (var sink in sinks) + { + await sink.PublishAsync(change, cancellationToken); + } + } + + return result; + } + + private async Task SaveAsync(string typeName, Guid id, ContentDocument changes, int expectedVersion, bool isPatch, CancellationToken cancellationToken) + { + var write = await BeginAsync(typeName, ContentOperation.Update, cancellationToken); + var type = write.Type; + var row = await InTransactionAsync( + async () => + { + var documents = await store.DocumentsAsync(type, id, write.Site, trashed: false, cancellationToken); + if (documents.Count == 0) + { + throw new EntryNotFoundException(type.Name, id); + } + + // A locale the entry does not have yet is read against the locale it does have, and belongs to the same owner. + var existing = documents.Find(item => item.Locale == write.DocumentLocale); + var addsLocale = existing is null; + var target = addsLocale ? write.Target with { RowLocale = documents[0].Locale } : write.Target; + + if (!await evaluator.CanAccessEntryAsync(write.Operation, type, ContentOperation.Update, id, target, QueryStatus.Draft, cancellationToken)) + { + throw new EntryNotFoundException(type.Name, id); + } + + var document = existing ?? new Document + { + DocumentId = id, + Locale = write.DocumentLocale, + Type = type.Name, + SiteId = documents[0].SiteId, + Status = EntryStatus.Draft, + DraftVersion = 0, + CreatedAt = write.Operation.Timestamp, + CreatedBy = documents[0].CreatedBy, + UpdatedAt = write.Operation.Timestamp, + UpdatedBy = write.Operation.Principal.Id, + }; + + if (document.DraftVersion != expectedVersion) + { + throw await ConflictAsync(write, id, expectedVersion, document.DraftVersion, cancellationToken); + } + + var writable = await evaluator.WritableFieldsAsync(write.Operation, type, ContentOperation.Update, id, target, cancellationToken); + var stored = await store.ReadAsync(type, document, ContentState.Draft, cancellationToken); + var refused = isPatch ? changes : WithoutUnchanged(write, changes, stored, writable); + Reject(write, refused, writable); + + var composed = isPatch + ? composer.Patch(type, stored, changes) + : EntryComposer.Replace(type, stored, refused, field => Keeps(write, field, writable, addsLocale)); + var working = Prepare(write, composed, document); + composer.CarryHidden(type, stored, working, includingGroups: !isPatch); + + working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, stored, writable, document, cancellationToken); + + var sharedChanged = type.Localized && type.Fields.Any(field => !field.Localizable && !EntryJson.Same(working[field.Name], stored[field.Name])); + var next = expectedVersion + 1; + var status = document.Status == EntryStatus.Published ? EntryStatus.Modified : document.Status; + if (addsLocale) + { + document.DraftVersion = next; + store.AddDocument(document); + } + else if (await store.AdvanceVersionAsync(id, document.Locale, expectedVersion, status, write.Operation.Timestamp, write.Operation.Principal.Id, cancellationToken)) + { + document.DraftVersion = next; + document.Status = status; + document.UpdatedAt = write.Operation.Timestamp; + document.UpdatedBy = write.Operation.Principal.Id; + } + else + { + throw await ConflictAsync(write, id, expectedVersion, await store.DraftVersionAsync(id, document.Locale, cancellationToken), cancellationToken); + } + + await store.WriteDraftAsync(type, document, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); + if (sharedChanged) + { + await store.MarkOtherLocalesModifiedAsync(id, document.Locale, cancellationToken); + } + + // A write may not take an entry out of the reach of the rule that let the principal change it. + if (!await evaluator.CanAccessEntryAsync(write.Operation, type, ContentOperation.Update, id, write.Target, QueryStatus.Draft, cancellationToken)) + { + throw new ContentAccessDeniedException(type.Name, CorePermissions.OperationName(ContentOperation.Update)); + } + + EntryStore.ApplySystem(working, document); + await hooks.RunAsync(HookKind.AfterUpdate, type, write.Operation, working, stored, cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.DraftSaved, type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return await RespondAsync(write, row, cancellationToken); + } + + // A full update sends what a read gave it: a field the principal can see but not change is no write as long as it + // is the same. Not for what it cannot see, because the answer would tell it the value: so the comparison leaves out + // hidden values inside the field, and a value that names one is never "the same". + private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument changes, ContentDocument stored, WritableFields writable) + { + ContentDocument result = []; + foreach (var (key, value) in changes) + { + var unchanged = write.Type.FindField(key) is { Hidden: false } field + && (field.ReadOnly || !writable.Includes(key)) + && write.Permissions.CanReadField(write.Type, key, write.Target) + && write.Access.IsClean(field, value, write.Type) + && canonicalizer.TryRead(field, value, out var read) + && JsonNode.DeepEquals(Visible(field, read), Visible(field, stored[key])); + + if (!unchanged) + { + result[key] = value; + } + } + + return result; + } + + private JsonNode? Visible(SchemaField field, object? value) + { + var node = EntryJson.ToNode(value); + HiddenFields.Remove(schema, node, field); + return node; + } + + private static bool Keeps(WriteScope write, SchemaField field, WritableFields writable, bool addsLocale) => + field.Hidden + || field.ReadOnly + || !writable.Includes(field.Name) + || !write.Permissions.CanReadField(write.Type, field.Name, write.Target) + || (addsLocale && !field.Localizable); + + private static void Reject(WriteScope write, ContentDocument changes, WritableFields writable) + { + try + { + write.Access.RejectUnwritable(changes, write.Type, writable); + } + catch (ArgumentException exception) + { + // JSON that repeats a key cannot be read, and so cannot be shown to stay within the limits. + throw new ValidationException([new ValidationError("", ValidationErrorCodes.InvalidValue, exception.Message)]); + } + } + + private ContentDocument Prepare(WriteScope write, ContentDocument composed, Document document) + { + var working = canonicalizer.Canonicalize(write.Type, composed); + ContentDocument withSystem = []; + EntryStore.ApplySystem(withSystem, document); + foreach (var (key, value) in working) + { + if (SystemField.Find(key) is null) + { + withSystem[key] = value; + } + } + + return withSystem; + } + + // Validation runs on what hooks left, and again if BeforeCreate or BeforeUpdate changed it, so that a hook cannot get + // a value past it. A hook is held to the principal's field limits as well, except on a hidden or read-only field, + // which no client can reach and which is what a hook is for. + private async Task RunBeforeHooksAsync(WriteScope write, HookKind before, ContentDocument working, ContentDocument? previous, WritableFields writable, Document document, CancellationToken cancellationToken) + { + var baseline = EntryJson.Fields(write.Type, working); + await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, previous, cancellationToken); + working = AfterHook(write, working, baseline, writable, document); + await ValidateAsync(write, working, document.DocumentId, cancellationToken); + + var validated = EntryJson.Fields(write.Type, working); + await hooks.RunAsync(before, write.Type, write.Operation, working, previous, cancellationToken); + working = AfterHook(write, working, validated, writable, document); + + if (!JsonNode.DeepEquals(validated, EntryJson.Fields(write.Type, working))) + { + await ValidateAsync(write, working, document.DocumentId, cancellationToken); + } + + return working; + } + + private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, WritableFields writable, Document document) + { + var after = Prepare(write, working, document); + var fields = EntryJson.Fields(write.Type, after); + write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(baseline[name], fields[name])), write.Type, writable); + return after; + } + + private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid id, CancellationToken cancellationToken) + { + var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, working) : null; + var result = await validation.ValidateAsync( + write.Type, + mapper.ValidationValues(write.Type, working), + entry, + write.Operation, + ValidationMode.Draft, + cancellationToken, + (context, token) => new ValueTask(store.CheckUniqueAsync(write.Type, working, id, write.Site, write.Locale, context, token))); + + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + + // One entry is one document: it goes to the trash and comes back in every locale, so the principal must hold the + // operation on all of them. No row that it may reach reads as no entry, which keeps a client from probing. + private async Task<(Document Row, ContentDocument Stored)> ReachAllLocalesAsync(WriteScope write, ContentOperation operation, Guid id, bool trashed, CancellationToken cancellationToken) + { + var documents = await store.DocumentsAsync(write.Type, id, write.Site, trashed, cancellationToken); + var reached = 0; + foreach (var document in documents) + { + var target = new AccessTarget(write.Site, write.Type.Localized ? document.Locale : write.Locale); + if (await evaluator.CanAccessEntryAsync(write.Operation, write.Type, operation, id, target, QueryStatus.Draft, cancellationToken)) + { + reached++; + } + } + + if (reached == 0) + { + throw new EntryNotFoundException(write.Type.Name, id); + } + + if (reached < documents.Count) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(operation)); + } + + var row = documents.Find(document => document.Locale == write.DocumentLocale) ?? documents[0]; + return (row, await store.ReadAsync(write.Type, row, ContentState.Draft, cancellationToken)); + } + + private async Task ConflictAsync(WriteScope write, Guid id, int expected, int current, CancellationToken cancellationToken) => + new(expected, current, await ReadDraftOrNullAsync(write, id, cancellationToken)); + + // A principal may hold a write without the read. It still gets the id and the version, which it needs for its next write. + private async Task RespondAsync(WriteScope write, Document row, CancellationToken cancellationToken) => + await ReadDraftOrNullAsync(write, row.DocumentId, cancellationToken) + ?? new ContentDocument { ["id"] = row.DocumentId, ["version"] = row.DraftVersion }; + + private async Task ReadDraftOrNullAsync(WriteScope write, Guid id, CancellationToken cancellationToken) + { + try + { + return await GetAsync(write.Type.Name, id, new GetOptions { Status = QueryStatus.Draft, Locale = write.Locale, Fallback = LocaleFallback.None }, everySystemField: false, cancellationToken); + } + catch (ValidationException exception) when (exception.Errors.All(error => error.Path is "type" or "status")) + { + return null; + } + } + + private sealed record WriteScope(OperationContext Operation, SchemaType Type, SiteId Site, string Locale, AccessTarget Target, EffectivePermissions Permissions, FieldAccess Access) + { + public string DocumentLocale => Type.Localized ? Locale : ""; + + public Guid? DocumentSite => Type.Scope == ContentScope.Global ? null : Site.Value; + } +} diff --git a/src/FluentCMS.Core/Content/EntryCanonicalizer.cs b/src/FluentCMS.Core/Content/EntryCanonicalizer.cs new file mode 100644 index 000000000..8816ad297 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryCanonicalizer.cs @@ -0,0 +1,465 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.ProseMirror; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Storage; +using FluentCMS.Media; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// Turns what a client, a mapper or a hook hands over into the one form the service works on and stores: +// a column field holds its CLR value, a reference a Guid or a Guid[], rich text its canonical RichText, a single +// component a ContentDocument or null, and anything stored as JSON a JsonNode. Calling it on its own result changes nothing. +// The placeholders of spec 4.3 go in here, so that hooks, validation and the write all see the same entry. +internal sealed class EntryCanonicalizer(SchemaModel schema) +{ + private const string Discriminator = "type"; + private const string IdKey = "id"; + private const int SlugLength = 200; + + public ContentDocument Canonicalize(SchemaType type, ContentDocument entry) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(entry); + + List errors = []; + ContentDocument result = []; + foreach (var (key, value) in entry) + { + if (SystemField.Find(key) is not null) + { + result[key] = value; + } + else if (type.FindField(key) is null) + { + errors.Add(Invalid(key, $"'{key}' is not a field of {type.Name}.")); + } + } + + try + { + ReadFields(type, entry, result, "", flattened: false, errors); + EntryJson.ThrowIfUnreadable(EntryJson.Fields(type, result)); + } + catch (ArgumentException exception) + { + // JSON that repeats a key only shows it when it is read. + errors.Add(Invalid("", exception.Message)); + } + + return errors.Count == 0 ? result : throw new ValidationException(errors); + } + + // One field on its own, to compare a client's value with the stored one. False when the value cannot be read. + public bool TryRead(SchemaField field, object? raw, out object? value) + { + List errors = []; + value = Top(field, raw, field.Name, flattened: false, errors); + return errors.Count == 0; + } + + private void ReadFields(SchemaType owner, IEnumerable> values, ContentDocument into, string prefix, bool flattened, List errors) + { + var known = values as ContentDocument ?? new ContentDocument(values); + foreach (var field in owner.Fields) + { + known.TryGetValue(field.Name, out var raw); + into[field.Name] = Top(field, raw, prefix + field.Name, flattened, errors); + } + + // After the loop, so that the source has its value whichever comes first. + foreach (var field in owner.Fields.Where(field => field.FieldType == FieldTypeNames.Slug)) + { + if (into[field.Name] is null or "" && Source(field, into) is { } source) + { + into[field.Name] = SlugFieldType.Slugify(source, field.MaxLength ?? SlugLength); + } + } + } + + private static string? Source(SchemaField field, ContentDocument values) => + field.Settings.TryGetValue(FieldSettings.Source, out var name) && name is string source && values.TryGetValue(source, out var value) ? value as string : null; + + private object? Top(SchemaField field, object? raw, string path, bool flattened, List errors) + { + var kind = schema.FieldTypes.Find(field.FieldType)!.StorageKind(field); + if (kind == FieldStorageKind.Reference) + { + return ReadReference(field, raw, path, errors); + } + + if (field.FieldType == FieldTypeNames.Component && !field.Multiple) + { + return ReadGroup(field, raw, path, errors); + } + + if (kind == FieldStorageKind.Json && field.FieldType != FieldTypeNames.RichText) + { + return ToNode(field, raw, path, errors) ?? (IsList(field) ? new JsonArray() : null); + } + + var value = Coerce(field, raw, path, errors); + if (value is not null || flattened) + { + return value; + } + + return field.FieldType switch + { + FieldTypeNames.RichText => RichText.Empty, + FieldTypeNames.Slug => "", + FieldTypeNames.Text or FieldTypeNames.LongText when field.Required => "", + _ => null, + }; + } + + private static bool IsList(SchemaField field) => field.Multiple || field.FieldType == FieldTypeNames.Blocks; + + private object? Coerce(SchemaField field, object? raw, string path, List errors) + { + // The placeholder of a slug that has nothing to derive from comes back here, and the field type would refuse it. + if (field.FieldType == FieldTypeNames.Slug && RawValue.Unwrap(raw) is "") + { + return null; + } + + if (!schema.FieldTypes.Find(field.FieldType)!.TryCoerce(raw, field, out var value, out var message)) + { + errors.Add(Invalid(path, $"The value {message}.")); + return null; + } + + return field.FieldType == FieldTypeNames.RichText && value is RichText document ? CanonicalRichText(document, path, errors) : value; + } + + private static RichText? CanonicalRichText(RichText document, string path, List errors) + { + if (RichTextParser.TryParse(document, out var parsed, out var problems)) + { + return RichTextSerializer.ToRichText(parsed); + } + + errors.AddRange(problems.Select(problem => Invalid(path, $"The rich text is not valid at {problem.Path}: {problem.Message}"))); + return null; + } + + private ContentDocument? ReadGroup(SchemaField field, object? raw, string path, List errors) + { + if (schema.FindComponent((string)field.Settings[FieldSettings.Component]) is not { } component) + { + errors.Add(Invalid(path, "The component is not part of the schema.")); + return null; + } + + if (!TryReadMap(raw, out var map)) + { + errors.Add(Invalid(path, "The value is not an object.")); + return null; + } + + if (map is null) + { + return null; + } + + ReportUnknown(map, component, path, isBlock: false, errors); + ContentDocument group = []; + ReadFields(component, map, group, path + ".", flattened: true, errors); + return group.Values.All(value => value is null or JsonArray { Count: 0 }) ? null : group; + } + + // A value that lives inside JSON: inside a block or a repeated component, or the JSON field itself. + private JsonNode? ToNode(SchemaField field, object? raw, string path, List errors) + { + var kind = schema.FieldTypes.Find(field.FieldType)!.StorageKind(field); + if (kind == FieldStorageKind.Reference) + { + return ReferenceNode(field, ReadReference(field, raw, path, errors)); + } + + switch (field.FieldType) + { + case FieldTypeNames.Blocks: + return ReadObjects(field, raw, path, errors, ReadBlock); + case FieldTypeNames.Component when field.Multiple: + return ReadObjects(field, raw, path, errors, ReadComponentItem); + case FieldTypeNames.Component: + return ReadComponentItem(field, raw, path, errors); + default: + return EntryJson.ToNode(Coerce(field, raw, path, errors)); + } + } + + private static JsonNode? ReferenceNode(SchemaField field, object? value) + { + JsonObject Stub(Guid id) => new() { [IdKey] = id.ToString("D"), [Discriminator] = EntryPaths.ReferenceTarget(field) }; + + return value switch + { + Guid id => Stub(id), + Guid[] ids => new JsonArray([.. ids.Select(id => (JsonNode)Stub(id))]), + _ => null, + }; + } + + private static JsonArray? ReadObjects(SchemaField field, object? raw, string path, List errors, Func, JsonObject?> read) + { + if (!TryReadItems(raw, out var items)) + { + errors.Add(Invalid(path, $"The value is not a list of {(field.FieldType == FieldTypeNames.Blocks ? "blocks" : "objects")}.")); + return null; + } + + if (items is null) + { + return null; + } + + JsonArray result = []; + for (var index = 0; index < items.Count; index++) + { + result.Add(read(field, items[index], $"{path}[{index}]", errors)); + } + + return result; + } + + private JsonObject? ReadBlock(SchemaField field, object? raw, string path, List errors) + { + var allowed = SettingValues.List(field, FieldSettings.Blocks); + if (!TryReadMap(raw, out var map) || map is null) + { + errors.Add(Invalid(path, $"The block is not an object with a \"{Discriminator}\" naming its block.")); + return null; + } + + var name = BlockName(raw, map); + if (name is null || !allowed.Contains(name) || schema.FindBlock(name) is not { } block) + { + errors.Add(new ValidationError(path, ValidationErrorCodes.NotAnAllowedOption, $"The block type {name ?? "(none)"} is not one of: {string.Join(", ", allowed)}.")); + return null; + } + + ReportUnknown(map, block, path, isBlock: true, errors); + var result = ReadObject(block, map, path, errors); + JsonObject named = new() { [Discriminator] = name }; + foreach (var pair in result.ToList()) + { + result.Remove(pair.Key); + named[pair.Key] = pair.Value; + } + + return named; + } + + private string? BlockName(object? raw, Dictionary map) + { + if (map.TryGetValue(Discriminator, out var name)) + { + return RawValue.Unwrap(name) as string; + } + + return raw is null ? null : schema.Blocks.FirstOrDefault(block => block.ClrType == raw.GetType())?.Name; + } + + private JsonObject? ReadComponentItem(SchemaField field, object? raw, string path, List errors) + { + if (schema.FindComponent((string)field.Settings[FieldSettings.Component]) is not { } component) + { + errors.Add(Invalid(path, "The component is not part of the schema.")); + return null; + } + + if (!TryReadMap(raw, out var map)) + { + errors.Add(Invalid(path, "The value is not an object.")); + return null; + } + + if (map is null) + { + return null; + } + + ReportUnknown(map, component, path, isBlock: false, errors); + return ReadObject(component, map, path, errors); + } + + // Only what was given is kept: a JSON object has no column to fill, and a key left out is the same as no value. + private JsonObject ReadObject(SchemaType owner, Dictionary map, string path, List errors) + { + JsonObject result = []; + foreach (var field in owner.Fields) + { + if (map.TryGetValue(field.Name, out var raw)) + { + result[field.Name] = ToNode(field, raw, $"{path}.{field.Name}", errors); + } + } + + return result; + } + + private static void ReportUnknown(Dictionary map, SchemaType owner, string path, bool isBlock, List errors) + { + foreach (var key in map.Keys) + { + if (owner.FindField(key) is null && !(isBlock && key == Discriminator)) + { + errors.Add(Invalid($"{path}.{key}", $"'{key}' is not a field of {owner.Name}.")); + } + } + } + + private static object? ReadReference(SchemaField field, object? raw, string path, List errors) + { + var value = RawValue.Unwrap(raw); + if (value is null) + { + return field.Multiple ? Array.Empty() : null; + } + + if (!field.Multiple) + { + if (TryReadId(value, out var id)) + { + return id == Guid.Empty ? null : id; + } + + errors.Add(Invalid(path, "The value is not an entry id: expected a GUID.")); + return null; + } + + if (!TryReadReferenceList(value, out var items)) + { + errors.Add(Invalid(path, "The value is not a list of entry ids.")); + return Array.Empty(); + } + + List ids = []; + foreach (var item in items) + { + if (TryReadId(RawValue.Unwrap(item), out var id) && id != Guid.Empty) + { + ids.Add(id); + } + else + { + errors.Add(Invalid($"{path}[{ids.Count}]", "The value is not an entry id: expected a GUID.")); + } + } + + return ids.ToArray(); + } + + private static bool TryReadReferenceList(object value, out IEnumerable items) + { + switch (value) + { + case JsonElement { ValueKind: JsonValueKind.Array } array: + items = [.. array.EnumerateArray().Cast()]; + return true; + case string or JsonElement or IEnumerable>: + items = []; + return false; + case IEnumerable list: + items = list.Cast(); + return true; + default: + items = []; + return false; + } + } + + // An id, a Ref<>, a MediaRef, or the reference or populated entry a read gave, so that what was read can be written back. + public static bool TryReadId(object? value, out Guid id) + { + switch (value) + { + case Guid guid: + id = guid; + return true; + case string text: + return Guid.TryParseExact(text, "D", out id); + case MediaRef media: + id = media.Id; + return true; + case JsonElement { ValueKind: JsonValueKind.Object } element when element.TryGetProperty(IdKey, out var inner): + return TryReadId(RawValue.Unwrap(inner), out id); + case IEnumerable> map when map.FirstOrDefault(pair => pair.Key == IdKey) is { Key: not null, Value: var inner }: + return TryReadId(RawValue.Unwrap(inner), out id); + case not null when value.GetType() is { IsGenericType: true } type && type.GetGenericTypeDefinition() == typeof(Ref<>): + id = (Guid)type.GetProperty(nameof(Ref<>.Id))!.GetValue(value)!; + return true; + default: + id = Guid.Empty; + return false; + } + } + + // The keys of an object in any form a field type coerces, plus documents and instances of a component or block class. + public static bool TryReadMap(object? raw, out Dictionary? map) + { + map = null; + switch (raw) + { + case null: + return true; + case JsonElement or JsonNode or string: + if (!RawValue.TryReadJson(raw, out var node, out _)) + { + return false; + } + + if (node is JsonObject json) + { + map = json.ToDictionary(pair => pair.Key, pair => (object?)pair.Value, StringComparer.Ordinal); + } + + return node is null or JsonObject; + case IEnumerable> pairs: + map = pairs.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + return true; + case not IEnumerable when Attribute.IsDefined(raw.GetType(), typeof(ComponentAttribute)) || Attribute.IsDefined(raw.GetType(), typeof(BlockAttribute)): + map = Validation.FieldValue.ReadInstance(raw)!.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + return true; + default: + return false; + } + } + + private static bool TryReadItems(object? raw, out List? items) + { + items = null; + switch (raw) + { + case null: + return true; + case JsonElement or JsonNode or string: + if (!RawValue.TryReadJson(raw, out var node, out _)) + { + return false; + } + + if (node is JsonArray array) + { + items = [.. array]; + } + + return node is null or JsonArray; + case IEnumerable>: + return false; + case IEnumerable list: + items = [.. list.Cast()]; + return true; + default: + return false; + } + } + + private static ValidationError Invalid(string path, string message) => new(path, ValidationErrorCodes.InvalidValue, message); +} diff --git a/src/FluentCMS.Core/Content/EntryChange.cs b/src/FluentCMS.Core/Content/EntryChange.cs new file mode 100644 index 000000000..a4558d6c5 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -0,0 +1,19 @@ +using FluentCMS.Content; + +namespace FluentCMS.Core.Content; + +internal enum EntryChangeKind +{ + DraftSaved, + Trashed, + Restored, +} + +internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation); + +// The seam for the event bus of #2697: the service tells it what changed once the outermost transaction has committed, +// and tells it nothing when that rolls back. The write has happened by then, so an implementation must not throw. +internal interface IEntryChangeSink +{ + ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Core/Content/EntryComposer.cs b/src/FluentCMS.Core/Content/EntryComposer.cs new file mode 100644 index 000000000..d8701417d --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -0,0 +1,210 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// Makes the entry an update asks for out of what is stored and what the client sent, before it is canonicalized. +internal sealed class EntryComposer(SchemaModel schema) +{ + private const string Discriminator = "type"; + + // A full update describes the whole entry, but only as far as the client can see and change it: keep says which + // fields it has no say over. A key that is not a field stays, so that the canonicalizer refuses it. + public static ContentDocument Replace(SchemaType type, ContentDocument stored, ContentDocument incoming, Func keep) + { + ContentDocument composed = []; + foreach (var field in type.Fields) + { + if (incoming.TryGetValue(field.Name, out var value)) + { + composed[field.Name] = value; + } + else if (keep(field)) + { + stored.TryGetValue(field.Name, out var kept); + composed[field.Name] = kept; + } + } + + foreach (var (key, value) in incoming) + { + if (type.FindField(key) is null && SystemField.Find(key) is null) + { + composed[key] = value; + } + } + + return composed; + } + + // RFC 7396: a null removes, an object merges into a single component or a JSON field, and anything else replaces. + // A block list and a repeated component are arrays, so they replace, and rich text is one document. + public ContentDocument Patch(SchemaType type, ContentDocument stored, ContentDocument patch) + { + ContentDocument composed = []; + foreach (var field in type.Fields) + { + stored.TryGetValue(field.Name, out var value); + composed[field.Name] = value; + } + + foreach (var (key, value) in patch) + { + if (SystemField.Find(key) is null) + { + composed[key] = type.FindField(key) is { } field ? Merge(field, stored.GetValueOrDefault(key), value) : value; + } + } + + return composed; + } + + private object? Merge(SchemaField field, object? stored, object? patch) + { + if (RawValue.Unwrap(patch) is null) + { + return null; + } + + if (field.FieldType == FieldTypeNames.Component && !field.Multiple && EntryCanonicalizer.TryReadMap(patch, out var map) && map is not null + && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component) + { + ContentDocument group = stored as ContentDocument is { } existing ? new(existing) : []; + foreach (var (key, value) in map) + { + group[key] = component.FindField(key) is { } inner ? Merge(inner, group.GetValueOrDefault(key), value) : value; + } + + return group; + } + + if (field.FieldType == FieldTypeNames.Json && RawValue.TryReadJson(patch, out var node, out _) && node is JsonObject changes) + { + return MergeJson(stored as JsonNode, changes); + } + + return patch; + } + + private static JsonObject MergeJson(JsonNode? stored, JsonObject patch) + { + var result = stored is JsonObject existing ? (JsonObject)existing.DeepClone() : []; + foreach (var (key, value) in patch) + { + switch (value) + { + case null: + result.Remove(key); + break; + case JsonObject inner: + result[key] = MergeJson(result[key], inner); + break; + default: + result[key] = value.DeepClone(); + break; + } + } + + return result; + } + + // What a client cannot see or change inside a component or a block stays when it sends the rest back. In a list, a + // block keeps those values of the block at the same index, if that is of the same type. A single component only needs + // this on a full update: a patch merged into it already keeps them, and may be the system principal clearing one. + public void CarryHidden(SchemaType type, ContentDocument stored, ContentDocument composed, bool includingGroups) + { + foreach (var field in type.Fields) + { + stored.TryGetValue(field.Name, out var before); + composed.TryGetValue(field.Name, out var after); + if (field.FieldType == FieldTypeNames.Component && !field.Multiple) + { + if (includingGroups && before is ContentDocument oldGroup && after is ContentDocument newGroup + && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component) + { + CarryIntoGroup(component, oldGroup, newGroup); + } + } + else if (before is JsonNode oldNode && after is JsonNode newNode) + { + CarryInside(field, oldNode, newNode); + } + } + } + + private void CarryIntoGroup(SchemaType component, ContentDocument before, ContentDocument after) + { + foreach (var field in component.Fields) + { + if ((field.Hidden || field.ReadOnly) && before.GetValueOrDefault(field.Name) is { } value && after.GetValueOrDefault(field.Name) is null) + { + after[field.Name] = value; + } + else if (before.GetValueOrDefault(field.Name) is JsonNode oldNode && after.GetValueOrDefault(field.Name) is JsonNode newNode) + { + CarryInside(field, oldNode, newNode); + } + else if (field.FieldType == FieldTypeNames.Component && !field.Multiple + && before.GetValueOrDefault(field.Name) is ContentDocument oldGroup && after.GetValueOrDefault(field.Name) is ContentDocument newGroup + && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } inner) + { + CarryIntoGroup(inner, oldGroup, newGroup); + } + } + } + + private void CarryInside(SchemaField field, JsonNode before, JsonNode after) + { + if (before is JsonArray oldItems && after is JsonArray newItems) + { + for (var index = 0; index < Math.Min(oldItems.Count, newItems.Count); index++) + { + if (oldItems[index] is JsonObject oldItem && newItems[index] is JsonObject newItem && OwnerOf(field, oldItem, newItem) is { } owner) + { + CarryIntoObject(owner, oldItem, newItem); + } + } + } + else if (before is JsonObject oldObject && after is JsonObject newObject && OwnerOf(field, oldObject, newObject) is { } owner) + { + CarryIntoObject(owner, oldObject, newObject); + } + } + + private SchemaType? OwnerOf(SchemaField field, JsonObject before, JsonObject after) + { + if (field.FieldType == FieldTypeNames.Component) + { + return schema.FindComponent((string)field.Settings[FieldSettings.Component]); + } + + return field.FieldType == FieldTypeNames.Blocks + && before[Discriminator] is JsonValue oldName && after[Discriminator] is JsonValue newName + && oldName.TryGetValue(out var name) && newName.TryGetValue(out var same) && name == same + ? schema.FindBlock(name) + : null; + } + + private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject after) + { + foreach (var field in owner.Fields) + { + if (before[field.Name] is not { } value) + { + continue; + } + + if (field.Hidden || field.ReadOnly) + { + after[field.Name] ??= value.DeepClone(); + } + else if (after[field.Name] is { } inner) + { + CarryInside(field, value, inner); + } + } + } +} diff --git a/src/FluentCMS.Core/Content/EntryJson.cs b/src/FluentCMS.Core/Content/EntryJson.cs new file mode 100644 index 000000000..96c71c786 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryJson.cs @@ -0,0 +1,117 @@ +using System.Collections; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Media; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// The JSON an entry is stored, snapshotted and compared in. A date inside JSON has exactly the form the block filters +// compare with (#2692), so that a filter on a block finds what was written. +internal static class EntryJson +{ + private const string DateFormat = "yyyy-MM-dd"; + private const string MomentFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'"; + + private static readonly JsonSerializerOptions Options = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public static string Text(JsonNode node) => node.ToJsonString(Options); + + public static string Date(DateOnly day) => day.ToString(DateFormat, CultureInfo.InvariantCulture); + + public static string Moment(DateTimeOffset moment) => moment.UtcDateTime.ToString(MomentFormat, CultureInfo.InvariantCulture); + + // The fields of an entry, which is what a version holds and what a change is decided on. System fields are not in it. + public static JsonObject Fields(SchemaType type, ContentDocument entry) + { + JsonObject fields = []; + foreach (var field in type.Fields) + { + entry.TryGetValue(field.Name, out var value); + fields[field.Name] = ToNode(value); + } + + return fields; + } + + // A JsonObject reads its keys when they are first asked for, and only then finds one that is repeated. + public static void ThrowIfUnreadable(JsonNode? node) + { + switch (node) + { + case JsonObject properties: + foreach (var (_, value) in properties) + { + ThrowIfUnreadable(value); + } + + break; + case JsonArray items: + foreach (var item in items) + { + ThrowIfUnreadable(item); + } + + break; + } + } + + public static bool Same(object? left, object? right) => JsonNode.DeepEquals(ToNode(left), ToNode(right)); + + public static JsonNode? ToNode(object? value) => value switch + { + null => null, + JsonNode node => node.DeepClone(), + JsonElement { ValueKind: JsonValueKind.Undefined or JsonValueKind.Null } => null, + JsonElement element => JsonNode.Parse(element.GetRawText()), + string text => JsonValue.Create(text), + bool flag => JsonValue.Create(flag), + long number => JsonValue.Create(number), + int number => JsonValue.Create(number), + decimal number => JsonValue.Create(number), + double number => JsonValue.Create(number), + DateOnly day => JsonValue.Create(Date(day)), + DateTimeOffset moment => JsonValue.Create(Moment(moment)), + Guid id => JsonValue.Create(id.ToString("D", CultureInfo.InvariantCulture)), + MediaRef media => JsonValue.Create(media.Id.ToString("D", CultureInfo.InvariantCulture)), + RichText document => JsonNode.Parse(document.Json), + IEnumerable> pairs => ToObject(pairs), + IEnumerable items => new JsonArray([.. items.Cast().Select(ToNode)]), + _ => JsonSerializer.SerializeToNode(value, value.GetType(), Options), + }; + + private static JsonObject ToObject(IEnumerable> pairs) + { + JsonObject result = []; + foreach (var (name, value) in pairs) + { + result[name] = ToNode(value); + } + + return result; + } +} + +// Where a stored field sits in an entry: the path of a flattened column goes through the groups above it. +internal static class EntryPaths +{ + public static object? ValueAt(ContentDocument entry, StoredField stored, SchemaType type) + { + object? current = entry; + foreach (var name in Query.QueryPaths.ApiPath(type, stored).Split('.')) + { + if (current is not ContentDocument group || !group.TryGetValue(name, out current)) + { + return null; + } + } + + return current; + } + + public static string ReferenceTarget(SchemaField field) => field.Relation?.Target ?? field.FieldType; +} diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs new file mode 100644 index 000000000..f24feefe5 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -0,0 +1,438 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.ProseMirror; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Content; + +// The rows behind one entry: cms_document, the content tables, cms_reference and cms_version. It knows nothing of +// permissions; ContentService decides, and calls this inside its transaction. #2695 copies between the states with the same reads and writes. +internal sealed class EntryStore(CmsDbContext context, SchemaModel schema) +{ + private const string MediaTarget = "media"; + + // The rows of an entry in the site the operation runs in, one per locale, oldest first. Other sites' entries and other types' are not there. + public async Task> DocumentsAsync(SchemaType type, Guid id, SiteId site, bool trashed, CancellationToken cancellationToken) + { + var typeName = type.Name; + var siteId = (Guid?)site.Value; + IQueryable rows = trashed + ? context.Documents.IgnoreQueryFilters().Where(document => document.DeletedAt != null) + : context.Documents; + + rows = rows.AsNoTracking().Where(document => document.DocumentId == id && document.Type == typeName); + if (type.Scope == ContentScope.Site) + { + rows = rows.Where(document => document.SiteId == siteId); + } + + var found = await rows.ToListAsync(cancellationToken); + return [.. found.OrderBy(document => document.Id)]; + } + + public Task AnyEntryAsync(SchemaType type, SiteId site, CancellationToken cancellationToken) + { + var typeName = type.Name; + var siteId = (Guid?)site.Value; + var rows = context.Documents.IgnoreQueryFilters().Where(document => document.Type == typeName); + return type.Scope == ContentScope.Site + ? rows.AnyAsync(document => document.SiteId == siteId, cancellationToken) + : rows.AnyAsync(cancellationToken); + } + + public async Task DraftVersionAsync(Guid id, string locale, CancellationToken cancellationToken) => + await context.Documents.IgnoreQueryFilters().AsNoTracking() + .Where(document => document.DocumentId == id && document.Locale == locale) + .Select(document => document.DraftVersion) + .FirstOrDefaultAsync(cancellationToken); + + // The entry as it is stored, hidden fields included, in the form EntryCanonicalizer gives. Not for a response. + public async Task ReadAsync(SchemaType type, Document row, short state, CancellationToken cancellationToken) + { + var id = row.DocumentId; + var locale = row.Locale; + var shared = await RowAsync(StorageNames.Table(type), id, state, null, tracked: false, cancellationToken); + var localized = type.Localized ? await RowAsync(StorageNames.LocaleTable(type), id, state, locale, tracked: false, cancellationToken) : null; + + var typeName = type.Name; + var references = await context.References.AsNoTracking() + .Where(reference => reference.SourceType == typeName + && reference.SourceDocumentId == id + && reference.State == state + && (reference.Locale == "" || reference.Locale == locale)) + .OrderBy(reference => reference.Position) + .ToListAsync(cancellationToken); + var byField = references.ToLookup(reference => (reference.Field, reference.Locale)); + + ContentDocument entry = []; + ApplySystem(entry, row); + FillFields(type, type, "", entry, shared, localized, byField, locale); + return entry; + } + + public static void ApplySystem(ContentDocument entry, Document row) + { + entry["id"] = row.DocumentId; + entry["status"] = row.Status; + entry["locale"] = row.Locale; + entry["version"] = row.DraftVersion; + entry["createdAt"] = row.CreatedAt; + entry["updatedAt"] = row.UpdatedAt; + entry["publishedAt"] = row.PublishedAt; + entry["createdBy"] = row.CreatedBy; + entry["updatedBy"] = row.UpdatedBy; + } + + private void FillFields( + SchemaType root, + SchemaType owner, + string prefix, + ContentDocument into, + Dictionary? shared, + Dictionary? localized, + ILookup<(string Field, string Locale), Reference> references, + string locale) + { + var stored = FieldStorage.Of(schema, root).ToDictionary(item => QueryPaths.ApiPath(root, item), StringComparer.Ordinal); + foreach (var field in owner.Fields) + { + var path = prefix + field.Name; + if (field.FieldType == FieldTypeNames.Component && !field.Multiple) + { + ContentDocument group = []; + var component = schema.FindComponent((string)field.Settings[FieldSettings.Component])!; + FillFields(root, component, path + ".", group, shared, localized, references, locale); + into[field.Name] = group.Values.All(value => value is null or JsonArray { Count: 0 }) ? null : group; + continue; + } + + var leaf = stored[path]; + if (leaf.Kind == FieldStorageKind.Reference) + { + var ids = references[(path, leaf.Localized ? locale : "")].Select(reference => reference.TargetId).ToArray(); + into[field.Name] = field.Multiple ? ids : ids.Length > 0 ? ids[0] : null; + continue; + } + + var row = leaf.Localized ? localized : shared; + object? value = null; + row?.TryGetValue(leaf.Column, out value); + into[field.Name] = FromColumn(leaf, value); + } + } + + private static object? FromColumn(StoredField stored, object? value) + { + if (stored.Kind != FieldStorageKind.Json || value is not string json) + { + return value; + } + + return stored.Field.FieldType == FieldTypeNames.RichText ? new RichText(json) : JsonNode.Parse(json); + } + + public void AddDocument(Document row) => context.Documents.Add(row); + + // The compare and swap that makes one of two concurrent writers lose. The filter on Document keeps trash out. + public async Task AdvanceVersionAsync(Guid id, string locale, int expected, EntryStatus status, DateTimeOffset at, Guid by, CancellationToken cancellationToken) + { + var next = expected + 1; + var affected = await context.Documents + .Where(document => document.DocumentId == id && document.Locale == locale && document.DraftVersion == expected) + .ExecuteUpdateAsync( + set => set + .SetProperty(document => document.DraftVersion, next) + .SetProperty(document => document.Status, status) + .SetProperty(document => document.UpdatedAt, at) + .SetProperty(document => document.UpdatedBy, by), + cancellationToken); + + return affected == 1; + } + + // The draft of the shared row changed under the other locales, so their published state no longer equals it. + public Task MarkOtherLocalesModifiedAsync(Guid id, string locale, CancellationToken cancellationToken) => + context.Documents + .Where(document => document.DocumentId == id && document.Locale != locale && document.Status == EntryStatus.Published) + .ExecuteUpdateAsync(set => set.SetProperty(document => document.Status, EntryStatus.Modified), cancellationToken); + + public Task SetTrashedAsync(Guid id, DateTimeOffset? at, Guid? by, CancellationToken cancellationToken) => + context.Documents.IgnoreQueryFilters() + .Where(document => document.DocumentId == id) + .ExecuteUpdateAsync( + set => set + .SetProperty(document => document.DeletedAt, at) + .SetProperty(document => document.DeletedBy, by), + cancellationToken); + + // Saves the draft of one locale: the shared row, the locale row, the references and the version, in one save. + public async Task WriteDraftAsync(SchemaType type, Document row, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken) + { + var stored = FieldStorage.Of(schema, type).ToList(); + + await WriteRowAsync(StorageNames.Table(type), type, row, null, stored.Where(item => !item.Localized), entry, cancellationToken); + if (type.Localized) + { + await WriteRowAsync(StorageNames.LocaleTable(type), type, row, row.Locale, stored.Where(item => item.Localized), entry, cancellationToken); + } + + await ReplaceReferencesAsync(type, row, stored, entry, cancellationToken); + + context.Versions.Add(new ContentVersion + { + DocumentId = row.DocumentId, + Locale = row.Locale, + Number = row.DraftVersion, + Snapshot = EntryJson.Text(EntryJson.Fields(type, entry)), + StatusAfter = row.Status, + AuthorId = row.UpdatedBy, + Origin = origin, + ClientId = clientId, + CreatedAt = row.UpdatedAt, + }); + + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + } + + private async Task WriteRowAsync(string table, SchemaType type, Document document, string? locale, IEnumerable fields, ContentDocument entry, CancellationToken cancellationToken) + { + var row = await RowAsync(table, document.DocumentId, ContentState.Draft, locale, tracked: true, cancellationToken); + var isNew = row is null; + row ??= new Dictionary + { + [StorageNames.DocumentId] = document.DocumentId, + [StorageNames.State] = ContentState.Draft, + }; + + if (isNew && document.SiteId is { } site) + { + row[StorageNames.SiteId] = site; + } + + if (isNew && locale is not null) + { + row[StorageNames.Locale] = locale; + } + + foreach (var stored in fields.Where(item => item.Kind != FieldStorageKind.Reference)) + { + row[stored.Column] = ToColumn(stored, EntryPaths.ValueAt(entry, stored, type))!; + } + + if (isNew) + { + context.Rows(table).Add(row); + } + } + + private Task?> RowAsync(string table, Guid id, short state, string? locale, bool tracked, CancellationToken cancellationToken) + { + IQueryable> rows = tracked ? context.Rows(table) : context.Rows(table).AsNoTracking(); + rows = rows.Where(row => EF.Property(row, StorageNames.DocumentId) == id && EF.Property(row, StorageNames.State) == state); + if (locale is not null) + { + rows = rows.Where(row => EF.Property(row, StorageNames.Locale) == locale); + } + + return rows.FirstOrDefaultAsync(cancellationToken); + } + + private static object? ToColumn(StoredField stored, object? value) + { + if (value is null) + { + return stored.Required ? Placeholder(stored) : null; + } + + if (stored.Kind == FieldStorageKind.Json) + { + return value is RichText document ? document.Json : EntryJson.Text(EntryJson.ToNode(value)!); + } + + var target = Nullable.GetUnderlyingType(stored.ClrType!) ?? stored.ClrType!; + if (target.IsInstanceOfType(value)) + { + return value; + } + + return target == typeof(string) + ? Convert.ToString(value, CultureInfo.InvariantCulture) + : Convert.ChangeType(value, target, CultureInfo.InvariantCulture); + } + + private static string Placeholder(StoredField stored) + { + if (stored.Kind != FieldStorageKind.Json) + { + return ""; + } + + return stored.Field.FieldType == FieldTypeNames.RichText ? RichText.Empty.Json : stored.DefaultValue ?? "[]"; + } + + // A reference inside JSON or rich text has no output of its own: it is stored under the field that holds it, which + // the query engine does not read for a relation, so that "what references X" and integrity on publish see it too. + private async Task ReplaceReferencesAsync(SchemaType type, Document row, List stored, ContentDocument entry, CancellationToken cancellationToken) + { + var typeName = type.Name; + var id = row.DocumentId; + var locale = row.Locale; + await context.References + .Where(reference => reference.SourceType == typeName + && reference.SourceDocumentId == id + && reference.State == ContentState.Draft + && (reference.Locale == "" || reference.Locale == locale)) + .ExecuteDeleteAsync(cancellationToken); + + foreach (var item in stored) + { + var value = EntryPaths.ValueAt(entry, item, type); + List<(string Target, Guid Id)> targets = []; + Collect(item.Field, value, targets); + + var position = 0; + foreach (var (target, targetId) in targets) + { + context.References.Add(new Reference + { + SourceType = typeName, + SourceDocumentId = id, + State = ContentState.Draft, + Locale = item.Localized ? locale : "", + Field = QueryPaths.ApiPath(type, item), + Position = position++, + TargetType = target, + TargetId = targetId, + }); + } + } + } + + private void Collect(SchemaField field, object? value, List<(string Target, Guid Id)> into) + { + if (schema.FieldTypes.Find(field.FieldType)!.StorageKind(field) == FieldStorageKind.Reference) + { + CollectReference(field, value, into); + } + else if (field.FieldType == FieldTypeNames.RichText) + { + CollectRichText(value, into); + } + else if (value is JsonNode node) + { + CollectInside(field, node, into); + } + } + + private static void CollectReference(SchemaField field, object? value, List<(string Target, Guid Id)> into) + { + var target = EntryPaths.ReferenceTarget(field); + switch (value) + { + case Guid id: + into.Add((target, id)); + break; + case Guid[] ids: + into.AddRange(ids.Select(id => (target, id))); + break; + case JsonArray items: + foreach (var item in items) + { + CollectReference(field, item, into); + } + + break; + case JsonNode node when EntryCanonicalizer.TryReadId(RawValue.Unwrap(node), out var id): + into.Add((target, id)); + break; + } + } + + private static void CollectRichText(object? value, List<(string Target, Guid Id)> into) + { + var document = value switch + { + RichText text => text, + JsonObject node => new RichText(EntryJson.Text(node)), + _ => null, + }; + + if (document is null) + { + return; + } + + var found = RichTextReferenceExtractor.Extract(document); + into.AddRange(found.Entries.Select(entry => (entry.Type, entry.Id))); + into.AddRange(found.Media.Select(media => (MediaTarget, media.Id))); + } + + private void CollectInside(SchemaField field, JsonNode node, List<(string Target, Guid Id)> into) + { + foreach (var item in node is JsonArray items ? items.OfType() : node is JsonObject single ? [single] : []) + { + SchemaType? owner = field.FieldType switch + { + FieldTypeNames.Component => schema.FindComponent((string)field.Settings[FieldSettings.Component]), + FieldTypeNames.Blocks when item["type"] is JsonValue name && name.TryGetValue(out var block) => schema.FindBlock(block), + _ => null, + }; + + foreach (var inner in owner?.Fields ?? []) + { + if (item[inner.Name] is { } value) + { + Collect(inner, value, into); + } + } + } + } + + // Against both states, because a draft that takes a published value would only fail at publish. Against the trash too, + // because the unique index of the published state knows nothing of it, and a restore would bring the value back. + // An empty value is what an unfinished draft holds, and is not a value to clash on. + public async Task CheckUniqueAsync(SchemaType type, ContentDocument entry, Guid id, SiteId site, string locale, ValidationContext result, CancellationToken cancellationToken) + { + foreach (var stored in FieldStorage.Of(schema, type).Where(item => item.Field.Unique && item.Kind == FieldStorageKind.Column)) + { + var value = ToColumn(stored, EntryPaths.ValueAt(entry, stored, type)); + if (value is null or "") + { + continue; + } + + var table = stored.Localized ? StorageNames.LocaleTable(type) : StorageNames.Table(type); + var row = Expression.Parameter(typeof(Dictionary), "row"); + Expression taken = Expression.AndAlso( + Expression.Equal(QueryExpressions.Column(row, stored.Column, stored.ClrType!), QueryExpressions.Bind(value, stored.ClrType!)), + Expression.NotEqual(QueryExpressions.Column(row, StorageNames.DocumentId, typeof(Guid)), QueryExpressions.Bind(id))); + + if (type.Scope == ContentScope.Site) + { + taken = Expression.AndAlso(taken, Expression.Equal(QueryExpressions.Column(row, StorageNames.SiteId, typeof(Guid)), QueryExpressions.Bind(site.Value))); + } + + if (stored.Localized) + { + taken = Expression.AndAlso(taken, Expression.Equal(QueryExpressions.Column(row, StorageNames.Locale, typeof(string)), QueryExpressions.Bind(locale))); + } + + var predicate = Expression.Lambda, bool>>(taken, row); + if (await context.Rows(table).AsNoTracking().AnyAsync(predicate, cancellationToken)) + { + var path = QueryPaths.ApiPath(type, stored); + result.AddError(path, ValidationErrorCodes.NotUnique, $"Another entry already has this value of {path}."); + } + } + } +} diff --git a/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs index 5f352f08c..22a789803 100644 --- a/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs +++ b/src/FluentCMS.Core/Content/Hooks/HookPipeline.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Reflection; using FluentCMS.Content; +using FluentCMS.Core.Content.Query; using FluentCMS.Hooks; using FluentCMS.Schema; @@ -98,9 +99,13 @@ private sealed class Session( IHookEntryMapper? mapper) { private object? _previousEntry; - private IReadOnlyList _changedPaths = ContentChanges.Between(previous, entry); + private IReadOnlyList _changedPaths = ChangedFields(previous, entry); - public void RefreshChangedPaths() => _changedPaths = ContentChanges.Between(previous, entry); + public void RefreshChangedPaths() => _changedPaths = ChangedFields(previous, entry); + + // A save always moves the version and the update time, which say nothing about what changed. + private static List ChangedFields(ContentDocument? previous, ContentDocument entry) => + [.. ContentChanges.Between(previous, entry).Where(path => SystemField.Find(path) is null)]; public HookContext CreateContext(out object? mapped) { diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs index 73088d6b2..17021dc31 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs @@ -28,11 +28,16 @@ internal sealed record CompiledQuery( int? PageNumber, bool WithTotal); - public async Task ExecuteAsync(string typeName, ContentQuery query, CancellationToken cancellationToken = default) + public Task ExecuteAsync(string typeName, ContentQuery query, CancellationToken cancellationToken = default) => + ExecuteAsync(typeName, query, everySystemField: false, cancellationToken); + + // everySystemField is for a content class, which has a property for each of them; a document leaves out the two that + // are not in the response shape. Field permissions still decide. + public async Task ExecuteAsync(string typeName, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - var compiled = Compile(await access.ForQueryAsync(query.Site, query.Locale ?? "", cancellationToken), typeName, query); + var compiled = Compile(await access.ForQueryAsync(query.Site, query.Locale ?? "", cancellationToken), typeName, query, everySystemField); long? total = compiled.WithTotal ? await compiled.Filtered.LongCountAsync(cancellationToken) : null; var rows = await compiled.Rows.ToListAsync(cancellationToken); @@ -55,7 +60,7 @@ public async Task ExecuteAsync(string typeName, ContentQuery // Separate from running it, so that a test can read the SQL. internal CompiledQuery Compile(string typeName, ContentQuery query) => Compile(access, typeName, query); - private CompiledQuery Compile(IContentQueryAccess access, string typeName, ContentQuery query) + private CompiledQuery Compile(IContentQueryAccess access, string typeName, ContentQuery query, bool everySystemField = false) { ArgumentNullException.ThrowIfNull(query); @@ -85,7 +90,8 @@ private CompiledQuery Compile(IContentQueryAccess access, string typeName, Conte rows = rows.Where(Lambda(filter, row)); } - var plan = new EntryPlan(scope, type, Selection(scope, type, query.Select), Population(scope, type, query), level: 0, isRoot: true, row); + var select = everySystemField && query.Select.Count == 0 ? SelectNode.EverythingWithSystem : Selection(scope, type, query.Select); + var plan = new EntryPlan(scope, type, select, Population(scope, type, query), level: 0, isRoot: true, row); var keys = SortKeys.Build(scope, type, query.Sort, row); var keyColumns = keys.Select(key => plan.AddColumn(key.Value)).ToArray(); var (skip, size, number, withTotal) = Page(scope, query); diff --git a/src/FluentCMS.Core/Content/Query/EntryPlan.cs b/src/FluentCMS.Core/Content/Query/EntryPlan.cs index ef72df3ff..825eaa29a 100644 --- a/src/FluentCMS.Core/Content/Query/EntryPlan.cs +++ b/src/FluentCMS.Core/Content/Query/EntryPlan.cs @@ -12,8 +12,12 @@ internal sealed class SelectNode { public static readonly SelectNode Everything = new() { Whole = true }; + public static readonly SelectNode EverythingWithSystem = new() { Whole = true, EverySystemField = true }; + public bool Whole { get; private set; } + public bool EverySystemField { get; private init; } + public Dictionary Children { get; } = new(StringComparer.Ordinal); public bool Includes(string name) => Whole || Children.ContainsKey(name); @@ -94,7 +98,7 @@ public EntryPlan(QueryScope query, SchemaType type, SelectNode select, PopulateN List nodes = []; foreach (var system in SystemField.Fields) { - var wanted = select.Whole ? isRoot && system.Default : select.Includes(system.Name); + var wanted = select.Whole ? isRoot && (system.Default || select.EverySystemField) : select.Includes(system.Name); if (wanted && system.Name != "id" && query.Access.CanReadField(type, system.Name)) { nodes.Add(new ValueOutput(system.Name, AddColumn(system.ReadFrom(row, query.State)), value => value)); diff --git a/src/FluentCMS.Core/Content/ResponseRichText.cs b/src/FluentCMS.Core/Content/ResponseRichText.cs new file mode 100644 index 000000000..4f3716c33 --- /dev/null +++ b/src/FluentCMS.Core/Content/ResponseRichText.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// Applies ContentQuery.RichTextFormat to what a read returns: rich text becomes the rendered text, at every level and +// in populated entries. Stored JSON is the default and needs nothing. +internal sealed class ResponseRichText(SchemaModel schema, IRichTextRenderer renderer) +{ + private const string Json = "json"; + private const string Discriminator = "type"; + + public static bool IsJson(string? format) => format is null || string.Equals(format, Json, StringComparison.OrdinalIgnoreCase); + + public void ThrowIfUnknown(string? format) + { + if (!IsJson(format) && !renderer.Formats.Contains(format, StringComparer.OrdinalIgnoreCase)) + { + throw new ValidationException([new ValidationError("richText", QueryErrorCodes.InvalidParameter, $"'{format}' is not a rich text format.")]); + } + } + + public void Apply(SchemaType type, ContentDocument entry, string format) + { + foreach (var field in type.Fields) + { + if (entry.TryGetValue(field.Name, out var value) && value is not null) + { + entry[field.Name] = Render(field, value, format); + } + } + } + + private object? Render(SchemaField field, object value, string format) + { + switch (value) + { + case RichText document: + return renderer.Render(document, format); + case ContentDocument inner when field.Relation is { } relation && schema.FindContentType(relation.Target) is { } target: + Apply(target, inner, format); + return inner; + case IEnumerable many when field.Relation is { } relation && schema.FindContentType(relation.Target) is { } target: + foreach (var item in many) + { + Apply(target, item, format); + } + + return value; + case ContentDocument group when field.FieldType == FieldTypeNames.Component && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component: + Apply(component, group, format); + return group; + case JsonNode node: + RenderInside(field, node, format); + return node; + default: + return value; + } + } + + private void RenderInside(SchemaField field, JsonNode node, string format) + { + foreach (var item in node is JsonArray items ? items.OfType() : node is JsonObject single ? [single] : []) + { + SchemaType? owner = field.FieldType switch + { + FieldTypeNames.Component => schema.FindComponent((string)field.Settings[FieldSettings.Component]), + FieldTypeNames.Blocks when item[Discriminator] is JsonValue name && name.TryGetValue(out var block) => schema.FindBlock(block), + _ => null, + }; + + foreach (var inner in owner?.Fields ?? []) + { + switch (item[inner.Name]) + { + case JsonObject document when inner.FieldType == FieldTypeNames.RichText: + item[inner.Name] = renderer.Render(new RichText(EntryJson.Text(document)), format); + break; + case { } value: + RenderInside(inner, value, format); + break; + } + } + } + } +} diff --git a/src/FluentCMS.Core/Content/TypedContentService.cs b/src/FluentCMS.Core/Content/TypedContentService.cs new file mode 100644 index 000000000..e52a7ab1b --- /dev/null +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -0,0 +1,79 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Localization; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// One code path: every operation is the document service's, with the class mapped on the way in and out. A class has a +// property for every system field, so it reads the two a document leaves out, as far as the principal may read them. +internal sealed class TypedContentService(ContentService documents, SchemaModel schema, ContentEntryMapper mapper) : IContentService + where T : ContentItem +{ + private readonly string _type = schema.FindContentType(typeof(T))?.Name + ?? throw new InvalidOperationException($"{typeof(T)} is not a content type of the schema."); + + public async Task GetAsync(Guid id, GetOptions? options = null, CancellationToken cancellationToken = default) + { + ThrowIfUnfit(options?.Locale, options?.RichTextFormat); + + return await documents.GetAsync(_type, id, options, everySystemField: true, cancellationToken) is { } entry ? Map(entry) : null; + } + + public async Task> QueryAsync(ContentQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ThrowIfUnfit(query.Locale, query.RichTextFormat); + + var page = await documents.QueryAsync(_type, query, everySystemField: true, cancellationToken); + return new PagedResult(page.Items.Select(Map).ToList(), page.Total, page.Page, page.PageSize, page.NextCursor); + } + + public async Task CreateAsync(T entry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + return await ReadBackAsync(await documents.CreateAsync(_type, mapper.ToDocument(entry), cancellationToken), cancellationToken); + } + + public async Task UpdateAsync(Guid id, T entry, int expectedVersion, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + return await ReadBackAsync(await documents.UpdateAsync(_type, id, mapper.ToDocument(entry), expectedVersion, cancellationToken), cancellationToken); + } + + public async Task PatchAsync(Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default) => + await ReadBackAsync(await documents.PatchAsync(_type, id, patch, expectedVersion, cancellationToken), cancellationToken); + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) => + documents.DeleteAsync(_type, id, cancellationToken); + + public async Task RestoreAsync(Guid id, CancellationToken cancellationToken = default) => + await ReadBackAsync(await documents.RestoreAsync(_type, id, cancellationToken), cancellationToken); + + public Task CountAsync(ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default) => + documents.CountAsync(_type, filter, status, cancellationToken); + + private T Map(ContentDocument entry) => (T)mapper.ToEntry(typeof(T), entry); + + // What a write returned, which is all a principal without the read gets, with the system fields a class also holds. + private async Task ReadBackAsync(ContentDocument written, CancellationToken cancellationToken) + { + var options = new GetOptions { Status = QueryStatus.Draft, Fallback = LocaleFallback.None }; + return Map(written.ContainsKey("status") && await documents.GetAsync(_type, (Guid)written["id"]!, options, everySystemField: true, cancellationToken) is { } entry ? entry : written); + } + + private static void ThrowIfUnfit(string? locale, string? richTextFormat) + { + if (string.Equals(locale, ILocaleResolver.All, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException([new ValidationError("locale", QueryErrorCodes.InvalidParameter, "A content class holds one locale; read every locale as documents.")]); + } + + if (!ResponseRichText.IsJson(richTextFormat)) + { + throw new ValidationException([new ValidationError("richText", QueryErrorCodes.InvalidParameter, "A content class holds rich text as JSON; read rendered text as documents.")]); + } + } +} diff --git a/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs index 2be217666..51424db35 100644 --- a/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs +++ b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs @@ -16,7 +16,8 @@ public async ValueTask ValidateAsync( object? entry, OperationContext operation, ValidationMode mode, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? soundEntryCheck = null) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(values); @@ -37,8 +38,13 @@ public async ValueTask ValidateAsync( await RunEntryValidatorsAsync(entry, context, cancellationToken); } - // Uniqueness (a database query, #2693) and reference integrity on publish (#2695) slot in here, after the + // Uniqueness (a database query) and reference integrity on publish (#2695) come through here, after the // validators that need no I/O, so they only run for an entry that is otherwise sound. + if (context.Errors.Count == 0 && soundEntryCheck is not null) + { + await soundEntryCheck(context.ForEntry(), cancellationToken); + } + return context.Errors.Count == 0 ? ValidationResult.Success : new ValidationResult(context.Errors); } diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs index a6a993f0c..adb581511 100644 --- a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs @@ -3,8 +3,11 @@ using FluentCMS.Content; using FluentCMS.Core.Authorization; using FluentCMS.Core.Content; +using FluentCMS.Core.Content.Hooks; using FluentCMS.Core.Content.Query; +using FluentCMS.Core.Content.Validation; using FluentCMS.Core.Localization; +using FluentCMS.Core.ProseMirror; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Core.Storage; @@ -81,6 +84,19 @@ public static IServiceCollection AddFluentCms(this IServiceCollection services, services.AddScoped(); services.AddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.TryAddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddScoped(typeof(IContentService<>), typeof(TypedContentService<>)); + return services; } } diff --git a/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs b/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs index 80603f6e4..69eb772ac 100644 --- a/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs +++ b/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs @@ -84,6 +84,56 @@ public async Task ApplyAsync(string typeName, ContentQuery query, IReadOnlyList< } } + // An entry by id that has no row in the requested locale: read from the first locale of the chain that has one, under + // the requested locale, with what that locale and the ones after it serve named under "fallbacks". + public async Task ReadFromChainAsync(string typeName, ContentQuery query, Guid id, FallbackChain chain, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(chain); + + var fallbacks = chain.Fallbacks.ToList(); + for (var index = 0; index < fallbacks.Count; index++) + { + var read = await ReadAsync(typeName, query, fallbacks[index], [id], [.. query.Select], cancellationToken); + if (read.Count == 0) + { + continue; + } + + var entry = read[0]; + var served = new ContentDocument(); + if (schema.FindContentType(typeName) is { } type) + { + foreach (var name in type.Fields.Where(field => field.Localizable && !field.Hidden).Select(field => field.Name)) + { + if (entry.TryGetValue(name, out var value) && !IsMissing(value)) + { + served[name] = fallbacks[index]; + } + } + } + + await ApplyAsync(typeName, query, read, new FallbackChain(fallbacks.Skip(index)), cancellationToken); + if (entry.TryGetValue(FallbacksKey, out var further) && further is ContentDocument map) + { + foreach (var (name, locale) in map) + { + served[name] = locale; + } + } + + entry["locale"] = chain.Requested; + if (served.Count > 0) + { + entry[FallbacksKey] = served; + } + + return entry; + } + + return null; + } + // The locale=all read of entries already found: the stored values of each, by locale code, in the order the site // lists its locales. A locale the principal cannot read, or in which an entry has no row, is not in the map. A // type that is not localized has one document, under "". diff --git a/src/FluentCMS.Sdk/Content/ContentAccessDeniedException.cs b/src/FluentCMS.Sdk/Content/ContentAccessDeniedException.cs new file mode 100644 index 000000000..6cefc5334 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ContentAccessDeniedException.cs @@ -0,0 +1,21 @@ +namespace FluentCMS.Content; + +/// Thrown when the principal may not perform an operation on a content type. The HTTP layer maps it to a 401 or a 403. +public sealed class ContentAccessDeniedException : Exception +{ + /// Creates the exception. + /// The name of the content type. + /// The name of the operation, such as update. + public ContentAccessDeniedException(string type, string operation) + : base($"The operation {operation} on {type} is not allowed.") + { + Type = type; + Operation = operation; + } + + /// The name of the content type. + public string Type { get; } + + /// The name of the operation. + public string Operation { get; } +} diff --git a/src/FluentCMS.Sdk/Content/EntryNotFoundException.cs b/src/FluentCMS.Sdk/Content/EntryNotFoundException.cs new file mode 100644 index 000000000..c530c2d6e --- /dev/null +++ b/src/FluentCMS.Sdk/Content/EntryNotFoundException.cs @@ -0,0 +1,21 @@ +namespace FluentCMS.Content; + +/// Thrown when an entry does not exist, or when the principal may not reach it, so that a client cannot tell the two apart. The HTTP layer maps it to a 404. +public sealed class EntryNotFoundException : Exception +{ + /// Creates the exception. + /// The name of the content type. + /// The id of the entry. + public EntryNotFoundException(string type, Guid id) + : base($"There is no {type} {id}.") + { + Type = type; + Id = id; + } + + /// The name of the content type. + public string Type { get; } + + /// The id of the entry. + public Guid Id { get; } +} diff --git a/src/FluentCMS.Sdk/Content/GetOptions.cs b/src/FluentCMS.Sdk/Content/GetOptions.cs new file mode 100644 index 000000000..a563093e9 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/GetOptions.cs @@ -0,0 +1,26 @@ +namespace FluentCMS.Content; + +/// What reads. +public sealed record GetOptions +{ + /// Which state of the entry to read. + public QueryStatus Status { get; init; } = QueryStatus.Published; + + /// The locale to read as a BCP 47 tag, all for every locale, or for the locale of the operation. + public string? Locale { get; init; } + + /// Whether a missing localized value falls back to another locale. + public LocaleFallback Fallback { get; init; } + + /// The dotted paths of the fields to return, or empty for every field. id is always returned. + public IReadOnlyList Select { get; init; } = []; + + /// The dotted paths of the relations to inline, or * for every relation. + public IReadOnlyList Populate { get; init; } = []; + + /// How many levels of relations to inline for each entry of , or for the default of one. + public int? PopulateDepth { get; init; } + + /// The format to render rich text in: json, or a name from . means JSON. + public string? RichTextFormat { get; init; } +} diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs new file mode 100644 index 000000000..0f33131e9 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -0,0 +1,173 @@ +using FluentCMS.Schema; + +namespace FluentCMS.Content; + +/// +/// The only path to content for HTTP, GraphQL, MCP and host code. Every operation runs in the ambient +/// , which gives the principal, site and locale, and applies permissions, field limits, +/// hooks, validation and versions. This form works on and names the type on each call. +/// +public interface IContentService +{ + /// Reads one entry. + /// The name of the content type. + /// The id of the entry. + /// What to read, or for the published entry in the locale of the operation. + /// A token to cancel the work. + /// The entry, or when it does not exist or the principal may not read it. + /// The options are not valid, or the principal may not read the type or its drafts. + Task GetAsync(string type, Guid id, GetOptions? options = null, CancellationToken cancellationToken = default); + + /// Reads a page of entries. + /// The name of the content type. + /// What to read. A missing site or locale is that of the operation. + /// A token to cancel the work. + /// The page. + /// The query is not valid, or the principal may not read the type or its drafts. + Task> QueryAsync(string type, ContentQuery query, CancellationToken cancellationToken = default); + + /// Creates the draft of a new entry in the site and locale of the operation. + /// The name of the content type. + /// The values. System fields are set by the engine. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not create entries of the type. + /// A value is refused or not valid. + Task CreateAsync(string type, ContentDocument entry, CancellationToken cancellationToken = default); + + /// + /// Replaces the draft in the locale of the operation. A field left out is cleared, unless the principal may not write + /// or read it, in which case it keeps its value. With an expected version of 0, this adds a locale the entry does not have yet. + /// + /// The name of the content type. + /// The id of the entry. + /// The values. System fields are set by the engine. + /// The version the change was based on. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not update entries of the type. + /// The entry does not exist or the principal may not reach it. + /// The stored version is not the expected one. + /// A value is refused or not valid. + Task UpdateAsync(string type, Guid id, ContentDocument entry, int expectedVersion, CancellationToken cancellationToken = default); + + /// Changes the draft in the locale of the operation with a JSON merge patch: a null removes a field, and an object merges into a single component or a JSON field. + /// The name of the content type. + /// The id of the entry. + /// The changes. + /// The version the change was based on. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not update entries of the type. + /// The entry does not exist or the principal may not reach it. + /// The stored version is not the expected one. + /// A value is refused or not valid. + Task PatchAsync(string type, Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default); + + /// Moves an entry, in all its locales, to the trash. + /// The name of the content type. + /// The id of the entry. + /// A token to cancel the work. + /// A task that completes when the entry is in the trash. + /// The principal may not delete the entry in every one of its locales. + /// The entry does not exist, is in the trash already, or the principal may not reach it. + Task DeleteAsync(string type, Guid id, CancellationToken cancellationToken = default); + + /// Brings an entry, in all its locales, back from the trash. + /// The name of the content type. + /// The id of the entry. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not restore the entry in every one of its locales. + /// The entry is not in the trash, or the principal may not reach it. + Task RestoreAsync(string type, Guid id, CancellationToken cancellationToken = default); + + /// Counts the entries that match a filter in the site and locale of the operation. + /// The name of the content type. + /// The filter, or for every entry. + /// Which state of the entries to count. + /// A token to cancel the work. + /// The number of entries the principal may read. + /// The filter is not valid, or the principal may not read the type or its drafts. + Task CountAsync(string type, ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default); +} + +/// +/// for one content class. It maps the class to and from and +/// goes through the same operations. Its system fields are set by the engine, and what they hold on a write is ignored. +/// +/// The content class. +public interface IContentService + where T : ContentItem +{ + /// Reads one entry. + /// The id of the entry. + /// What to read, or for the published entry in the locale of the operation. A class cannot hold every locale or rendered rich text, so these are refused. + /// A token to cancel the work. + /// The entry, or when it does not exist or the principal may not read it. + /// The options are not valid, or the principal may not read the type or its drafts. + Task GetAsync(Guid id, GetOptions? options = null, CancellationToken cancellationToken = default); + + /// Reads a page of entries. + /// What to read. A missing site or locale is that of the operation. A class cannot hold every locale or rendered rich text, so these are refused. + /// A token to cancel the work. + /// The page. + /// The query is not valid, or the principal may not read the type or its drafts. + Task> QueryAsync(ContentQuery query, CancellationToken cancellationToken = default); + + /// Creates the draft of a new entry in the site and locale of the operation. + /// The values. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not create entries of the type. + /// A value is refused or not valid. + Task CreateAsync(T entry, CancellationToken cancellationToken = default); + + /// Replaces the draft in the locale of the operation. A property with no value clears its field, unless the principal may not write or read it. + /// The id of the entry. + /// The values. + /// The version the change was based on. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not update entries of the type. + /// The entry does not exist or the principal may not reach it. + /// The stored version is not the expected one. + /// A value is refused or not valid. + Task UpdateAsync(Guid id, T entry, int expectedVersion, CancellationToken cancellationToken = default); + + /// Changes the draft in the locale of the operation with a JSON merge patch. A patch names fields, which a class cannot do, so it is a document. + /// The id of the entry. + /// The changes. + /// The version the change was based on. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not update entries of the type. + /// The entry does not exist or the principal may not reach it. + /// The stored version is not the expected one. + /// A value is refused or not valid. + Task PatchAsync(Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default); + + /// Moves an entry, in all its locales, to the trash. + /// The id of the entry. + /// A token to cancel the work. + /// A task that completes when the entry is in the trash. + /// The principal may not delete the entry in every one of its locales. + /// The entry does not exist, is in the trash already, or the principal may not reach it. + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// Brings an entry, in all its locales, back from the trash. + /// The id of the entry. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not restore the entry in every one of its locales. + /// The entry is not in the trash, or the principal may not reach it. + Task RestoreAsync(Guid id, CancellationToken cancellationToken = default); + + /// Counts the entries that match a filter in the site and locale of the operation. + /// The filter, or for every entry. + /// Which state of the entries to count. + /// A token to cancel the work. + /// The number of entries the principal may read. + /// The filter is not valid, or the principal may not read the type or its drafts. + Task CountAsync(ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Sdk/Content/PagedResult.cs b/src/FluentCMS.Sdk/Content/PagedResult.cs new file mode 100644 index 000000000..d389a8a26 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/PagedResult.cs @@ -0,0 +1,38 @@ +namespace FluentCMS.Content; + +/// A page of entries. +/// The type of an entry. +public sealed class PagedResult +{ + /// Creates a page. + /// The entries. + /// How many entries match, or when they were not counted. + /// The page number, or for a page read by offset or cursor. + /// The page size. + /// The cursor of the next page, or on the last page. + public PagedResult(IReadOnlyList items, long? total, int? page, int pageSize, string? nextCursor) + { + ArgumentNullException.ThrowIfNull(items); + + Items = items; + Total = total; + Page = page; + PageSize = pageSize; + NextCursor = nextCursor; + } + + /// The entries. + public IReadOnlyList Items { get; } + + /// How many entries match, or when they were not counted. + public long? Total { get; } + + /// The page number, or for a page read by offset or cursor. + public int? Page { get; } + + /// The page size. + public int PageSize { get; } + + /// The cursor of the next page, or on the last page. + public string? NextCursor { get; } +} diff --git a/src/FluentCMS.Sdk/Content/SystemOperation.cs b/src/FluentCMS.Sdk/Content/SystemOperation.cs new file mode 100644 index 000000000..33d45c7b3 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/SystemOperation.cs @@ -0,0 +1,50 @@ +using FluentCMS.Sites; + +namespace FluentCMS.Content; + +/// +/// Runs code as , for jobs, seeding and the command line tool. A request never gets here: +/// only an origin that is not a client may use it, and the content service refuses the system principal from any other. +/// +public static class SystemOperation +{ + /// Whether an origin may run as the system principal. + /// The origin. + /// for , and . + public static bool Allows(Origin origin) => origin is Origin.Job or Origin.Cli or Origin.Seed; + + /// Sets the ambient operation to the system principal until the result is disposed, which puts back the operation before it. + /// The ambient operation. + /// Where the work comes from: , or . + /// The site to work in. + /// The locale to work in, or empty for the default locale of the site. + /// An id that ties the work together, or for a new one. + /// What ends the operation. + /// is that of a client. + public static IDisposable Begin(IOperationContextAccessor accessor, Origin origin, SiteId site, string locale = "", string? correlationId = null) + { + ArgumentNullException.ThrowIfNull(accessor); + ArgumentNullException.ThrowIfNull(locale); + + if (!Allows(origin)) + { + throw new ArgumentOutOfRangeException(nameof(origin), origin, "Only a job, the command line tool or seeding runs as the system principal."); + } + + var scope = new Scope(accessor, accessor.Current); + accessor.Current = new OperationContext( + Principal.System, + origin, + site, + locale, + null, + correlationId ?? Guid.CreateVersion7().ToString(), + DateTimeOffset.UtcNow); + return scope; + } + + private sealed class Scope(IOperationContextAccessor accessor, OperationContext? previous) : IDisposable + { + public void Dispose() => accessor.Current = previous; + } +} diff --git a/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs b/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs index 422702121..cd0fcf57f 100644 --- a/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs +++ b/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs @@ -32,4 +32,7 @@ public static class ValidationErrorCodes /// The field's RequiredWhen condition holds and the field has no value. public const string RequiredByCondition = "FCMS2110"; + + /// Another entry in the same site and locale holds the value of a unique field, as a draft or published. + public const string NotUnique = "FCMS2111"; } diff --git a/src/FluentCMS.Sdk/Content/VersionConflictException.cs b/src/FluentCMS.Sdk/Content/VersionConflictException.cs new file mode 100644 index 000000000..4be26b91c --- /dev/null +++ b/src/FluentCMS.Sdk/Content/VersionConflictException.cs @@ -0,0 +1,26 @@ +namespace FluentCMS.Content; + +/// Thrown when a write was based on a version that is no longer the stored one. The HTTP layer maps it to a 412 with the current entry in the problem details. +public sealed class VersionConflictException : Exception +{ + /// Creates the exception. + /// The version the write was based on. + /// The stored version. + /// The stored entry as the principal may read it, if it may. + public VersionConflictException(int expectedVersion, int currentVersion, ContentDocument? current) + : base($"The write was based on version {expectedVersion}, but the entry is at version {currentVersion}.") + { + ExpectedVersion = expectedVersion; + CurrentVersion = currentVersion; + Current = current; + } + + /// The version the write was based on. + public int ExpectedVersion { get; } + + /// The stored version. + public int CurrentVersion { get; } + + /// The stored entry without what the principal may not read, or when it may not read the entry. + public ContentDocument? Current { get; } +} diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index e61804ef0..217b8ede3 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -11,6 +11,10 @@ FluentCMS.Content.And.Deconstruct(out System.Collections.Generic.IReadOnlyList bool FluentCMS.Content.And.Filters.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Content.And.Filters.init -> void +FluentCMS.Content.ContentAccessDeniedException +FluentCMS.Content.ContentAccessDeniedException.ContentAccessDeniedException(string! type, string! operation) -> void +FluentCMS.Content.ContentAccessDeniedException.Operation.get -> string! +FluentCMS.Content.ContentAccessDeniedException.Type.get -> string! FluentCMS.Content.ContentDocument FluentCMS.Content.ContentDocument.Add(string! name, object? value) -> void FluentCMS.Content.ContentDocument.Clear() -> void @@ -57,6 +61,10 @@ FluentCMS.Content.ContentQuery.Sort.get -> System.Collections.Generic.IReadOnlyL FluentCMS.Content.ContentQuery.Sort.init -> void FluentCMS.Content.ContentQuery.Status.get -> FluentCMS.Content.QueryStatus? FluentCMS.Content.ContentQuery.Status.init -> void +FluentCMS.Content.EntryNotFoundException +FluentCMS.Content.EntryNotFoundException.EntryNotFoundException(string! type, System.Guid id) -> void +FluentCMS.Content.EntryNotFoundException.Id.get -> System.Guid +FluentCMS.Content.EntryNotFoundException.Type.get -> string! FluentCMS.Content.FieldFilter FluentCMS.Content.FieldFilter.Deconstruct(out string! Path, out FluentCMS.Content.FilterOperator Operator, out object? Value) -> void FluentCMS.Content.FieldFilter.Equals(FluentCMS.Content.FieldFilter? other) -> bool @@ -82,6 +90,42 @@ FluentCMS.Content.FilterOperator.LessThanOrEqual = 5 -> FluentCMS.Content.Filter FluentCMS.Content.FilterOperator.NotEqual = 1 -> FluentCMS.Content.FilterOperator FluentCMS.Content.FilterOperator.NotIn = 3 -> FluentCMS.Content.FilterOperator FluentCMS.Content.FilterOperator.StartsWith = 10 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.GetOptions +FluentCMS.Content.GetOptions.$() -> FluentCMS.Content.GetOptions! +FluentCMS.Content.GetOptions.Equals(FluentCMS.Content.GetOptions? other) -> bool +FluentCMS.Content.GetOptions.Fallback.get -> FluentCMS.Content.LocaleFallback +FluentCMS.Content.GetOptions.Fallback.init -> void +FluentCMS.Content.GetOptions.GetOptions() -> void +FluentCMS.Content.GetOptions.Locale.get -> string? +FluentCMS.Content.GetOptions.Locale.init -> void +FluentCMS.Content.GetOptions.Populate.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.GetOptions.Populate.init -> void +FluentCMS.Content.GetOptions.PopulateDepth.get -> int? +FluentCMS.Content.GetOptions.PopulateDepth.init -> void +FluentCMS.Content.GetOptions.RichTextFormat.get -> string? +FluentCMS.Content.GetOptions.RichTextFormat.init -> void +FluentCMS.Content.GetOptions.Select.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.GetOptions.Select.init -> void +FluentCMS.Content.GetOptions.Status.get -> FluentCMS.Content.QueryStatus +FluentCMS.Content.GetOptions.Status.init -> void +FluentCMS.Content.IContentService +FluentCMS.Content.IContentService.CountAsync(string! type, FluentCMS.Content.ContentFilter? filter = null, FluentCMS.Content.QueryStatus status = FluentCMS.Content.QueryStatus.Published, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.CreateAsync(string! type, FluentCMS.Content.ContentDocument! entry, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.DeleteAsync(string! type, System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.GetAsync(string! type, System.Guid id, FluentCMS.Content.GetOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.PatchAsync(string! type, System.Guid id, FluentCMS.Content.ContentDocument! patch, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.QueryAsync(string! type, FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.RestoreAsync(string! type, System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.UpdateAsync(string! type, System.Guid id, FluentCMS.Content.ContentDocument! entry, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService +FluentCMS.Content.IContentService.CountAsync(FluentCMS.Content.ContentFilter? filter = null, FluentCMS.Content.QueryStatus status = FluentCMS.Content.QueryStatus.Published, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.CreateAsync(T! entry, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.DeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.GetAsync(System.Guid id, FluentCMS.Content.GetOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.PatchAsync(System.Guid id, FluentCMS.Content.ContentDocument! patch, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.QueryAsync(FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.RestoreAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.UpdateAsync(System.Guid id, T! entry, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IEntryValidator FluentCMS.Content.IEntryValidator.ValidateAsync(T entry, FluentCMS.Content.ValidationContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask FluentCMS.Content.IFieldValidator @@ -142,6 +186,13 @@ FluentCMS.Content.PageRequest.Size.get -> int? FluentCMS.Content.PageRequest.Size.init -> void FluentCMS.Content.PageRequest.WithTotal.get -> bool FluentCMS.Content.PageRequest.WithTotal.init -> void +FluentCMS.Content.PagedResult +FluentCMS.Content.PagedResult.Items.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.PagedResult.NextCursor.get -> string? +FluentCMS.Content.PagedResult.Page.get -> int? +FluentCMS.Content.PagedResult.PageSize.get -> int +FluentCMS.Content.PagedResult.PagedResult(System.Collections.Generic.IReadOnlyList! items, long? total, int? page, int pageSize, string? nextCursor) -> void +FluentCMS.Content.PagedResult.Total.get -> long? FluentCMS.Content.Principal FluentCMS.Content.Principal.Claim(string! type) -> string? FluentCMS.Content.Principal.Claims.get -> System.Collections.Generic.IReadOnlyDictionary! @@ -171,6 +222,7 @@ FluentCMS.Content.SortField.Equals(FluentCMS.Content.SortField? other) -> bool FluentCMS.Content.SortField.Path.get -> string! FluentCMS.Content.SortField.Path.init -> void FluentCMS.Content.SortField.SortField(string! Path, bool Descending = false) -> void +FluentCMS.Content.SystemOperation FluentCMS.Content.ValidationContext FluentCMS.Content.ValidationContext.AddError(string! code, string! message) -> void FluentCMS.Content.ValidationContext.AddError(string! path, string! code, string! message) -> void @@ -206,6 +258,11 @@ FluentCMS.Content.ValidationResult FluentCMS.Content.ValidationResult.Errors.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Content.ValidationResult.IsValid.get -> bool FluentCMS.Content.ValidationResult.ValidationResult(System.Collections.Generic.IEnumerable! errors) -> void +FluentCMS.Content.VersionConflictException +FluentCMS.Content.VersionConflictException.Current.get -> FluentCMS.Content.ContentDocument? +FluentCMS.Content.VersionConflictException.CurrentVersion.get -> int +FluentCMS.Content.VersionConflictException.ExpectedVersion.get -> int +FluentCMS.Content.VersionConflictException.VersionConflictException(int expectedVersion, int currentVersion, FluentCMS.Content.ContentDocument? current) -> void FluentCMS.Hooks.HookContext FluentCMS.Hooks.HookContext.Cancel(string! code, string! message) -> void FluentCMS.Hooks.HookContext.CancelCode.get -> string? @@ -661,6 +718,7 @@ const FluentCMS.Content.ValidationErrorCodes.InvalidValue = "FCMS2108" -> string const FluentCMS.Content.ValidationErrorCodes.MaxLength = "FCMS2103" -> string! const FluentCMS.Content.ValidationErrorCodes.MinLength = "FCMS2102" -> string! const FluentCMS.Content.ValidationErrorCodes.NotAnAllowedOption = "FCMS2107" -> string! +const FluentCMS.Content.ValidationErrorCodes.NotUnique = "FCMS2111" -> string! const FluentCMS.Content.ValidationErrorCodes.PatternMismatch = "FCMS2106" -> string! const FluentCMS.Content.ValidationErrorCodes.Required = "FCMS2101" -> string! const FluentCMS.Content.ValidationErrorCodes.RequiredByCondition = "FCMS2110" -> string! @@ -688,6 +746,9 @@ override FluentCMS.Content.FieldFilter.$() -> FluentCMS.Content.FieldFilt override FluentCMS.Content.FieldFilter.Equals(object? obj) -> bool override FluentCMS.Content.FieldFilter.GetHashCode() -> int override FluentCMS.Content.FieldFilter.ToString() -> string! +override FluentCMS.Content.GetOptions.Equals(object? obj) -> bool +override FluentCMS.Content.GetOptions.GetHashCode() -> int +override FluentCMS.Content.GetOptions.ToString() -> string! override FluentCMS.Content.Not.$() -> FluentCMS.Content.Not! override FluentCMS.Content.Not.Equals(object? obj) -> bool override FluentCMS.Content.Not.GetHashCode() -> int @@ -752,6 +813,8 @@ static FluentCMS.Content.ContentQuery.operator !=(FluentCMS.Content.ContentQuery static FluentCMS.Content.ContentQuery.operator ==(FluentCMS.Content.ContentQuery? left, FluentCMS.Content.ContentQuery? right) -> bool static FluentCMS.Content.FieldFilter.operator !=(FluentCMS.Content.FieldFilter? left, FluentCMS.Content.FieldFilter? right) -> bool static FluentCMS.Content.FieldFilter.operator ==(FluentCMS.Content.FieldFilter? left, FluentCMS.Content.FieldFilter? right) -> bool +static FluentCMS.Content.GetOptions.operator !=(FluentCMS.Content.GetOptions? left, FluentCMS.Content.GetOptions? right) -> bool +static FluentCMS.Content.GetOptions.operator ==(FluentCMS.Content.GetOptions? left, FluentCMS.Content.GetOptions? right) -> bool static FluentCMS.Content.Not.operator !=(FluentCMS.Content.Not? left, FluentCMS.Content.Not? right) -> bool static FluentCMS.Content.Not.operator ==(FluentCMS.Content.Not? left, FluentCMS.Content.Not? right) -> bool static FluentCMS.Content.OperationContext.operator !=(FluentCMS.Content.OperationContext? left, FluentCMS.Content.OperationContext? right) -> bool @@ -764,6 +827,8 @@ static FluentCMS.Content.Principal.Anonymous.get -> FluentCMS.Content.Principal! static FluentCMS.Content.Principal.System.get -> FluentCMS.Content.Principal! static FluentCMS.Content.SortField.operator !=(FluentCMS.Content.SortField? left, FluentCMS.Content.SortField? right) -> bool static FluentCMS.Content.SortField.operator ==(FluentCMS.Content.SortField? left, FluentCMS.Content.SortField? right) -> bool +static FluentCMS.Content.SystemOperation.Allows(FluentCMS.Content.Origin origin) -> bool +static FluentCMS.Content.SystemOperation.Begin(FluentCMS.Content.IOperationContextAccessor! accessor, FluentCMS.Content.Origin origin, FluentCMS.Sites.SiteId site, string! locale = "", string? correlationId = null) -> System.IDisposable! static FluentCMS.Content.ValidationError.operator !=(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool static FluentCMS.Content.ValidationError.operator ==(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool static FluentCMS.Content.ValidationResult.Success.get -> FluentCMS.Content.ValidationResult! diff --git a/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs b/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs index 33b8e9b0e..45842e934 100644 --- a/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs +++ b/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs @@ -16,6 +16,8 @@ public class LayeringRulesTests private const string ApiNamespace = "FluentCMS.Core.Api"; private const string StorageNamespace = "FluentCMS.Core.Storage"; private const string AuthorizationNamespace = "FluentCMS.Core.Authorization"; + private const string ContentNamespace = "FluentCMS.Core.Content"; + private const string SitesNamespace = "FluentCMS.Core.Sites"; private static readonly Lazy core = new( () => new ArchLoader().LoadAssemblies(Assembly.Load("FluentCMS.Core")).Build()); @@ -74,6 +76,58 @@ public void OnlyAuthorizationReadsRolePermissionEntities() .Check(core.Value); } + // Issue #2693. CmsDbContext also holds the system tables, so these may reach it for their own tables only, which the + // next rule checks: the evaluator for roles, Sites and LocaleRegistry for cms_site and cms_site_locale, and + // the extensions that register it. + [Fact] + public void OnlyTheServiceAndTheStorageLayerTouchCmsDbContext() + { + Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) + .And().DoNotResideInNamespaceOrBelow(StorageNamespace) + .And().DoNotResideInNamespaceOrBelow(SitesNamespace) + .And().DoNotHaveName("AuthorizationEvaluator") + .And().DoNotHaveName("LocaleRegistry") + .And().DoNotHaveName("FluentCmsServiceCollectionExtensions") + .Should().NotDependOnAnyTypesThat().HaveName("CmsDbContext") + .Check(core.Value); + } + + // The evaluator asks whether an entry is in a site, and SiteManager whether a site has entries; neither reads content. + // Nothing outside Content and Storage reads a content table: Rows is the only way to one, and it is checked below. + [Fact] + public void OnlyTheServiceAndTheStorageLayerTouchTheContentSystemTables() + { + Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) + .And().DoNotResideInNamespaceOrBelow(StorageNamespace) + .And().DoNotHaveName("AuthorizationEvaluator") + .And().DoNotHaveName("SiteManager") + .Should().NotDependOnAnyTypesThat().HaveName("Document") + .AndShould().NotDependOnAnyTypesThat().HaveName("Reference") + .AndShould().NotDependOnAnyTypesThat().HaveName("ContentVersion") + .Check(core.Value); + } + + [Fact] + public void OnlyTheServiceAndTheStorageLayerReadContentRows() + { + var rows = MethodMembers().That().AreDeclaredIn(typeof(FluentCMS.Core.Storage.CmsDbContext)).And().HaveName("Rows(System.String)"); + + Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) + .And().DoNotResideInNamespaceOrBelow(StorageNamespace) + .Should().NotCallAny(rows) + .Check(core.Value); + } + + // Inside Content, the store and the query engine are what the service and the evaluator go through, not a way around them. + [Fact] + public void NothingOutsideContentUsesTheEntryStoreOrWritesAroundTheService() + { + Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) + .And().DoNotHaveName("FluentCmsServiceCollectionExtensions") + .Should().NotDependOnAnyTypesThat().HaveName("EntryStore") + .Check(core.Value); + } + private static IEnumerable FluentCmsReferences(ProjectFile project) => project.ProjectReferences .Concat(project.PackageReferences.Select(package => package.Name)) diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceAccessTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceAccessTests.cs new file mode 100644 index 000000000..124f0eee7 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceAccessTests.cs @@ -0,0 +1,300 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Data.Tests.Query; +using FluentCMS.Hooks; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceAccessTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private sealed class SetRegion : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["region"] = "from-hook"; + context.Entry["secret"] = "from-hook"; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AFieldThePrincipalCannotReadNeverAppearsInGetQueryOrAHooksReturnedEntry() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, SetRegion>()); + var entry = await database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5, ["region"] = "eu" }); + var role = database.Database.AddRoleForTest("Limited", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + var limited = WithRole(role); + + var read = (await database.GetAsync(limited, IdOf(entry)))!; + read["title"].ShouldBe("Hello"); + read.ContainsKey("views").ShouldBeFalse(); + read.ContainsKey("region").ShouldBeFalse(); + + var listed = (await database.QueryAsync(limited)).Items.ShouldHaveSingleItem(); + listed.ContainsKey("views").ShouldBeFalse(); + + var returned = await database.PatchAsync(limited, IdOf(entry), Titled("Changed"), 1); + returned["title"].ShouldBe("Changed"); + returned.ContainsKey("region").ShouldBeFalse(); + returned.ContainsKey("secret").ShouldBeFalse(); + returned.ContainsKey("views").ShouldBeFalse(); + + database.Row("cms_article", IdOf(entry))["region"].ShouldBe("from-hook"); + } + + [Fact] + public async Task ANotReadableEntryStillGivesItsWriterTheIdAndVersion() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var role = _database.Database.AddRoleForTest("WriteOnly", ("content.article.update", null), ("content.article.create", null)); + + var updated = await _database.PatchAsync(WithRole(role), IdOf(entry), Titled("Changed"), 1); + updated.Keys.ShouldBe(["id", "version"]); + updated["version"].ShouldBe(2); + + var created = await _database.CreateAsync(WithRole(role), Titled("New")); + created.Keys.ShouldBe(["id", "version"]); + + var conflict = await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), IdOf(entry), Titled("x"), 1)); + conflict.CurrentVersion.ShouldBe(2); + conflict.Current.ShouldBeNull(); + } + + [Fact] + public async Task AuthorReadsAndEditsOnlyTheirOwnEntries() + { + var ann = AuthorRole(Guid.CreateVersion7()); + var bob = AuthorRole(Guid.CreateVersion7()); + + var mine = await _database.CreateAsync(ann, Titled("Mine")); + var theirs = await _database.CreateAsync(bob, Titled("Theirs")); + var id = IdOf(theirs); + + (await _database.GetAsync(ann, IdOf(mine))).ShouldNotBeNull(); + (await _database.QueryAsync(ann)).Items.ShouldHaveSingleItem()["title"].ShouldBe("Mine"); + await Should.NotThrowAsync(() => _database.PatchAsync(ann, IdOf(mine), Titled("Changed"), 1)); + + (await _database.GetAsync(ann, id)).ShouldBeNull(); + await Should.ThrowAsync(() => _database.PatchAsync(ann, id, Titled("Stolen"), 1)); + await Should.ThrowAsync(() => _database.UpdateAsync(ann, id, Titled("Stolen"), 1)); + await Should.ThrowAsync(() => _database.DeleteAsync(ann, id)); + + (await _database.GetAsync(bob, id))!["title"].ShouldBe("Theirs"); + } + + [Fact] + public async Task AnEntryThatIsNotThereAndOneThatIsRefusedLookTheSame() + { + var theirs = await _database.CreateAsync(AuthorRole(Guid.CreateVersion7()), Titled("Theirs")); + var ann = AuthorRole(Guid.CreateVersion7()); + + await Should.ThrowAsync(() => _database.PatchAsync(ann, Guid.CreateVersion7(), Titled("x"), 1)); + await Should.ThrowAsync(() => _database.PatchAsync(ann, IdOf(theirs), Titled("x"), 1)); + await Should.ThrowAsync(() => _database.PatchAsync(Editor, Guid.CreateVersion7(), Titled("x"), 1)); + } + + [Fact] + public async Task AnEntryOfAnotherSiteOrTypeCannotBeReached() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + + (await _database.GetAsync(Editor, id, site: SiteB)).ShouldBeNull(); + await Should.ThrowAsync(() => _database.UpdateAsync(Editor, id, Titled("x"), 1, site: SiteB)); + await Should.ThrowAsync(() => _database.UpdateAsync(Editor, id, new() { ["name"] = "x" }, 1, "author")); + } + + [Fact] + public async Task ARoleOfOneSiteHoldsNothingInAnother() + { + var role = _database.Database.AddRoleForTest("A only", QueryDatabase.SiteA, ("content.article.create", null), ("content.article.read", null)); + + await Should.NotThrowAsync(() => _database.CreateAsync(WithRole(role), Titled("Here"))); + await Should.ThrowAsync(() => _database.CreateAsync(WithRole(role), Titled("There"), site: SiteB)); + } + + [Fact] + public async Task AnonymousCannotWrite() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var before = _database.Footprint(); + + // Even when the Public role grants it: writing needs a scope that an anonymous principal never has. + _database.Database.GrantForTest(BuiltInRoles.Public, "content.article.create"); + _database.Database.GrantForTest(BuiltInRoles.Public, "content.article.update"); + + await Should.ThrowAsync(() => _database.CreateAsync(Principal.Anonymous, Titled("x"))); + await Should.ThrowAsync(() => _database.UpdateAsync(Principal.Anonymous, IdOf(entry), Titled("x"), 1)); + await Should.ThrowAsync(() => _database.PatchAsync(Principal.Anonymous, IdOf(entry), Titled("x"), 1)); + await Should.ThrowAsync(() => _database.DeleteAsync(Principal.Anonymous, IdOf(entry))); + await Should.ThrowAsync(() => _database.RestoreAsync(Principal.Anonymous, IdOf(entry))); + + _database.Footprint().ShouldBe(before); + } + + [Fact] + public async Task AnonymousReadsNothingUntilThePublicRoleAllowsIt() + { + await _database.CreateAsync(Editor, Titled("Hello")); + + (await Should.ThrowAsync(() => _database.QueryAsync(Principal.Anonymous, new ContentQuery()))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("type"); + (await Should.ThrowAsync(() => _database.QueryAsync(Principal.Anonymous, new ContentQuery { Status = QueryStatus.Draft }))) + .Errors.ShouldContain(error => error.Path == "type"); + } + + [Fact] + public async Task WithNoOperationNothingRuns() + { + using var scope = _database.Database.NewScope(); + scope.ServiceProvider.GetRequiredService().Current = null; + var service = scope.ServiceProvider.GetRequiredService(); + + await Should.ThrowAsync(() => service.CreateAsync("article", Titled("x"), Token)); + await Should.ThrowAsync(() => service.GetAsync("article", Guid.CreateVersion7(), null, Token)); + } + + [Theory] + [InlineData(Origin.Api)] + [InlineData(Origin.Admin)] + [InlineData(Origin.Mcp)] + public async Task TheSystemPrincipalIsRefusedFromAClientOrigin(Origin origin) + { + await Should.ThrowAsync(() => _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", Titled("x"), Token), + origin: origin)); + } + + [Theory] + [InlineData(Origin.Job)] + [InlineData(Origin.Cli)] + [InlineData(Origin.Seed)] + public async Task TheSystemPrincipalWritesFromAJobTheToolOrSeeding(Origin origin) + { + using var scope = _database.Database.NewScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + + using (SystemOperation.Begin(accessor, origin, SiteA)) + { + accessor.Current!.Principal.ShouldBe(Principal.System); + + var entry = await scope.ServiceProvider.GetRequiredService().CreateAsync("article", new() { ["title"] = "Seeded", ["secret"] = "x" }, Token); + + entry["locale"].ShouldBe("en"); + entry.ContainsKey("secret").ShouldBeFalse(); + _database.Read(context => context.Documents.Single(document => document.DocumentId == IdOf(entry)).CreatedBy).ShouldBe(Guid.Empty); + _database.Versions(IdOf(entry)).ShouldHaveSingleItem().Origin.ShouldBe(origin); + } + + accessor.Current.ShouldBeNull(); + } + + [Fact] + public void ASystemOperationCannotBeBegunFromAClientOrigin() + { + using var scope = _database.Database.NewScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + + Should.Throw(() => SystemOperation.Begin(accessor, Origin.Api, SiteA)); + accessor.Current.ShouldBeNull(); + } + + [Fact] + public async Task EntriesTheSystemWroteAreNoOnesOwn() + { + var entry = await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", Titled("Seeded"), Token), + origin: Origin.Seed); + + (await _database.GetAsync(AuthorRole(), IdOf(entry))).ShouldBeNull(); + } + + [Fact] + public async Task DeleteHidesTheEntryAndRestoreBringsItBack() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + + await _database.DeleteAsync(Editor, id); + + (await _database.GetAsync(Editor, id)).ShouldBeNull(); + (await _database.QueryAsync(Editor)).Items.ShouldBeEmpty(); + await Should.ThrowAsync(() => _database.PatchAsync(Editor, id, Titled("x"), 1)); + await Should.ThrowAsync(() => _database.DeleteAsync(Editor, id)); + + var restored = await _database.RestoreAsync(Editor, id); + + restored["title"].ShouldBe("Hello"); + restored["version"].ShouldBe(1); + (await _database.GetAsync(Editor, id)).ShouldNotBeNull(); + await Should.ThrowAsync(() => _database.RestoreAsync(Editor, id)); + } + + [Fact] + public async Task DeleteAndRestoreWorkOnEveryLocale() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + + await _database.DeleteAsync(Editor, id, locale: "fr"); + (await _database.GetAsync(Editor, id, locale: "en")).ShouldBeNull(); + + await _database.RestoreAsync(Editor, id); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Draft, Fallback = LocaleFallback.None }, locale: "fr"))!["title"].ShouldBe("Bonjour"); + } + + [Fact] + public async Task RestoreHonoursTheRowRule() + { + var role = _database.Database.AddRoleForTest( + "Restorer", + ("content.*.create", null), + ("content.*.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }), + ("content.*.delete", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }), + ("content.*.restore", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule })); + var ann = WithRole(role, Guid.CreateVersion7()); + var bob = WithRole(role, Guid.CreateVersion7()); + + var mine = await _database.CreateAsync(ann, Titled("Mine")); + var theirs = await _database.CreateAsync(bob, Titled("Theirs")); + await _database.DeleteAsync(ann, IdOf(mine)); + await _database.DeleteAsync(bob, IdOf(theirs)); + + (await _database.RestoreAsync(ann, IdOf(mine)))["title"].ShouldBe("Mine"); + await Should.ThrowAsync(() => _database.RestoreAsync(ann, IdOf(theirs))); + (await _database.GetAsync(bob, IdOf(theirs))).ShouldBeNull(); + + // The seeded Author may delete its own entries but has no restore grant at all. + var author = AuthorRole(Guid.CreateVersion7()); + var own = await _database.CreateAsync(author, Titled("Own")); + await _database.DeleteAsync(author, IdOf(own)); + await Should.ThrowAsync(() => _database.RestoreAsync(author, IdOf(own))); + } + + [Fact] + public async Task DeletingNeedsTheGrantOnEveryLocale() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + await _database.UpdateAsync(Editor, IdOf(entry), Titled("Bonjour"), 0, locale: "fr"); + var role = _database.Database.AddRoleForTest("French", ("content.article.delete", new PermissionConstraints { Locales = ["fr"] })); + + await Should.ThrowAsync(() => _database.DeleteAsync(WithRole(role), IdOf(entry), locale: "fr")); + (await _database.GetAsync(Editor, IdOf(entry))).ShouldNotBeNull(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceHookTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceHookTests.cs new file mode 100644 index 000000000..8dbd3c1b2 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceHookTests.cs @@ -0,0 +1,329 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Hooks; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceHookTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private sealed class Log + { + public List Calls { get; } = []; + + public List> ChangedPaths { get; } = []; + } + + private sealed class EveryHook(Log log, ServiceDatabase? database = null) : + IBeforeValidateHook, + IBeforeCreateHook, + IAfterCreateHook, + IBeforeUpdateHook, + IAfterUpdateHook, + IBeforeDeleteHook, + IAfterDeleteHook, + IBeforeRestoreHook, + IAfterRestoreHook + { + private ValueTask Record(string name, HookContext context) + { + log.Calls.Add(name); + log.ChangedPaths.Add(context.ChangedPaths); + _ = database; + return ValueTask.CompletedTask; + } + + public ValueTask BeforeValidateAsync(HookContext context, CancellationToken cancellationToken) => Record("BeforeValidate", context); + + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Record("BeforeCreate", context); + + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) => Record("AfterCreate", context); + + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) => Record("BeforeUpdate", context); + + public ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken) => Record("AfterUpdate", context); + + public ValueTask BeforeDeleteAsync(HookContext context, CancellationToken cancellationToken) => Record("BeforeDelete", context); + + public ValueTask AfterDeleteAsync(HookContext context, CancellationToken cancellationToken) => Record("AfterDelete", context); + + public ValueTask BeforeRestoreAsync(HookContext context, CancellationToken cancellationToken) => Record("BeforeRestore", context); + + public ValueTask AfterRestoreAsync(HookContext context, CancellationToken cancellationToken) => Record("AfterRestore", context); + } + + private static void RegisterEvery(IServiceCollection services, EveryHook hook) + { + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + } + + [Fact] + public async Task HooksRunInTheDocumentedOrderWithTheChangedFields() + { + var log = new Log(); + using var database = new ServiceDatabase(cms => RegisterEvery(cms.Services, new EveryHook(log))); + + var entry = await database.CreateAsync(Editor, Titled("Hello")); + log.Calls.ShouldBe(["BeforeValidate", "BeforeCreate", "AfterCreate"]); + log.Calls.Clear(); + log.ChangedPaths.Clear(); + + await database.PatchAsync(Editor, IdOf(entry), new() { ["subtitle"] = "Sub" }, 1); + log.Calls.ShouldBe(["BeforeValidate", "BeforeUpdate", "AfterUpdate"]); + log.ChangedPaths.ShouldAllBe(paths => paths.Count == 1 && paths[0] == "subtitle"); + log.Calls.Clear(); + + await database.DeleteAsync(Editor, IdOf(entry)); + await database.RestoreAsync(Editor, IdOf(entry)); + log.Calls.ShouldBe(["BeforeDelete", "AfterDelete", "BeforeRestore", "AfterRestore"]); + } + + private sealed class Cancel(string kind) : + IBeforeValidateHook, + IBeforeCreateHook, + IAfterCreateHook, + IBeforeUpdateHook, + IAfterUpdateHook, + IBeforeDeleteHook, + IAfterDeleteHook + { + private ValueTask Maybe(string name, HookContext context) + { + if (name == kind) + { + context.Cancel("ACME0001", "No."); + } + + return ValueTask.CompletedTask; + } + + public ValueTask BeforeValidateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeValidate", context); + + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeCreate", context); + + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("AfterCreate", context); + + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeUpdate", context); + + public ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("AfterUpdate", context); + + public ValueTask BeforeDeleteAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeDelete", context); + + public ValueTask AfterDeleteAsync(HookContext context, CancellationToken cancellationToken) => Maybe("AfterDelete", context); + } + + private static void RegisterCancel(IServiceCollection services, string kind) + { + var hook = new Cancel(kind); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + services.AddSingleton>(hook); + } + + [Theory] + [InlineData("BeforeValidate")] + [InlineData("BeforeCreate")] + [InlineData("AfterCreate")] + public async Task ACancellingHookOnCreateRollsBackEveryTable(string kind) + { + using var database = new ServiceDatabase(cms => RegisterCancel(cms.Services, kind)); + var author = Guid.CreateVersion7(); + var before = database.Footprint(); + + var refused = await Should.ThrowAsync(() => database.CreateAsync(Editor, new() { ["title"] = "Hello", ["author"] = author })); + + refused.Code.ShouldBe("ACME0001"); + database.Footprint().ShouldBe(before); + } + + [Theory] + [InlineData("BeforeValidate")] + [InlineData("BeforeUpdate")] + [InlineData("AfterUpdate")] + public async Task ACancellingHookOnUpdateRollsBackEveryTable(string kind) + { + var active = new ActiveKind(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton>(new SwitchCancel(active)).AddSingleton>(new SwitchCancel(active)).AddSingleton>(new SwitchCancel(active))); + var author = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var entry = await database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1, ["author"] = author }); + var before = database.Footprint(); + active.Kind = kind; + + await Should.ThrowAsync(() => database.PatchAsync(Editor, IdOf(entry), new() { ["title"] = "Changed", ["views"] = 2, ["author"] = null }, 1)); + + active.Kind = ""; + database.Footprint().ShouldBe(before); + var stored = (await database.GetAsync(Editor, IdOf(entry)))!; + stored["title"].ShouldBe("Hello"); + stored["views"].ShouldBe(1L); + stored["version"].ShouldBe(1); + stored["author"].ShouldNotBeNull(); + } + + private sealed class ActiveKind + { + public string Kind { get; set; } = ""; + } + + private sealed class SwitchCancel(ActiveKind active) : + IBeforeValidateHook, + IBeforeUpdateHook, + IAfterUpdateHook + { + private ValueTask Maybe(string name, HookContext context) + { + if (active.Kind == name) + { + context.Cancel("ACME0001", "No."); + } + + return ValueTask.CompletedTask; + } + + public ValueTask BeforeValidateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeValidate", context); + + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("BeforeUpdate", context); + + public ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken) => Maybe("AfterUpdate", context); + } + + private sealed class ChangeInBeforeCreate(string field, object? value) : IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry[field] = value; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AHooksChangeIsStoredInCanonicalFormAndChecked() + { + using var stored = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("publishedOn", "2026-11-02T10:00:00+02:00"))); + var entry = await stored.CreateAsync(Editor, Titled("Hello")); + ((DateTimeOffset)entry["publishedOn"]!).ShouldBe(new DateTimeOffset(2026, 11, 2, 8, 0, 0, TimeSpan.Zero)); + + using var invalid = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("views", 5000))); + var before = invalid.Footprint(); + (await Should.ThrowAsync(() => invalid.CreateAsync(Editor, Titled("Hello")))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("views"), + error => error.Code.ShouldBe(ValidationErrorCodes.AboveMaximum)); + invalid.Footprint().ShouldBe(before); + + using var unknown = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("nothing", 1))); + (await Should.ThrowAsync(() => unknown.CreateAsync(Editor, Titled("Hello")))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("nothing"); + } + + [Fact] + public async Task UniquenessChecksWhatABeforeHookLeft() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("code", "same"))); + await database.CreateAsync(Editor, Titled("One")); + + (await Should.ThrowAsync(() => database.CreateAsync(Editor, Titled("Two")))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("code"); + } + + [Fact] + public async Task AHookCannotWriteAFieldThePrincipalMayNot() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("views", 5))); + var role = database.Database.AddRoleForTest("Limited", ("content.article.create", new PermissionConstraints { Fields = ["title"] }), ("content.article.read", null)); + var before = database.Footprint(); + + (await Should.ThrowAsync(() => database.CreateAsync(WithRole(role), Titled("Hello")))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("views"), + error => error.Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable)); + database.Footprint().ShouldBe(before); + + // An editor may write it, so the hook may too. + await Should.NotThrowAsync(() => database.CreateAsync(Editor, Titled("Other"))); + } + + [Fact] + public async Task AHookFillsAHiddenFieldThatTheResponseLeavesOut() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeInBeforeCreate("secret", "computed"))); + var role = database.Database.AddRoleForTest("Limited", ("content.article.create", new PermissionConstraints { Fields = ["title"] }), ("content.article.read", null)); + + var entry = await database.CreateAsync(WithRole(role), Titled("Hello")); + + entry.ContainsKey("secret").ShouldBeFalse(); + database.Row("cms_article", IdOf(entry))["secret"].ShouldBe("computed"); + } + + private sealed class ChangeSystemFields : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["createdBy"] = Guid.CreateVersion7(); + context.Entry["version"] = 77; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AHookCannotSetASystemField() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton>(new ChangeSystemFields())); + var entry = await database.CreateAsync(Editor, Titled("Hello")); + + var updated = await database.PatchAsync(Editor, IdOf(entry), Titled("Changed"), 1); + + updated["version"].ShouldBe(2); + database.Read(context => context.Documents.Single(document => document.DocumentId == IdOf(entry)).CreatedBy).ShouldBe(Editor.Id); + } + + private sealed class Rename : IBeforeUpdateHook
+ { + public ValueTask BeforeUpdateAsync(HookContext
context, CancellationToken cancellationToken) + { + context.Entry.Title = context.Previous!.Title + " and more"; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task ATypedHookSeesTheClassAndChangesOnlyWhatItChanges() + { + var schema = SampleSchema.FromClasses().Build(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, Rename>(), schema); + var entry = await database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["category"] = "opinion", + ["sections"] = new List { new() { ["type"] = "quote", ["text"] = "Hi" } }, + }); + + var role = database.Database.AddRoleForTest("Limited", ("content.article.update", new PermissionConstraints { Fields = ["title", "slug"] }), ("content.article.read", null)); + + var updated = await database.PatchAsync(WithRole(role), IdOf(entry), new() { ["slug"] = "other" }, 1); + + updated["title"].ShouldBe("Hello and more"); + updated["category"].ShouldBe("opinion"); + updated["slug"].ShouldBe("other"); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs new file mode 100644 index 000000000..b3ec813bc --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs @@ -0,0 +1,231 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Query; +using FluentCMS.Core.Localization; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceReadTests : IDisposable +{ + private const string Body = """{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hi"}]}]}"""; + + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static GetOptions Draft(string? locale = null, LocaleFallback fallback = LocaleFallback.Chain) => new() { Status = QueryStatus.Draft, Locale = locale, Fallback = fallback }; + + [Fact] + public async Task AQueryMapsToAPagedResult() + { + for (var index = 0; index < 3; index++) + { + await _database.CreateAsync(Editor, Titled($"Entry {index}")); + } + + var page = await _database.QueryAsync(Editor, new ContentQuery + { + Status = QueryStatus.Draft, + Page = new PageRequest { Number = 1, Size = 2 }, + Sort = [new SortField("createdAt")], + }); + + page.Items.Count.ShouldBe(2); + page.Total.ShouldBe(3); + page.Page.ShouldBe(1); + page.PageSize.ShouldBe(2); + page.NextCursor.ShouldNotBeNull(); + } + + [Fact] + public async Task AReadWithoutASiteOrLocaleStaysInTheOperation() + { + await _database.CreateAsync(Editor, Titled("In A")); + await _database.CreateAsync(Editor, Titled("In B"), site: SiteB); + + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft })).Items.ShouldHaveSingleItem()["title"].ShouldBe("In A"); + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft }, site: SiteB)).Items.ShouldHaveSingleItem()["title"].ShouldBe("In B"); + } + + [Fact] + public async Task ADraftIsNotPublished() + { + var entry = await _database.CreateAsync(Editor, Titled("Draft")); + + (await _database.GetAsync(Editor, IdOf(entry), new GetOptions())).ShouldBeNull(); + (await _database.QueryAsync(Editor, new ContentQuery())).Items.ShouldBeEmpty(); + } + + [Theory] + [InlineData("xx-")] + [InlineData("de")] + public async Task AnInvalidOrUnlistedLocaleIsRefusedOnReadAndWrite(string locale) + { + await Should.ThrowAsync(() => _database.GetAsync(Editor, Guid.CreateVersion7(), locale: locale)); + await Should.ThrowAsync(() => _database.CreateAsync(Editor, Titled("x"), locale: locale)); + } + + [Fact] + public async Task AWriteNeedsOneLocale() + { + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, Titled("x"), locale: "all"))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("locale"); + } + + [Fact] + public async Task AReadFallsBackThroughTheChain() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Sub en", ["body"] = Body }); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, new() { ["title"] = "Bonjour", ["subtitle"] = "Sous-titre" }, 0, locale: "fr"); + await _database.UpdateAsync(Editor, id, Titled("Salut"), 0, locale: "fr-CA"); + + var read = (await _database.GetAsync(Editor, id, Draft("fr-CA")))!; + + read["title"].ShouldBe("Salut"); + read["subtitle"].ShouldBe("Sous-titre"); + ((RichText)read["body"]!).Json.ShouldBe(Body); + var fallbacks = (ContentDocument)read[LocaleFallbackReader.FallbacksKey]!; + fallbacks["subtitle"].ShouldBe("fr"); + fallbacks["body"].ShouldBe("en"); + + var none = (await _database.GetAsync(Editor, id, Draft("fr-CA", LocaleFallback.None)))!; + none["subtitle"].ShouldBeNull(); + none.ContainsKey(LocaleFallbackReader.FallbacksKey).ShouldBeFalse(); + + var listed = (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft, Locale = "fr-CA" })).Items.ShouldHaveSingleItem(); + listed["subtitle"].ShouldBe("Sous-titre"); + } + + [Fact] + public async Task AnEntryByIdWithNoRowInTheLocaleIsServedFromTheChainAndAListLeavesItOut() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Sub" }); + + var read = (await _database.GetAsync(Editor, IdOf(entry), Draft("fr-CA")))!; + + read["title"].ShouldBe("Hello"); + read["locale"].ShouldBe("fr-CA"); + var fallbacks = (ContentDocument)read[LocaleFallbackReader.FallbacksKey]!; + fallbacks["title"].ShouldBe("en"); + fallbacks["subtitle"].ShouldBe("en"); + + (await _database.GetAsync(Editor, IdOf(entry), Draft("fr-CA", LocaleFallback.None))).ShouldBeNull(); + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft, Locale = "fr-CA" })).Items.ShouldBeEmpty(); + } + + [Fact] + public async Task AnEntryServedFromTheChainIsLimitedByTheRequestedLocaleToo() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Sub" }); + var role = _database.Database.AddRoleForTest( + "PerLocale", + ("content.article.read", new Core.Authorization.PermissionConstraints { Locales = ["en"] }), + ("content.article.read", new Core.Authorization.PermissionConstraints { Locales = ["fr-CA"], Fields = ["title"] })); + + var read = (await _database.GetAsync(WithRole(role), IdOf(entry), Draft("fr-CA")))!; + + read["title"].ShouldBe("Hello"); + read.ContainsKey("subtitle").ShouldBeFalse(); + ((ContentDocument)read[LocaleFallbackReader.FallbacksKey]!).Keys.ShouldBe(["title"]); + } + + [Fact] + public async Task EveryLocaleComesBackAsAMap() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + + var read = (await _database.GetAsync(Editor, id, Draft("all")))!; + read["id"].ShouldBe(id); + var map = (ContentDocument)read["locales"]!; + map.Keys.ShouldBe(["en", "fr"]); + ((ContentDocument)map["fr"]!)["title"].ShouldBe("Bonjour"); + + var listed = (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft, Locale = "all" })).Items.ShouldHaveSingleItem(); + ((ContentDocument)listed["locales"]!).Keys.ShouldBe(["en", "fr"]); + + (await _database.GetAsync(Editor, Guid.CreateVersion7(), Draft("all"))).ShouldBeNull(); + } + + [Fact] + public async Task EveryLocaleOfAnEntryOmitsWhatThePrincipalMayNotRead() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5 }); + var role = _database.Database.AddRoleForTest("Limited", ("content.article.read", new Core.Authorization.PermissionConstraints { Fields = ["title"] })); + + var read = (await _database.GetAsync(WithRole(role), IdOf(entry), new GetOptions { Locale = "all", Status = QueryStatus.Draft }))!; + + ((ContentDocument)((ContentDocument)read["locales"]!)["en"]!).ContainsKey("views").ShouldBeFalse(); + } + + [Fact] + public async Task RichTextIsRenderedAtEveryLevel() + { + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["body"] = Body, + ["sections"] = new List { new() { ["type"] = "hero", ["note"] = Body } }, + }); + + var read = (await _database.GetAsync(Editor, IdOf(entry), new GetOptions { Status = QueryStatus.Draft, RichTextFormat = RichTextFormats.Html }))!; + + read["body"].ShouldBe("

Hi

"); + ((System.Text.Json.Nodes.JsonArray)read["sections"]!)[0]!["note"]!.GetValue().ShouldBe("

Hi

"); + + (await _database.GetAsync(Editor, IdOf(entry), new GetOptions { Status = QueryStatus.Draft, RichTextFormat = "json" }))!["body"].ShouldBeOfType(); + (await Should.ThrowAsync(() => _database.GetAsync(Editor, IdOf(entry), new GetOptions { RichTextFormat = "pdf" }))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(QueryErrorCodes.InvalidParameter); + } + + [Fact] + public async Task ABlockFilterFindsWhatTheServiceStored() + { + await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["sections"] = new List { new() { ["type"] = "hero", ["at"] = "2026-11-02T10:00:00+02:00", ["on"] = "2026-11-02" } }, + }); + + foreach (var filter in new[] + { + new FieldFilter("sections.hero.at", FilterOperator.Equal, new DateTimeOffset(2026, 11, 2, 8, 0, 0, TimeSpan.Zero)), + new FieldFilter("sections.hero.on", FilterOperator.Equal, new DateOnly(2026, 11, 2)), + }) + { + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft, Filter = filter })).Items.Count.ShouldBe(1); + } + } + + [Fact] + public async Task CountCountsWhatThePrincipalMayRead() + { + var ann = AuthorRole(Guid.CreateVersion7()); + await _database.CreateAsync(ann, new() { ["title"] = "Mine", ["views"] = 1 }); + await _database.CreateAsync(ann, new() { ["title"] = "Mine too", ["views"] = 2 }); + await _database.CreateAsync(Editor, new() { ["title"] = "Theirs", ["views"] = 1 }); + + (await Count(Editor, null)).ShouldBe(3); + (await Count(ann, null)).ShouldBe(2); + (await Count(ann, new FieldFilter("views", FilterOperator.Equal, 1L))).ShouldBe(1); + (await _database.AsAsync(Editor, services => services.GetRequiredService().CountAsync("article", null, QueryStatus.Published, Token))).ShouldBe(0); + + Task Count(Principal principal, ContentFilter? filter) => + _database.AsAsync(principal, services => services.GetRequiredService().CountAsync("article", filter, QueryStatus.Draft, Token)); + } + + [Fact] + public async Task AnUnknownTypeIsRefusedEverywhere() + { + await Should.ThrowAsync(() => _database.GetAsync(Editor, Guid.CreateVersion7(), type: "nothing")); + await Should.ThrowAsync(() => _database.CreateAsync(Editor, [], "nothing")); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs new file mode 100644 index 000000000..814cbe315 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs @@ -0,0 +1,738 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Hooks; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceWriteTests : IDisposable +{ + private const string Body = """{"content":[{"content":[{"text":"Hi","type":"text"}],"type":"paragraph"}],"type":"doc"}"""; + private const string CanonicalBody = """{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hi"}]}]}"""; + + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + [Fact] + public async Task CreateWritesEveryTableAndReturnsTheSystemFields() + { + var author = await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author"); + + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello World", + ["views"] = 3, + ["author"] = IdOf(author), + ["body"] = Body, + }); + + entry["version"].ShouldBe(1); + entry["status"].ShouldBe(EntryStatus.Draft); + entry["locale"].ShouldBe("en"); + entry["title"].ShouldBe("Hello World"); + entry["slug"].ShouldBe("hello-world"); + entry["category"].ShouldBe("news"); + entry.ContainsKey("createdBy").ShouldBeFalse(); + + var id = IdOf(entry); + var version = _database.Versions(id).ShouldHaveSingleItem(); + version.Number.ShouldBe(1); + version.StatusAfter.ShouldBe(EntryStatus.Draft); + version.AuthorId.ShouldBe(Editor.Id); + version.Origin.ShouldBe(Origin.Api); + version.ClientId.ShouldBe("client"); + JsonNode.Parse(version.Snapshot)!["title"]!.GetValue().ShouldBe("Hello World"); + + _database.Row("cms_article", id)["views"].ShouldBe(3L); + _database.Row("cms_article_i18n", id, "en")["title"].ShouldBe("Hello World"); + _database.References(id).ShouldHaveSingleItem().TargetId.ShouldBe(IdOf(author)); + } + + [Fact] + public async Task RichTextIsStoredInItsCanonicalForm() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["body"] = Body }); + + _database.Row("cms_article_i18n", IdOf(entry), "en")["body"].ShouldBe(CanonicalBody); + } + + [Fact] + public async Task InvalidRichTextIsRefused() + { + var refused = await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["body"] = """{"type":"doc","content":[{"type":"nothing"}]}""" })); + + refused.Errors[0].Path.ShouldBe("body"); + refused.Errors[0].Code.ShouldBe(ValidationErrorCodes.InvalidValue); + } + + [Fact] + public async Task ADraftWithMissingRequiredTextWritesThePlaceholder() + { + var entry = await _database.CreateAsync(Editor, []); + + var row = _database.Row("cms_article_i18n", IdOf(entry), "en"); + row["title"].ShouldBe(""); + row["slug"].ShouldBe(""); + row["body"].ShouldBe(RichText.Empty.Json); + row["subtitle"].ShouldBeNull(); + + var shared = _database.Row("cms_article", IdOf(entry)); + shared["labels"].ShouldBe("[]"); + shared["sections"].ShouldBe("[]"); + shared["views"].ShouldBeNull(); + + entry["title"].ShouldBe(""); + } + + [Fact] + public async Task TwoDraftsWithThePlaceholderDoNotClash() + { + await _database.CreateAsync(Editor, []); + + await Should.NotThrowAsync(() => _database.CreateAsync(Editor, [])); + } + + [Fact] + public async Task BlockDatesAreStoredInTheFormsTheFiltersCompare() + { + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["sections"] = JsonNode.Parse("""[{"type":"hero","at":"2026-11-02T10:00:00+02:00","on":"2026-11-02","note":{"content":[{"type":"paragraph"}],"type":"doc"}}]"""), + }); + + var stored = JsonNode.Parse((string)_database.Row("cms_article", IdOf(entry))["sections"])!; + stored[0]!["at"]!.GetValue().ShouldBe("2026-11-02T08:00:00Z"); + stored[0]!["on"]!.GetValue().ShouldBe("2026-11-02"); + stored[0]!["note"]!.ToJsonString().ShouldBe(RichText.Empty.Json); + } + + [Fact] + public async Task ABlockOutsideTheFieldIsRefused() + { + var refused = await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["sections"] = JsonNode.Parse("""[{"type":"other"}]""") })); + + refused.Errors.ShouldHaveSingleItem().Code.ShouldBe(ValidationErrorCodes.NotAnAllowedOption); + } + + [Fact] + public async Task AValueInsideAComponentOrBlockIsValidated() + { + var refused = await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["seo"] = new ContentDocument { ["metaTitle"] = new string('x', 11) }, + ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"much too long a heading"}]"""), + })); + + refused.Errors.Select(error => error.Path).ShouldBe(["seo.metaTitle", "sections[0].heading"], ignoreOrder: true); + } + + [Fact] + public async Task AValidationFailureWritesNothing() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var before = _database.Footprint(); + + await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Other", ["views"] = 5000 })); + await Should.ThrowAsync(() => _database.PatchAsync(Editor, IdOf(entry), new() { ["views"] = 5000 }, 1)); + + _database.Footprint().ShouldBe(before); + (await _database.GetAsync(Editor, IdOf(entry)))!["version"].ShouldBe(1); + } + + [Fact] + public async Task AnUnknownFieldIsRefusedEvenForTheSystemPrincipal() + { + var refused = await Should.ThrowAsync(() => _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() { ["nope"] = 1 }, Token), + origin: Origin.Seed)); + + refused.Errors.ShouldHaveSingleItem().Path.ShouldBe("nope"); + } + + [Fact] + public async Task AStaleVersionFailsWithTheCurrentOneAttached() + { + var entry = await _database.CreateAsync(Editor, Titled("One")); + var updated = await _database.UpdateAsync(Editor, IdOf(entry), Titled("Two"), 1); + updated["version"].ShouldBe(2); + + var conflict = await Should.ThrowAsync(() => _database.UpdateAsync(Editor, IdOf(entry), Titled("Three"), 1)); + + conflict.ExpectedVersion.ShouldBe(1); + conflict.CurrentVersion.ShouldBe(2); + conflict.Current!["title"].ShouldBe("Two"); + conflict.Current["version"].ShouldBe(2); + (await _database.GetAsync(Editor, IdOf(entry)))!["title"].ShouldBe("Two"); + _database.Versions(IdOf(entry)).Count.ShouldBe(2); + } + + [Fact] + public async Task AVersionConflictOmitsWhatThePrincipalMayNotRead() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 5 }); + await _database.UpdateAsync(Editor, IdOf(entry), new() { ["title"] = "Two", ["views"] = 5 }, 1); + var role = _database.Database.AddRoleForTest("Limited", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + var conflict = await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), IdOf(entry), Titled("Three"), 1)); + + conflict.Current!["title"].ShouldBe("Two"); + conflict.Current.ContainsKey("views").ShouldBeFalse(); + } + + [Fact] + public async Task TwoConcurrentUpdatesInTwoContextsHaveOneWinner() + { + for (var round = 0; round < 5; round++) + { + var entry = await _database.CreateAsync(Editor, Titled($"Start {round}")); + var id = IdOf(entry); + + var results = await Task.WhenAll( + Task.Run(() => Attempt($"Left {round}")), + Task.Run(() => Attempt($"Right {round}"))); + + results.Count(result => result is null).ShouldBe(1); + results.Single(result => result is not null)!.CurrentVersion.ShouldBe(2); + _database.Versions(id).Count.ShouldBe(2); + + async Task Attempt(string title) + { + try + { + await _database.UpdateAsync(Editor, id, Titled(title), 1); + return null; + } + catch (VersionConflictException conflict) + { + return conflict; + } + } + } + } + + [Fact] + public async Task TheVersionSwapLetsOnlyOneOfTwoContextsAdvance() + { + var entry = await _database.CreateAsync(Editor, Titled("Start")); + var id = IdOf(entry); + + using var left = _database.Database.NewScope(); + using var right = _database.Database.NewScope(); + var leftStore = left.ServiceProvider.GetRequiredService(); + var rightStore = right.ServiceProvider.GetRequiredService(); + + (await leftStore.AdvanceVersionAsync(id, "en", 1, EntryStatus.Draft, DateTimeOffset.UtcNow, Editor.Id, Token)).ShouldBeTrue(); + (await rightStore.AdvanceVersionAsync(id, "en", 1, EntryStatus.Draft, DateTimeOffset.UtcNow, Editor.Id, Token)).ShouldBeFalse(); + (await rightStore.DraftVersionAsync(id, "en", Token)).ShouldBe(2); + } + + [Fact] + public async Task AMergePatchRemovesAFieldAndLeavesTheRest() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Sub", ["views"] = 4 }); + + var patched = await _database.PatchAsync(Editor, IdOf(entry), new() { ["subtitle"] = null, ["views"] = 5 }, 1); + + patched["subtitle"].ShouldBeNull(); + patched["views"].ShouldBe(5L); + patched["title"].ShouldBe("Hello"); + patched["version"].ShouldBe(2); + } + + [Fact] + public async Task AMergePatchMergesIntoAComponentAndAJsonField() + { + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["seo"] = new ContentDocument { ["metaTitle"] = "Keep" }, + ["metadata"] = JsonNode.Parse("""{"a":1,"b":2,"c":{"d":3,"e":4}}"""), + }); + + var patched = await _database.PatchAsync(Editor, IdOf(entry), new() + { + ["metadata"] = JsonNode.Parse("""{"a":null,"c":{"d":9}}"""), + }, 1); + + ((ContentDocument)patched["seo"]!)["metaTitle"].ShouldBe("Keep"); + JsonNode.DeepEquals((JsonNode)patched["metadata"]!, JsonNode.Parse("""{"b":2,"c":{"d":9,"e":4}}""")).ShouldBeTrue(); + + var cleared = await _database.PatchAsync(Editor, IdOf(entry), new() { ["seo"] = JsonNode.Parse("""{"metaTitle":null}""") }, 2); + ((ContentDocument)cleared["seo"]!)["metaTitle"].ShouldBeNull(); + } + + [Fact] + public async Task AFullUpdateClearsWhatItLeavesOutAndKeepsWhatThePrincipalCannotSee() + { + var entry = await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() + { + ["title"] = "Hello", + ["subtitle"] = "Sub", + ["secret"] = "kept", + ["views"] = 5, + ["seo"] = new ContentDocument { ["metaTitle"] = "Meta", ["internal"] = "inner" }, + ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"Hi","internal":"deep"}]"""), + }, Token), + origin: Origin.Seed); + var id = IdOf(entry); + + await _database.UpdateAsync(Editor, id, new() + { + ["title"] = "Changed", + ["seo"] = new ContentDocument { ["metaTitle"] = "New" }, + ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"Yo"}]"""), + }, 1); + + var shared = _database.Row("cms_article", id); + shared["secret"].ShouldBe("kept"); + shared["views"].ShouldBeNull(); + shared["seo_internal"].ShouldBe("inner"); + shared["seo_meta_title"].ShouldBe("New"); + var block = JsonNode.Parse((string)shared["sections"])![0]!; + block["heading"]!.GetValue().ShouldBe("Yo"); + block["internal"]!.GetValue().ShouldBe("deep"); + + _database.Row("cms_article_i18n", id, "en")["subtitle"].ShouldBeNull(); + } + + [Fact] + public async Task WhatARoundTripSendsBackIsNoChange() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Net" }, "tag")); + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["author"] = author, + ["tags"] = new[] { tag }, + ["hero"] = Guid.CreateVersion7(), + ["labels"] = new List { "net" }, + ["publishedOn"] = "2026-11-02T10:00:00+02:00", + ["issued"] = "2026-11-02", + ["price"] = 9.5m, + ["seo"] = new ContentDocument { ["metaTitle"] = "Meta" }, + ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"Hi","image":"0192f2a4-3333-7000-8000-000000000003"}]"""), + }); + + var read = (await _database.GetAsync(Editor, IdOf(entry)))!; + var saved = await _database.UpdateAsync(Editor, IdOf(entry), read, 1); + + foreach (var name in new[] { "title", "author", "tags", "hero", "labels", "publishedOn", "issued", "price", "seo", "sections", "body" }) + { + EntryJson.Same(saved[name], read[name]).ShouldBeTrue(name); + } + + saved["version"].ShouldBe(2); + } + + [Fact] + public async Task SystemFieldsSentByAClientAreIgnoredOrRefused() + { + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["id"] = Guid.Empty, + ["version"] = 99, + ["status"] = "published", + ["createdAt"] = "2000-01-01T00:00:00Z", + }); + + IdOf(entry).ShouldNotBe(Guid.Empty); + entry["version"].ShouldBe(1); + entry["status"].ShouldBe(EntryStatus.Draft); + ((DateTimeOffset)entry["createdAt"]!).Year.ShouldNotBe(2000); + + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["createdBy"] = Guid.CreateVersion7() }))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable); + (await Should.ThrowAsync(() => _database.PatchAsync(Editor, IdOf(entry), new() { ["updatedBy"] = Guid.CreateVersion7() }, 1))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable); + } + + public static TheoryData UnwritableShapes() + { + const string Json = """{"internal":"x"}"""; + return new TheoryData + { + { "seo", new ContentDocument { ["internal"] = "x" } }, + { "seo", new Dictionary { ["internal"] = "x" } }, + { "seo", JsonNode.Parse(Json)! }, + { "seo", JsonSerializer.SerializeToElement(JsonNode.Parse(Json)) }, + { "seo", Json }, + { "sections", JsonNode.Parse("""[{"type":"hero","internal":"x"}]""")! }, + { "sections", """[{"type":"hero","internal":"x"}]""" }, + { "sections", new List { new() { ["type"] = "hero", ["internal"] = "x" } } }, + { "sections", new[] { new Dictionary { ["type"] = "hero", ["internal"] = "x" } } }, + { "sections", JsonNode.Parse("""[{"type":"hero","details":{"internal":"x"}}]""")! }, + { "faq", JsonNode.Parse("""[{"internal":"x"}]""")! }, + { "faq", new List { new() { ["internal"] = "x" } } }, + }; + } + + [Theory] + [MemberData(nameof(UnwritableShapes))] + public async Task AHiddenFieldIsRefusedInEveryValueShape(string field, object value) + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var before = _database.Footprint(); + + var created = await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { [field] = value })); + var updated = await Should.ThrowAsync(() => _database.UpdateAsync(Editor, IdOf(entry), new() { [field] = value }, 1)); + var patched = await Should.ThrowAsync(() => _database.PatchAsync(Editor, IdOf(entry), new() { [field] = value }, 1)); + + created.Errors.ShouldAllBe(error => error.Code == AuthorizationErrorCodes.FieldNotWritable); + updated.Errors.ShouldAllBe(error => error.Code == AuthorizationErrorCodes.FieldNotWritable); + patched.Errors.ShouldAllBe(error => error.Code == AuthorizationErrorCodes.FieldNotWritable); + _database.Footprint().ShouldBe(before); + } + + [Fact] + public async Task JsonThatRepeatsAHiddenKeyIsRefused() + { + const string Repeated = """{"metaTitle":"a","internal":"x","internal":"y"}"""; + + await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["seo"] = Repeated })); + await Should.ThrowAsync(() => _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() { ["seo"] = Repeated }, Token), + origin: Origin.Seed)); + await Should.ThrowAsync(() => _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() { ["metadata"] = Repeated }, Token), + origin: Origin.Seed)); + } + + [Fact] + public async Task AFieldOutsideTheAllowListIsRefusedAndTheOthersAreWritten() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5, ["region"] = "eu" }); + var role = _database.Database.AddRoleForTest("Limited", ("content.article.read", null), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + var limited = WithRole(role); + + (await Should.ThrowAsync(() => _database.UpdateAsync(limited, IdOf(entry), new() { ["title"] = "x", ["views"] = 6 }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + (await Should.ThrowAsync(() => _database.PatchAsync(limited, IdOf(entry), new() { ["views"] = null }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + + // What it sends back unchanged is no write, and what it leaves out keeps its value. + var read = (await _database.GetAsync(limited, IdOf(entry)))!; + read["title"] = "Changed"; + read.Remove("region"); + var saved = await _database.UpdateAsync(limited, IdOf(entry), read, 1); + + saved["title"].ShouldBe("Changed"); + saved["views"].ShouldBe(5L); + saved["region"].ShouldBe("eu"); + } + + [Fact] + public async Task AFullUpdateIsNoWayToGuessAHiddenValue() + { + var entry = await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() + { + ["title"] = "Hello", + ["secret"] = "right", + ["seo"] = new ContentDocument { ["metaTitle"] = "Meta", ["internal"] = "right" }, + ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"Hi","internal":"right"}]"""), + }, Token), + origin: Origin.Seed); + var id = IdOf(entry); + var role = _database.Database.AddRoleForTest("Titles", ("content.article.read", null), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + var limited = WithRole(role); + + foreach (var guess in new[] { "right", "wrong" }) + { + (await Should.ThrowAsync(() => _database.UpdateAsync(limited, id, new() { ["secret"] = guess }, 1))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable); + (await Should.ThrowAsync(() => _database.UpdateAsync(limited, id, new() { ["seo"] = new ContentDocument { ["metaTitle"] = "Meta", ["internal"] = guess } }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("seo"); + (await Should.ThrowAsync(() => _database.UpdateAsync(limited, id, new() { ["sections"] = JsonNode.Parse($$"""[{"type":"hero","heading":"Hi","internal":"{{guess}}"}]""") }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("sections"); + } + + // What it read, with no hidden values in it, goes back as no change to them. + var read = (await _database.GetAsync(limited, id))!; + read["title"] = "Changed"; + var saved = await _database.UpdateAsync(limited, id, read, 1); + + saved["title"].ShouldBe("Changed"); + var shared = _database.Row("cms_article", id); + shared["seo_internal"].ShouldBe("right"); + JsonNode.Parse((string)shared["sections"])![0]!["internal"]!.GetValue().ShouldBe("right"); + } + + [Fact] + public async Task AReadOnlyFieldIsTheEnginesToSet() + { + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["stamp"] = "x" }))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable); + + var entry = await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("article", new() { ["stamp"] = "x" }, Token), + origin: Origin.Job); + + entry["stamp"].ShouldBe("x"); + } + + [Fact] + public async Task UniquenessHoldsAgainstADraftAndAPublishedEntry() + { + await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }); + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c2" }))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("slug"), + error => error.Code.ShouldBe(ValidationErrorCodes.NotUnique)); + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Other", ["code"] = "c1" }))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("code"); + + var published = await _database.CreateAsync(Editor, new() { ["title"] = "Live", ["code"] = "c3" }); + _database.Read(context => + { + var localized = context.Rows("cms_article_i18n").AsNoTracking().Single(row => EF.Property(row, "document_id") == IdOf(published)); + localized.Remove("id"); + localized["state"] = ContentState.Published; + localized["slug"] = "published-only"; + context.Rows("cms_article_i18n").Add(localized); + return context.SaveChanges(); + }); + + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Published Only" }))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("slug"); + } + + [Fact] + public async Task UniquenessIsPerSiteAndPerLocale() + { + var first = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }); + + await Should.NotThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }, site: SiteB)); + await Should.NotThrowAsync(() => _database.UpdateAsync(Editor, IdOf(first), new() { ["title"] = "Hello" }, 0, locale: "fr")); + await Should.NotThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c2" }, locale: "fr-CA")); + + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello" }, locale: "fr"))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("slug"); + } + + [Fact] + public async Task AnEntryIsNotItsOwnClashAndATrashedOneStillHoldsItsValue() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }); + await Should.NotThrowAsync(() => _database.UpdateAsync(Editor, IdOf(entry), new() { ["title"] = "Hello", ["code"] = "c1" }, 1)); + + await _database.DeleteAsync(Editor, IdOf(entry)); + + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Other", ["code"] = "c1" }))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("code"); + } + + [Fact] + public async Task AGlobalTypeIsUniqueAcrossSites() + { + var tag = await _database.CreateAsync(Editor, new() { ["name"] = "Net", ["code"] = "net" }, "tag"); + + _database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == IdOf(tag)).SiteId).ShouldBeNull(); + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["name"] = "Other", ["code"] = "net" }, "tag", site: SiteB))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("code"); + (await _database.GetAsync(Editor, IdOf(tag), type: "tag", site: SiteB)).ShouldNotBeNull(); + } + + [Fact] + public async Task ASingletonHasOneEntryPerSite() + { + await _database.CreateAsync(Editor, new() { ["name"] = "One" }, "settings"); + + (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["name"] = "Two" }, "settings"))) + .Errors.ShouldHaveSingleItem().Code.ShouldBe(ContentErrorCodes.SingletonExists); + await Should.NotThrowAsync(() => _database.CreateAsync(Editor, new() { ["name"] = "Two" }, "settings", site: SiteB)); + } + + [Fact] + public async Task AReferenceWriteIsCorrectAcrossLocalizedAndSharedFields() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var bob = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Bob" }, "author")); + var tag1 = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "A" }, "tag")); + var tag2 = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "B" }, "tag")); + var media = Guid.CreateVersion7(); + + var entry = await _database.CreateAsync(Editor, new() + { + ["title"] = "Hello", + ["author"] = ann, + ["sponsor"] = ann, + ["tags"] = new[] { tag2, tag1 }, + ["seo"] = new ContentDocument { ["image"] = media }, + }); + var id = IdOf(entry); + + var french = await _database.UpdateAsync(Editor, id, new() { ["title"] = "Bonjour", ["sponsor"] = bob }, 0, locale: "fr"); + + var rows = _database.References(id); + rows.Select(row => (row.Field, row.Locale, row.Position, row.TargetType, row.TargetId, row.State)).ShouldBe( + [ + ("author", "", 0, "author", ann, ContentState.Draft), + ("seo.image", "", 0, "media", media, ContentState.Draft), + ("sponsor", "en", 0, "author", ann, ContentState.Draft), + ("sponsor", "fr", 0, "author", bob, ContentState.Draft), + ("tags", "", 0, "tag", tag2, ContentState.Draft), + ("tags", "", 1, "tag", tag1, ContentState.Draft), + ], ignoreOrder: true); + + // Adding a locale left the shared values alone, and the engine reads them under the path it expects. + ((ContentDocument)french["sponsor"]!)["id"].ShouldBe(bob); + ((ContentDocument)french["author"]!)["id"].ShouldBe(ann); + ((List)french["tags"]!).Select(tag => tag["id"]).ShouldBe([tag2, tag1]); + ((ContentDocument)((ContentDocument)french["seo"]!)["image"]!)["id"].ShouldBe(media); + + // Changing one locale's relation leaves the other's. + await _database.PatchAsync(Editor, id, new() { ["sponsor"] = null, ["tags"] = new[] { tag1 } }, 1); + (await _database.GetAsync(Editor, id, locale: "fr"))!["sponsor"].ShouldNotBeNull(); + (await _database.GetAsync(Editor, id))!["sponsor"].ShouldBeNull(); + _database.References(id).Where(row => row.Field == "tags").ShouldHaveSingleItem().TargetId.ShouldBe(tag1); + } + + [Fact] + public async Task ReferencesInsideJsonAndRichTextAreRecordedUnderTheirField() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var media = Guid.CreateVersion7(); + var linked = Guid.CreateVersion7(); + var sections = new JsonArray(new JsonObject { ["type"] = "hero", ["image"] = media.ToString(), ["cover"] = ann.ToString() }); + var body = $$$"""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"x","marks":[{"type":"entryLink","attrs":{"entryType":"author","entryId":"{{{linked}}}"}}]}]}]}"""; + + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["sections"] = sections, ["body"] = body }); + + var rows = _database.References(IdOf(entry)); + rows.Where(row => row.Field == "sections").Select(row => (row.TargetType, row.TargetId, row.Locale)).ShouldBe([("media", media, ""), ("author", ann, "")]); + rows.Where(row => row.Field == "body").Select(row => (row.TargetType, row.TargetId, row.Locale)).ShouldBe([("author", linked, "en")]); + + var stored = JsonNode.Parse((string)_database.Row("cms_article", IdOf(entry))["sections"])![0]!; + stored["image"]!["type"]!.GetValue().ShouldBe("media"); + stored["cover"]!["id"]!.GetValue().ShouldBe(ann.ToString()); + } + + [Fact] + public async Task AddingALocaleNeedsTheExpectedVersionZeroAndKeepsTheOwner() + { + var owner = Guid.CreateVersion7(); + var author = AuthorRole(owner); + var entry = await _database.CreateAsync(author, new() { ["title"] = "Hello", ["views"] = 3 }); + var id = IdOf(entry); + + var conflict = await Should.ThrowAsync(() => _database.UpdateAsync(author, id, Titled("Bonjour"), 1, locale: "fr")); + conflict.CurrentVersion.ShouldBe(0); + + var french = await _database.UpdateAsync(author, id, Titled("Bonjour"), 0, locale: "fr"); + + french["version"].ShouldBe(1); + french["locale"].ShouldBe("fr"); + french["views"].ShouldBe(3L); + _database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == id && document.Locale == "fr").CreatedBy).ShouldBe(owner); + _database.Versions(id).Select(version => (version.Locale, version.Number)).ShouldBe([("en", 1), ("fr", 1)]); + + await Should.ThrowAsync(() => _database.UpdateAsync(AuthorRole(), id, Titled("Hallo"), 0, locale: "fr-CA")); + } + + [Fact] + public async Task AGrantLimitedToALocaleWritesOnlyThatLocale() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var role = _database.Database.AddRoleForTest("Translator", ("content.article.update", new PermissionConstraints { Locales = ["fr"] }), ("content.article.read", null)); + var translator = WithRole(role); + + await Should.NotThrowAsync(() => _database.UpdateAsync(translator, IdOf(entry), Titled("Bonjour"), 0, locale: "fr")); + await Should.ThrowAsync(() => _database.PatchAsync(translator, IdOf(entry), Titled("Changed"), 1)); + } + + [Fact] + public async Task AChangeToASharedFieldMarksThePublishedOtherLocalesModified() + { + var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + _database.Read(context => context.Documents.Where(document => document.DocumentId == id).ExecuteUpdate(set => set.SetProperty(document => document.Status, EntryStatus.Published))); + + await _database.PatchAsync(Editor, id, new() { ["subtitle"] = "Sub" }, 1); + StatusOf(id, "fr").ShouldBe(EntryStatus.Published); + StatusOf(id, "en").ShouldBe(EntryStatus.Modified); + + await _database.PatchAsync(Editor, id, new() { ["views"] = 4 }, 2); + StatusOf(id, "fr").ShouldBe(EntryStatus.Modified); + + // Adding a locale that changes a shared field counts too. + _database.Read(context => context.Documents.Where(document => document.DocumentId == id).ExecuteUpdate(set => set.SetProperty(document => document.Status, EntryStatus.Published))); + await _database.UpdateAsync(Editor, id, new() { ["title"] = "Salut", ["views"] = 9 }, 0, locale: "fr-CA"); + StatusOf(id, "en").ShouldBe(EntryStatus.Modified); + } + + private EntryStatus StatusOf(Guid id, string locale) => + _database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == id && document.Locale == locale).Status); + + [Fact] + public async Task AWriteMayNotTakeAnEntryOutOfTheRuleThatAllowedIt() + { + using var database = new ServiceDatabase(cms => cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region"))); + var entry = await database.CreateAsync(Editor, new() { ["title"] = "Hello", ["region"] = "eu" }); + var role = database.Database.AddRoleForTest("Regional", ("content.article.read", new PermissionConstraints { RowRule = "region" }), ("content.article.update", new PermissionConstraints { RowRule = "region" })); + var regional = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + + await Should.NotThrowAsync(() => database.PatchAsync(regional, IdOf(entry), new() { ["subtitle"] = "Sub" }, 1)); + await Should.ThrowAsync(() => database.PatchAsync(regional, IdOf(entry), new() { ["region"] = "us" }, 2)); + (await database.GetAsync(Editor, IdOf(entry)))!["region"].ShouldBe("eu"); + } + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + private sealed class Recorder : IEntryChangeSink + { + public List Changes { get; } = []; + + public ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) + { + Changes.Add(change); + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task TheChangeSinkHearsOnlyWhatCommitted() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + + var entry = await database.CreateAsync(Editor, Titled("Hello")); + await Should.ThrowAsync(() => database.CreateAsync(Editor, new() { ["views"] = 5000 })); + await Should.ThrowAsync(() => database.PatchAsync(Editor, IdOf(entry), Titled("x"), 7)); + await database.DeleteAsync(Editor, IdOf(entry)); + await database.RestoreAsync(Editor, IdOf(entry)); + + recorder.Changes.Select(change => change.Kind).ShouldBe([EntryChangeKind.DraftSaved, EntryChangeKind.Trashed, EntryChangeKind.Restored]); + recorder.Changes[0].Version.ShouldBe(1); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ServiceDatabase.cs b/tests/FluentCMS.Data.Tests/Content/ServiceDatabase.cs new file mode 100644 index 000000000..f97b872b1 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ServiceDatabase.cs @@ -0,0 +1,174 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Data.Tests.Query; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace FluentCMS.Data.Tests.Content; + +// The real service on a real SQLite file, with sites A and B that list en, fr and fr-CA. Every call is a request of its +// own: a new scope, so a new DbContext, under the operation the entry point would set. +internal sealed class ServiceDatabase : IDisposable +{ + public static readonly SiteId SiteA = new(QueryDatabase.SiteA); + public static readonly SiteId SiteB = new(QueryDatabase.SiteB); + + // The sample types in the shape a host writes them, with a unique field in each table, for the tests of the document service. + public static readonly SchemaModel Schema = SampleSchema.NewBuilder() + .AddComponent("seo", component => component + .Field("metaTitle", "text", field => field.Length(null, 10)) + .Field("image", "media") + .Field("internal", "text", field => field.Hidden())) + .AddBlock("hero", block => block + .Field("heading", "text", field => field.Length(null, 10)) + .Field("on", "date") + .Field("at", "dateTime") + .Field("note", "richText") + .Field("image", "media") + .Field("cover", "relation", field => field.Relation("author")) + .Field("internal", "text", field => field.Hidden()) + .Field("details", "component", field => field.Setting(FieldSettings.Component, "seo"))) + .AddBlock("quote", block => block.Field("text", "text")) + .AddBlock("other", block => block.Field("text", "text")) + .AddCollection("author", type => type + .Field("name", "text", field => field.Required()) + .Field("secret", "text", field => field.Hidden())) + .AddCollection("tag", type => type + .Scope(ContentScope.Global) + .Field("name", "text") + .Field("code", "text", field => field.Unique())) + .AddCollection("article", type => type + .Localized() + .Field("title", "text", field => field.Required().Localizable().Length(null, 40)) + .Field("slug", "slug", field => field.Localizable().Unique().Setting(FieldSettings.Source, "title")) + .Field("subtitle", "text", field => field.Localizable()) + .Field("body", "richText", field => field.Localizable()) + .Field("sponsor", "relation", field => field.Localizable().Relation("author")) + .Field("views", "integer", field => field.Range(0, 1000)) + .Field("code", "text", field => field.Unique()) + .Field("region", "text") + .Field("category", "select", field => field.DefaultValue("news").Setting(FieldSettings.Options, new[] { "news", "opinion" })) + .Field("labels", "select", field => field.Multiple().Setting(FieldSettings.Options, new[] { "net", "sql" })) + .Field("publishedOn", "dateTime") + .Field("issued", "date") + .Field("price", "decimal") + .Field("author", "relation", field => field.Relation("author")) + .Field("tags", "relation", field => field.Multiple().Relation("tag", ordered: true)) + .Field("hero", "media") + .Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, new[] { "hero", "quote" })) + .Field("seo", "component", field => field.Setting(FieldSettings.Component, "seo")) + .Field("faq", "component", field => field.Multiple().Setting(FieldSettings.Component, "seo")) + .Field("metadata", "json") + .Field("stamp", "text", field => field.IsReadOnly()) + .Field("secret", "text", field => field.Hidden())) + .AddSingleton("settings", type => type.Field("name", "text")) + .Build(); + + public ServiceDatabase(Action? configure = null, SchemaModel? schema = null) + { + Database = new QueryDatabase(configure, schema ?? Schema); + + var context = Database.Context; + foreach (var (site, slug) in new[] { (QueryDatabase.SiteA, "a"), (QueryDatabase.SiteB, "b") }) + { + context.Add(new Site { Id = site, Name = slug, Slug = slug, DefaultLocale = "en", CreatedAt = DateTimeOffset.UtcNow }); + context.AddRange( + new SiteLocale { SiteId = site, Code = "en", Position = 0, Enabled = true }, + new SiteLocale { SiteId = site, Code = "fr", Position = 1, Enabled = true }, + new SiteLocale { SiteId = site, Code = "fr-CA", Position = 2, Enabled = true }); + } + + context.SaveChanges(); + } + + public QueryDatabase Database { get; } + + public static CancellationToken Token => TestContext.Current.CancellationToken; + + public static Principal Editor { get; } = AuthorizationDatabase.User([BuiltInRoles.Editor], [TokenScopes.Management]); + + public static Principal AuthorRole(Guid? id = null) => AuthorizationDatabase.User([BuiltInRoles.Author], [TokenScopes.Management], id); + + public static Principal WithRole(Guid role, Guid? id = null) => AuthorizationDatabase.User([role], [TokenScopes.Management], id); + + public async Task AsAsync(Principal principal, Func> work, string locale = "en", SiteId? site = null, Origin origin = Origin.Api) + { + using var scope = Database.NewScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + accessor.Current = new OperationContext(principal, origin, site ?? SiteA, locale, "client", "correlation", DateTimeOffset.UtcNow); + try + { + return await work(scope.ServiceProvider); + } + finally + { + accessor.Current = null; + } + } + + public Task CreateAsync(Principal principal, ContentDocument entry, string type = "article", string locale = "en", SiteId? site = null) => + AsAsync(principal, services => services.GetRequiredService().CreateAsync(type, entry, Token), locale, site); + + public Task UpdateAsync(Principal principal, Guid id, ContentDocument entry, int version, string type = "article", string locale = "en", SiteId? site = null) => + AsAsync(principal, services => services.GetRequiredService().UpdateAsync(type, id, entry, version, Token), locale, site); + + public Task PatchAsync(Principal principal, Guid id, ContentDocument patch, int version, string type = "article", string locale = "en") => + AsAsync(principal, services => services.GetRequiredService().PatchAsync(type, id, patch, version, Token), locale); + + public Task GetAsync(Principal principal, Guid id, GetOptions? options = null, string type = "article", string locale = "en", SiteId? site = null) => + AsAsync(principal, services => services.GetRequiredService().GetAsync(type, id, options ?? new GetOptions { Status = QueryStatus.Draft }, Token), locale, site); + + public Task> QueryAsync(Principal principal, ContentQuery? query = null, string type = "article", string locale = "en", SiteId? site = null) => + AsAsync(principal, services => services.GetRequiredService().QueryAsync(type, query ?? new ContentQuery { Status = QueryStatus.Draft }, Token), locale, site); + + public Task DeleteAsync(Principal principal, Guid id, string type = "article", string locale = "en") => + AsAsync(principal, async services => + { + await services.GetRequiredService().DeleteAsync(type, id, Token); + return null; + }, locale); + + public Task RestoreAsync(Principal principal, Guid id, string type = "article", string locale = "en") => + AsAsync(principal, services => services.GetRequiredService().RestoreAsync(type, id, Token), locale); + + public static Guid IdOf(ContentDocument entry) => (Guid)entry["id"]!; + + public static int VersionOf(ContentDocument entry) => (int)entry["version"]!; + + public T Read(Func read) + { + using var scope = Database.NewScope(); + return read(scope.ServiceProvider.GetRequiredService()); + } + + public List Versions(Guid id) => + Read(context => context.Versions.AsNoTracking().Where(version => version.DocumentId == id).OrderBy(version => version.Locale).ThenBy(version => version.Number).ToList()); + + public List References(Guid id) => + Read(context => context.References.AsNoTracking().Where(reference => reference.SourceDocumentId == id).OrderBy(reference => reference.Field).ThenBy(reference => reference.Position).ToList()); + + public Dictionary Row(string table, Guid id, string? locale = null) => + Read(context => context.Rows(table).AsNoTracking().Single(row => + EF.Property(row, StorageNames.DocumentId) == id + && EF.Property(row, StorageNames.State) == ContentState.Draft + && (locale == null || EF.Property(row, StorageNames.Locale) == locale))); + + // What each table holds for all content, so that a test can say that a failed operation left no trace in any of them. + public string Footprint() => Read(context => string.Join( + ',', + context.Documents.IgnoreQueryFilters().Count(), + context.Rows("cms_article").Count(), + context.Rows("cms_article_i18n").Count(), + context.References.Count(), + context.Versions.Count())); + + public void Dispose() => Database.Dispose(); +} diff --git a/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs b/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs new file mode 100644 index 000000000..a07384316 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs @@ -0,0 +1,195 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Media; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; +using Author = FluentCMS.Core.Tests.Schema.Samples.Author; + +namespace FluentCMS.Data.Tests.Content; + +// The sample types, which is what #2706 will replace with the Blog types of FluentCMS.Testing. +public sealed class TypedContentServiceTests : IDisposable +{ + private const string Body = """{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hi"}]}]}"""; + + private readonly ServiceDatabase _database = new(schema: SampleSchema.FromClasses().Build()); + + public void Dispose() => _database.Dispose(); + + private Task AsAsync(Principal principal, Func, Task> work, string locale = "en") + where T : ContentItem => + _database.AsAsync(principal, services => work(services.GetRequiredService>()), locale); + + private static GetOptions Draft => new() { Status = QueryStatus.Draft }; + + private static Article Sample(Guid author, Guid tag, Guid media) => new() + { + Title = "Hello World", + Body = new RichText(Body), + Author = new Ref(author), + Tags = [new Ref(tag)], + Hero = new MediaRef(media), + Sections = [new HeroBlock { Heading = "Big", Image = new MediaRef(media) }, new QuoteBlock { Text = "Said", Attribution = "Ann" }, new GalleryBlock { Images = [new MediaRef(media)] }], + Seo = new Seo { MetaTitle = "Meta" }, + Category = ArticleCategory.Video, + PublishedOn = new DateTimeOffset(2026, 11, 2, 8, 0, 0, TimeSpan.Zero), + VideoUrl = "https://example.com/video", + }; + + [Fact] + public async Task TheSampleTypesRoundTrip() + { + var author = await AsAsync(Editor, service => service.CreateAsync(new Author { Name = "Ann", Bio = new RichText(Body), Avatar = new MediaRef(Guid.CreateVersion7()) }, Token)); + var tag = await AsAsync(Editor, service => service.CreateAsync(new Tag { Name = "Net Core" }, Token)); + var media = Guid.CreateVersion7(); + + var created = await AsAsync(Editor, service => service.CreateAsync(Sample(author.Id, tag.Id, media), Token)); + + created.Id.ShouldNotBe(Guid.Empty); + created.Version.ShouldBe(1); + created.Status.ShouldBe(EntryStatus.Draft); + created.Locale.ShouldBe("en"); + created.CreatedAt.ShouldNotBe(default); + created.CreatedBy.ShouldBe(Editor.Id); + + var read = (await AsAsync(Editor, service => service.GetAsync(created.Id, Draft, Token)))!; + + read.Title.ShouldBe("Hello World"); + read.Slug.ShouldBe("hello-world"); + read.Body.Json.ShouldBe(Body); + read.Author.Id.ShouldBe(author.Id); + read.Tags.ShouldHaveSingleItem().Id.ShouldBe(tag.Id); + read.Hero.ShouldBe(new MediaRef(media)); + read.Seo.MetaTitle.ShouldBe("Meta"); + read.Category.ShouldBe(ArticleCategory.Video); + read.PublishedOn.ShouldBe(new DateTimeOffset(2026, 11, 2, 8, 0, 0, TimeSpan.Zero)); + read.VideoUrl.ShouldBe("https://example.com/video"); + + read.Sections.Count.ShouldBe(3); + var hero = read.Sections[0].ShouldBeOfType(); + hero.Heading.ShouldBe("Big"); + hero.Image.ShouldBe(new MediaRef(media)); + var quote = read.Sections[1].ShouldBeOfType(); + quote.Attribution.ShouldBe("Ann"); + read.Sections[2].ShouldBeOfType().Images.ShouldBe([new MediaRef(media)]); + + var slug = await AsAsync(Editor, service => service.GetAsync(tag.Id, Draft, Token)); + slug!.Slug.ShouldBe("net-core"); + (await AsAsync(Editor, service => service.GetAsync(author.Id, Draft, Token)))!.Bio.Json.ShouldBe(Body); + + read.Title = "Changed"; + var updated = await AsAsync(Editor, service => service.UpdateAsync(read.Id, read, read.Version, Token)); + + updated.Title.ShouldBe("Changed"); + updated.Version.ShouldBe(2); + updated.Slug.ShouldBe("hello-world"); + updated.Category.ShouldBe(ArticleCategory.Video); + updated.Sections.Count.ShouldBe(3); + updated.Author.Id.ShouldBe(author.Id); + updated.UpdatedBy.ShouldBe(Editor.Id); + + var page = await AsAsync>(Editor, service => service.QueryAsync(new ContentQuery { Status = QueryStatus.Draft }, Token)); + page.Items.ShouldHaveSingleItem().Title.ShouldBe("Changed"); + page.Total.ShouldBe(1); + + (await AsAsync(Editor, service => service.CountAsync(null, QueryStatus.Draft, Token))).ShouldBe(1); + + var patched = await AsAsync(Editor, service => service.PatchAsync(read.Id, new() { ["videoUrl"] = null }, 2, Token)); + patched.VideoUrl.ShouldBeNull(); + + await AsAsync(Editor, async service => + { + await service.DeleteAsync(read.Id, Token); + return true; + }); + (await AsAsync(Editor, service => service.GetAsync(read.Id, Draft, Token))).ShouldBeNull(); + (await AsAsync(Editor, service => service.RestoreAsync(read.Id, Token))).Title.ShouldBe("Changed"); + } + + [Fact] + public async Task ASingletonRoundTrips() + { + var created = await AsAsync(Editor, service => service.CreateAsync(new SiteSettings { SiteName = "Mine" }, Token)); + + (await AsAsync(Editor, service => service.GetAsync(created.Id, Draft, Token)))!.SiteName.ShouldBe("Mine"); + } + + [Fact] + public async Task TheSystemFieldsOfAClassAreIgnoredOnAWrite() + { + var forged = Guid.CreateVersion7(); + var created = await AsAsync(Editor, service => service.CreateAsync( + new Tag + { + Name = "Net", + Id = forged, + CreatedBy = forged, + UpdatedBy = forged, + Version = 50, + Status = EntryStatus.Published, + CreatedAt = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero), + PublishedAt = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero), + }, + Token)); + + created.Id.ShouldNotBe(forged); + created.CreatedBy.ShouldBe(Editor.Id); + created.UpdatedBy.ShouldBe(Editor.Id); + created.Version.ShouldBe(1); + created.Status.ShouldBe(EntryStatus.Draft); + created.CreatedAt.Year.ShouldNotBe(2000); + created.PublishedAt.ShouldBeNull(); + + created.CreatedBy = forged; + created.Version = 1; + var updated = await AsAsync(Editor, service => service.UpdateAsync(created.Id, created, 1, Token)); + updated.CreatedBy.ShouldBe(Editor.Id); + } + + [Fact] + public async Task AWriterWithAnAllowListCanSendBackTheClassItRead() + { + var created = await AsAsync(Editor, service => service.CreateAsync(new Article { Title = "Hello", VideoUrl = "https://example.com/v", Category = ArticleCategory.Opinion }, Token)); + var role = _database.Database.AddRoleForTest("Titles", ("content.article.read", null), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + + var read = (await AsAsync(WithRole(role), service => service.GetAsync(created.Id, Draft, Token)))!; + read.Title = "Changed"; + var saved = await AsAsync(WithRole(role), service => service.UpdateAsync(created.Id, read, read.Version, Token)); + + saved.Title.ShouldBe("Changed"); + saved.Category.ShouldBe(ArticleCategory.Opinion); + saved.VideoUrl.ShouldBe("https://example.com/v"); + + read.Version = saved.Version; + read.Category = ArticleCategory.Video; + (await Should.ThrowAsync(() => AsAsync(WithRole(role), service => service.UpdateAsync(created.Id, read, read.Version, Token)))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("category"); + } + + [Fact] + public async Task AClassThatCannotHoldTheFormIsRefused() + { + (await Should.ThrowAsync(() => AsAsync(Editor, service => service.GetAsync(Guid.CreateVersion7(), new GetOptions { Locale = "all" }, Token)))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("locale"); + (await Should.ThrowAsync(() => AsAsync>(Editor, service => service.QueryAsync(new ContentQuery { RichTextFormat = RichTextFormats.Html }, Token)))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("richText"); + } + + [Fact] + public async Task AClassOutsideTheSchemaFailsAtOnce() + { + await Should.ThrowAsync(() => _database.AsAsync( + Editor, + services => Task.FromResult(services.GetRequiredService>()))); + } + + private sealed class NotAType : ContentItem + { + } +} diff --git a/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs b/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs index 4bc04e17a..96518d8bd 100644 --- a/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs +++ b/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs @@ -1,4 +1,5 @@ using FluentCMS.Content; +using FluentCMS.Core.Authorization; using FluentCMS.Core.Content.Query; using FluentCMS.Core.DependencyInjection; using FluentCMS.Core.Storage; @@ -222,6 +223,34 @@ public void Delete(Guid id) Context.Rows("cms_article_i18n").Where(row => EF.Property(row, "document_id") == id).ExecuteDelete(); } + public Guid AddRoleForTest(string name, params (string Permission, PermissionConstraints? Constraints)[] grants) => + AddRoleForTest(name, null, grants); + + public Guid AddRoleForTest(string name, Guid? site, params (string Permission, PermissionConstraints? Constraints)[] grants) + { + using var scope = NewScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var role = new Role { Name = name, SiteId = site }; + context.Add(role); + context.SaveChanges(); + + foreach (var (permission, constraints) in grants) + { + context.Add(new RoleGrant { RoleId = role.Id, Permission = permission, Constraints = constraints?.ToJson() }); + } + + context.SaveChanges(); + return role.Id; + } + + public void GrantForTest(Guid role, string permission) + { + using var scope = NewScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Add(new RoleGrant { RoleId = role, Permission = permission }); + context.SaveChanges(); + } + public Task RunAsync(string queryString, IContentQueryAccess? access = null, ContentQueryOptions? options = null, string type = "article") => Engine(access, options).ExecuteAsync(type, BracketQueryParser.Parse(queryString), TestContext.Current.CancellationToken); diff --git a/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs b/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs index 81c0e67c5..f9362161f 100644 --- a/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs +++ b/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs @@ -55,7 +55,7 @@ public void CodesAreDistinctAndInTheContentValidationGroup() .Select(field => (string)field.GetRawConstantValue()!) .ToList(); - codes.Count.ShouldBe(10); + codes.Count.ShouldBe(11); codes.Distinct().Count().ShouldBe(codes.Count); codes.ShouldAllBe(code => code.StartsWith("FCMS21", StringComparison.Ordinal) && code.Length == 8); } From 5b27d5f2d28ba3b3f6f37d7b9f0433274e0affd4 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 15:24:42 -0500 Subject: [PATCH 2/4] Close the review findings on the content service --- .../Authorization/AuthorizationEvaluator.cs | 6 +- .../Authorization/EffectivePermissions.cs | 12 +- .../Content/ContentEntryMapper.cs | 26 +- src/FluentCMS.Core/Content/ContentLog.cs | 9 + src/FluentCMS.Core/Content/ContentService.cs | 309 ++++++++++++--- .../Content/EntryCanonicalizer.cs | 32 +- src/FluentCMS.Core/Content/EntryChange.cs | 7 +- src/FluentCMS.Core/Content/EntryComposer.cs | 133 +++++-- src/FluentCMS.Core/Content/EntryErrors.cs | 117 ++++++ src/FluentCMS.Core/Content/EntryJson.cs | 19 + .../Content/Hooks/ContentCopy.cs | 1 + .../Content/TypedContentService.cs | 16 +- src/FluentCMS.Sdk/Content/IContentService.cs | 7 +- src/FluentCMS.Sdk/Schema/ContentItem.cs | 7 +- .../ContentTableReaches.cs | 71 ++++ .../FluentCMS.Architecture.Tests.csproj | 6 + .../LayeringRulesTests.cs | 59 ++- .../Content/ContentServiceCarryTests.cs | 292 +++++++++++++++ .../Content/ContentServiceLimitsTests.cs | 353 ++++++++++++++++++ .../Content/ContentServiceReadTests.cs | 16 +- .../Content/ContentServiceTransactionTests.cs | 210 +++++++++++ .../Content/ContentServiceWriteTests.cs | 16 +- .../Schema/ContentItemTests.cs | 4 +- 23 files changed, 1577 insertions(+), 151 deletions(-) create mode 100644 src/FluentCMS.Core/Content/ContentLog.cs create mode 100644 src/FluentCMS.Core/Content/EntryErrors.cs create mode 100644 tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceTransactionTests.cs diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index 430aab5bc..f037b2c5a 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -113,13 +113,15 @@ public async ValueTask WritableFieldsAsync( } } - if (grant.Constraints.Fields is null) + var reachesShared = !type.Localized || grant.ReachesEveryLocale(target); + if (grant.Constraints.Fields is null && reachesShared) { return WritableFields.All; } allowed = true; - fields.UnionWith(grant.Constraints.Fields); + var names = grant.Constraints.Fields ?? type.Fields.Select(field => field.Name); + fields.UnionWith(reachesShared ? names : names.Where(name => type.FindField(name) is not { Localizable: false })); } return allowed ? new WritableFields(true, fields) : WritableFields.None; diff --git a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs index 74e4dd32c..5c035ae83 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -7,8 +7,9 @@ namespace FluentCMS.Core.Authorization; // Where an operation applies. A null site is every site, so only a grant with no site limit holds for it; an empty // locale is no locale, so only a grant with no locale limit holds for it. RowLocale is the row a row rule reads when -// that is not Locale's: a write that adds a locale to an entry has no row of its own there yet. -internal readonly record struct AccessTarget(SiteId? Site, string? Locale, string? RowLocale = null); +// that is not Locale's: a write that adds a locale to an entry has no row of its own there yet. EntryLocales is every +// locale the entry has or is about to have, for the fields they share; null means Locale alone. +internal readonly record struct AccessTarget(SiteId? Site, string? Locale, string? RowLocale = null, IReadOnlyCollection? EntryLocales = null); // One row of cms_role_permission, with the site of its role: a role of one site grants nothing in another. internal sealed record EffectiveGrant(string Pattern, PermissionConstraints Constraints, Guid? RoleSite) @@ -18,6 +19,13 @@ public bool Applies(string permission, AccessTarget target) => && (RoleSite is null || target.Site?.Value == RoleSite) && (Constraints.Sites is null || (target.Site is { } site && Constraints.Sites.Contains(site))) && (Constraints.Locales is null || (!string.IsNullOrEmpty(target.Locale) && Constraints.Locales.Contains(target.Locale, StringComparer.OrdinalIgnoreCase))); + + // A field that is not localizable has one value for all the locales of an entry, so a grant limited to some of them + // may change it only when that covers them all. + public bool ReachesEveryLocale(AccessTarget target) => + Constraints.Locales is not { } locales + || target.EntryLocales is null + || target.EntryLocales.All(locale => locales.Contains(locale, StringComparer.OrdinalIgnoreCase)); } // The answers of spec 2.3 for one principal: the union of its roles' grants, narrowed by its token's scopes. Scopes diff --git a/src/FluentCMS.Core/Content/ContentEntryMapper.cs b/src/FluentCMS.Core/Content/ContentEntryMapper.cs index 715af3f5d..02fa0472f 100644 --- a/src/FluentCMS.Core/Content/ContentEntryMapper.cs +++ b/src/FluentCMS.Core/Content/ContentEntryMapper.cs @@ -54,10 +54,15 @@ public void CopyTo(object entry, ContentDocument document) } // The document of a typed write: a property with no value is not named, because a class cannot leave a field out. - public ContentDocument ToDocument(object entry) + // Nor is one that include turns down, at the top level. A hidden property holds nothing a read gave it, and a + // read-only one is not the client's to send, at any level; only the system principal sends a read-only value. + public ContentDocument ToDocument(object entry, Func include, bool isSystem) { ArgumentNullException.ThrowIfNull(entry); - return ToFields(entry); + ArgumentNullException.ThrowIfNull(include); + + bool Inside(SchemaField field) => !field.Hidden && (isSystem || !field.ReadOnly); + return ToFields(entry, field => Inside(field) && include(field), Inside); } // What validation reads: nested objects as documents and each value as the property of its class declares it, where @@ -424,7 +429,7 @@ private static bool TryConvertScalar(object raw, Type target, out object? result } } - private ContentDocument ToFields(object instance) + private ContentDocument ToFields(object instance, Func? include, Func? inside) { ContentDocument fields = []; var type = instance.GetType(); @@ -435,7 +440,12 @@ private ContentDocument ToFields(object instance) foreach (var (field, property) in MapOf(type).Fields) { - var value = ToApi(property.GetValue(instance)); + if (include?.Invoke(field) == false) + { + continue; + } + + var value = ToApi(property.GetValue(instance), inside); if (!IsEmpty(value)) { fields[field.Name] = value; @@ -454,7 +464,7 @@ private ContentDocument ToFields(object instance) _ => false, }; - private object? ToApi(object? value) + private object? ToApi(object? value, Func? inside = null) { switch (value) { @@ -467,7 +477,7 @@ private ContentDocument ToFields(object instance) case MediaRefList medias: return medias.Where(media => !media.IsEmpty).Select(media => media.Id).ToArray(); case BlockList blocks: - return blocks.Select(block => (object?)ToFields(block)).ToList(); + return blocks.Select(block => (object?)ToFields(block, inside, inside)).ToList(); case not null when RawValue.IsNumber(value): return value; } @@ -485,10 +495,10 @@ private ContentDocument ToFields(object instance) if (Attribute.IsDefined(type, typeof(ComponentAttribute)) || Attribute.IsDefined(type, typeof(BlockAttribute))) { - return ToFields(value); + return ToFields(value, inside, inside); } - return value is IEnumerable items and not IEnumerable> ? items.Cast().Select(ToApi).ToList() : value; + return value is IEnumerable items and not IEnumerable> ? items.Cast().Select(item => ToApi(item, inside)).ToList() : value; } private static List ItemsOf(object? value) => value switch diff --git a/src/FluentCMS.Core/Content/ContentLog.cs b/src/FluentCMS.Core/Content/ContentLog.cs new file mode 100644 index 000000000..e469a8397 --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentLog.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Content; + +internal static partial class ContentLog +{ + [LoggerMessage(Level = LogLevel.Error, Message = "An entry change sink failed for {Kind} of {Type} {Id} in locale '{Locale}'; the write stands.")] + public static partial void SinkFailed(ILogger logger, Exception exception, EntryChangeKind kind, string type, Guid id, string locale); +} diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index 0904848db..ca436aea5 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Globalization; using System.Text.Json.Nodes; using FluentCMS.Content; using FluentCMS.Core.Authorization; @@ -13,6 +13,8 @@ using FluentCMS.Schema; using FluentCMS.Sites; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; namespace FluentCMS.Core.Content; @@ -35,9 +37,12 @@ internal sealed class ContentService( EntryComposer composer, ContentEntryMapper mapper, ResponseRichText richText, - IEnumerable sinks) : IContentService + IEnumerable sinks, + ILogger logger) : IContentService { private readonly List _pending = []; + private bool _ownsTransaction; + private int _savepoints; public Task GetAsync(string type, Guid id, GetOptions? options = null, CancellationToken cancellationToken = default) => GetAsync(type, id, options, everySystemField: false, cancellationToken); @@ -104,8 +109,9 @@ public async Task CreateAsync(string type, ContentDocument entr var row = await InTransactionAsync( async () => { + var sent = WithoutFallbacks(entry, isPatch: false); var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, null, write.Target, cancellationToken); - Reject(write, entry, writable); + Reject(write, sent, writable); if (write.Type.Kind == SchemaTypeKind.Singleton && await store.AnyEntryAsync(write.Type, write.Site, cancellationToken)) { @@ -126,7 +132,7 @@ public async Task CreateAsync(string type, ContentDocument entr UpdatedBy = write.Operation.Principal.Id, }; - ContentDocument composed = new(entry); + ContentDocument composed = new(sent); foreach (var field in write.Type.Fields) { if (field.DefaultValue is { } value && !composed.ContainsKey(field.Name)) @@ -135,19 +141,25 @@ public async Task CreateAsync(string type, ContentDocument entr } } - var working = Prepare(write, composed, document); + var working = Prepare(write, composed, document, null); working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, null, writable, document, cancellationToken); store.AddDocument(document); await store.WriteDraftAsync(write.Type, document, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); + // A row rule on a create grant has no entry to read until now. + if (!await evaluator.CanAccessEntryAsync(write.Operation, write.Type, ContentOperation.Create, id, write.Target, QueryStatus.Draft, cancellationToken)) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Create)); + } + await hooks.RunAsync(HookKind.AfterCreate, write.Type, write.Operation, working, null, cancellationToken); _pending.Add(new EntryChange(EntryChangeKind.DraftSaved, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); return document; }, cancellationToken); - return await RespondAsync(write, row, cancellationToken); + return await RespondAsync(write, row); } public Task UpdateAsync(string type, Guid id, ContentDocument entry, int expectedVersion, CancellationToken cancellationToken = default) @@ -174,14 +186,14 @@ public async Task DeleteAsync(string type, Guid id, CancellationToken cancellati await InTransactionAsync( async () => { - var (row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Delete, id, trashed: false, cancellationToken); + var (documents, row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Delete, id, trashed: false, cancellationToken); var working = ContentCopy.Of(stored); - await hooks.RunAsync(HookKind.BeforeDelete, write.Type, write.Operation, working, stored, cancellationToken); + await hooks.RunAsync(HookKind.BeforeDelete, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); await store.SetTrashedAsync(id, write.Operation.Timestamp, write.Operation.Principal.Id, cancellationToken); - await hooks.RunAsync(HookKind.AfterDelete, write.Type, write.Operation, working, stored, cancellationToken); + await hooks.RunAsync(HookKind.AfterDelete, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); - _pending.Add(new EntryChange(EntryChangeKind.Trashed, write.Type.Name, id, row.Locale, row.DraftVersion, write.Operation)); + _pending.AddRange(documents.Select(document => new EntryChange(EntryChangeKind.Trashed, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation))); return row; }, cancellationToken); @@ -195,19 +207,19 @@ public async Task RestoreAsync(string type, Guid id, Cancellati var restored = await InTransactionAsync( async () => { - var (row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Restore, id, trashed: true, cancellationToken); + var (documents, row, stored) = await ReachAllLocalesAsync(write, ContentOperation.Restore, id, trashed: true, cancellationToken); var working = ContentCopy.Of(stored); - await hooks.RunAsync(HookKind.BeforeRestore, write.Type, write.Operation, working, stored, cancellationToken); + await hooks.RunAsync(HookKind.BeforeRestore, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); await store.SetTrashedAsync(id, null, null, cancellationToken); - await hooks.RunAsync(HookKind.AfterRestore, write.Type, write.Operation, working, stored, cancellationToken); + await hooks.RunAsync(HookKind.AfterRestore, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); - _pending.Add(new EntryChange(EntryChangeKind.Restored, write.Type.Name, id, row.Locale, row.DraftVersion, write.Operation)); + _pending.AddRange(documents.Select(document => new EntryChange(EntryChangeKind.Restored, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation))); return row; }, cancellationToken); - return await RespondAsync(write, restored, cancellationToken); + return await RespondAsync(write, restored); } // No ambient operation is not an anonymous one: it is a bug in the entry point, and nothing runs. A client's origin @@ -233,7 +245,14 @@ private async Task> ReadAsync(string typeName, Cont { richText.ThrowIfUnknown(query.RichTextFormat); + // The entry point resolves the site before the operation (spec 4.6), and an API key's binding comes first, so a + // query cannot name another one. The grants are read against the site named, which is why this comes first. var operation = Current(); + if (query.Site is { } asked && asked != operation.Site && !operation.Principal.IsSystem) + { + throw new ValidationException([new ValidationError("site", QueryErrorCodes.InvalidParameter, "A query reads the site of its operation.")]); + } + var site = query.Site ?? operation.Site; var locale = await ResolveLocaleAsync(site, query.Locale ?? operation.Locale, cancellationToken); var everyLocale = locale == ILocaleResolver.All; @@ -340,6 +359,18 @@ private async Task> ReadEveryLocaleAsync(SchemaType type, return items; } + // What a typed write may name: a class holds a default for whatever the principal could not read. + internal async Task<(Func CanRead, bool IsSystem)> TypedWriteLimitsAsync(string typeName, CancellationToken cancellationToken) + { + var operation = Current(); + var type = schema.FindContentType(typeName) + ?? throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{typeName}' is not a content type.")]); + + var target = new AccessTarget(operation.Site, await ResolveLocaleAsync(operation.Site, operation.Locale, cancellationToken)); + var permissions = await evaluator.ResolveAsync(operation, cancellationToken); + return (field => permissions.CanReadField(type, field.Name, target), permissions.IsSystem); + } + private async Task BeginAsync(string typeName, ContentOperation operationKind, CancellationToken cancellationToken) { var operation = Current(); @@ -362,27 +393,31 @@ private async Task BeginAsync(string typeName, ContentOperation oper return new WriteScope(operation, type, operation.Site, locale, target, permissions, new FieldAccess(permissions, schema)); } - private async Task InTransactionAsync(Func> work, CancellationToken cancellationToken) + // Runs writes as one transaction and tells the sink what committed. A write inside another transaction takes a + // savepoint, so that one that fails leaves nothing behind for that transaction to commit. Internal for #2703. + internal async Task InTransactionAsync(Func> work, CancellationToken cancellationToken) { - if (context.Database.CurrentTransaction is not null) + if (context.Database.CurrentTransaction is { } outer) { - return await work(); + return await InSavepointAsync(outer, work, cancellationToken); } // On SQLite this takes the write lock at once, so a second writer waits here and then sees the first one's version. var committed = false; T result; + _ownsTransaction = true; var transaction = await context.Database.BeginTransactionAsync(cancellationToken); await using (transaction) { try { result = await work(); - await transaction.CommitAsync(cancellationToken); + await transaction.CommitAsync(CancellationToken.None); committed = true; } finally { + _ownsTransaction = false; if (!committed) { context.ChangeTracker.Clear(); @@ -391,17 +426,69 @@ private async Task InTransactionAsync(Func> work, CancellationToke } } + await PublishCommittedAsync(); + return result; + } + + private async Task InSavepointAsync(IDbContextTransaction outer, Func> work, CancellationToken cancellationToken) + { + var pending = _pending.Count; + var savepoint = string.Create(CultureInfo.InvariantCulture, $"cms_content_{++_savepoints}"); + if (outer.SupportsSavepoints) + { + await outer.CreateSavepointAsync(savepoint, cancellationToken); + } + + T result; + try + { + result = await work(); + } + catch + { + context.ChangeTracker.Clear(); + _pending.RemoveRange(pending, _pending.Count - pending); + if (outer.SupportsSavepoints) + { + await outer.RollbackToSavepointAsync(savepoint, CancellationToken.None); + } + + throw; + } + + if (outer.SupportsSavepoints) + { + await outer.ReleaseSavepointAsync(savepoint, CancellationToken.None); + } + + // The end of a transaction someone else began cannot be seen, and an entry that may yet roll back is never told. + if (!_ownsTransaction) + { + _pending.RemoveRange(pending, _pending.Count - pending); + } + + return result; + } + + // The write has committed: neither a failing sink nor a cancelled caller may turn it into a failure (spec 4.11). + private async Task PublishCommittedAsync() + { List changes = [.. _pending]; _pending.Clear(); foreach (var change in changes) { foreach (var sink in sinks) { - await sink.PublishAsync(change, cancellationToken); + try + { + await sink.PublishAsync(change, CancellationToken.None); + } + catch (Exception exception) + { + ContentLog.SinkFailed(logger, exception, change.Kind, change.Type, change.Id, change.Locale); + } } } - - return result; } private async Task SaveAsync(string typeName, Guid id, ContentDocument changes, int expectedVersion, bool isPatch, CancellationToken cancellationToken) @@ -421,6 +508,7 @@ private async Task SaveAsync(string typeName, Guid id, ContentD var existing = documents.Find(item => item.Locale == write.DocumentLocale); var addsLocale = existing is null; var target = addsLocale ? write.Target with { RowLocale = documents[0].Locale } : write.Target; + target = target with { EntryLocales = [.. documents.Select(item => item.Locale).Append(write.DocumentLocale).Distinct(StringComparer.OrdinalIgnoreCase)] }; if (!await evaluator.CanAccessEntryAsync(write.Operation, type, ContentOperation.Update, id, target, QueryStatus.Draft, cancellationToken)) { @@ -448,14 +536,22 @@ private async Task SaveAsync(string typeName, Guid id, ContentD var writable = await evaluator.WritableFieldsAsync(write.Operation, type, ContentOperation.Update, id, target, cancellationToken); var stored = await store.ReadAsync(type, document, ContentState.Draft, cancellationToken); - var refused = isPatch ? changes : WithoutUnchanged(write, changes, stored, writable); + var sent = WithoutFallbacks(changes, isPatch); + + // A full update answers what a read showed, so what that read left out is not the client's to change. + var shown = isPatch ? null : await ReadDraftOrNullAsync(write, id, addsLocale ? documents[0].Locale : write.Locale, cancellationToken); + var refused = isPatch ? sent : WithoutUnchanged(write, sent, shown, writable, addsLocale); Reject(write, refused, writable); var composed = isPatch - ? composer.Patch(type, stored, changes) + ? composer.Patch(type, stored, sent) : EntryComposer.Replace(type, stored, refused, field => Keeps(write, field, writable, addsLocale)); - var working = Prepare(write, composed, document); - composer.CarryHidden(type, stored, working, includingGroups: !isPatch); + var working = Prepare(write, composed, document, stored); + composer.CarryProtected(type, stored, working, includingGroups: !isPatch || !write.Permissions.IsSystem); + if (!isPatch) + { + KeepUnreadableTargets(type, stored, shown, working); + } working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, stored, writable, document, cancellationToken); @@ -492,29 +588,32 @@ private async Task SaveAsync(string typeName, Guid id, ContentD } EntryStore.ApplySystem(working, document); - await hooks.RunAsync(HookKind.AfterUpdate, type, write.Operation, working, stored, cancellationToken); + await hooks.RunAsync(HookKind.AfterUpdate, type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); _pending.Add(new EntryChange(EntryChangeKind.DraftSaved, type.Name, id, document.Locale, document.DraftVersion, write.Operation)); return document; }, cancellationToken); - return await RespondAsync(write, row, cancellationToken); + return await RespondAsync(write, row); } // A full update sends what a read gave it: a field the principal can see but not change is no write as long as it - // is the same. Not for what it cannot see, because the answer would tell it the value: so the comparison leaves out - // hidden values inside the field, and a value that names one is never "the same". - private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument changes, ContentDocument stored, WritableFields writable) + // is the same. The comparison is with what that read showed, on this row and with the relation targets it may read, + // so the answer tells it nothing it could not read anyway. A value that names a hidden field is never the same. + private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument changes, ContentDocument? shown, WritableFields writable, bool addsLocale) { ContentDocument result = []; foreach (var (key, value) in changes) { - var unchanged = write.Type.FindField(key) is { Hidden: false } field + var unchanged = shown is not null + && write.Type.FindField(key) is { Hidden: false } field && (field.ReadOnly || !writable.Includes(key)) - && write.Permissions.CanReadField(write.Type, key, write.Target) + && (!addsLocale || !field.Localizable) + && shown.TryGetValue(key, out var visible) && write.Access.IsClean(field, value, write.Type) && canonicalizer.TryRead(field, value, out var read) - && JsonNode.DeepEquals(Visible(field, read), Visible(field, stored[key])); + && canonicalizer.TryRead(field, visible, out var seen) + && JsonNode.DeepEquals(Visible(field, read), Visible(field, seen)); if (!unchanged) { @@ -525,6 +624,68 @@ private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument chang return result; } + // What a read gave comes back with the targets it may not read missing. They are not the client's to remove: a + // multiple relation keeps them after what was sent, and a single one sent empty keeps its target. A patch names + // what it changes, so an explicit null there still clears. + private void KeepUnreadableTargets(SchemaType type, ContentDocument stored, ContentDocument? shown, ContentDocument working) + { + ContentDocument seen = []; + foreach (var field in type.Fields) + { + if (shown is not null && shown.TryGetValue(field.Name, out var visible) && canonicalizer.TryRead(field, visible, out var read)) + { + seen[field.Name] = read; + } + } + + foreach (var item in FieldStorage.Of(schema, type).Where(item => item.Kind == FieldStorageKind.Reference)) + { + var kept = IdsOf(EntryPaths.ValueAt(stored, item, type)).Except(IdsOf(EntryPaths.ValueAt(seen, item, type))).ToList(); + if (kept.Count == 0) + { + continue; + } + + var sent = IdsOf(EntryPaths.ValueAt(working, item, type)); + if (item.Field.Multiple) + { + EntryPaths.SetAt(working, item, type, sent.Concat(kept.Except(sent)).ToArray()); + } + else if (sent.Count == 0) + { + EntryPaths.SetAt(working, item, type, kept[0]); + } + } + } + + private static List IdsOf(object? value) => value switch + { + Guid id => [id], + Guid[] ids => [.. ids], + _ => [], + }; + + // What a read adds is no value to write. On a full update, a field it names came from another locale, so it was not sent. + private static ContentDocument WithoutFallbacks(ContentDocument changes, bool isPatch) + { + if (!changes.TryGetValue(LocaleFallbackReader.FallbacksKey, out var named)) + { + return changes; + } + + var result = new ContentDocument(changes); + result.Remove(LocaleFallbackReader.FallbacksKey); + if (!isPatch && EntryCanonicalizer.TryReadMap(named, out var map) && map is not null) + { + foreach (var name in map.Keys) + { + result.Remove(name); + } + } + + return result; + } + private JsonNode? Visible(SchemaField field, object? value) { var node = EntryJson.ToNode(value); @@ -552,9 +713,14 @@ private static void Reject(WriteScope write, ContentDocument changes, WritableFi } } - private ContentDocument Prepare(WriteScope write, ContentDocument composed, Document document) + // A hidden field shows to no one, and the rest as the principal's grants say. A nested source goes with the field + // that holds it, since an allow-list names top-level fields. + private static bool MayDerive(WriteScope write, SchemaType owner, SchemaField source) => + !source.Hidden && (owner != write.Type || write.Permissions.CanReadField(write.Type, source.Name, write.Target)); + + private ContentDocument Prepare(WriteScope write, ContentDocument composed, Document document, ContentDocument? stored) { - var working = canonicalizer.Canonicalize(write.Type, composed); + var working = canonicalizer.Canonicalize(write.Type, composed, (owner, source) => MayDerive(write, owner, source), stored); ContentDocument withSystem = []; EntryStore.ApplySystem(withSystem, document); foreach (var (key, value) in working) @@ -570,55 +736,77 @@ private ContentDocument Prepare(WriteScope write, ContentDocument composed, Docu // Validation runs on what hooks left, and again if BeforeCreate or BeforeUpdate changed it, so that a hook cannot get // a value past it. A hook is held to the principal's field limits as well, except on a hidden or read-only field, - // which no client can reach and which is what a hook is for. - private async Task RunBeforeHooksAsync(WriteScope write, HookKind before, ContentDocument working, ContentDocument? previous, WritableFields writable, Document document, CancellationToken cancellationToken) + // which no client can reach and which is what a hook is for. Hooks get a copy of the stored state, so they cannot + // change what the write compares with. + private async Task RunBeforeHooksAsync(WriteScope write, HookKind before, ContentDocument working, ContentDocument? stored, WritableFields writable, Document document, CancellationToken cancellationToken) { - var baseline = EntryJson.Fields(write.Type, working); - await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, previous, cancellationToken); - working = AfterHook(write, working, baseline, writable, document); - await ValidateAsync(write, working, document.DocumentId, cancellationToken); + var sent = EntryJson.Fields(write.Type, working); + var was = stored is null ? null : EntryJson.Fields(write.Type, stored); + + await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, CopyOf(stored), cancellationToken); + working = AfterHook(write, working, sent, writable, document, stored); + await ValidateAsync(write, working, document.DocumentId, was, sent, cancellationToken); var validated = EntryJson.Fields(write.Type, working); - await hooks.RunAsync(before, write.Type, write.Operation, working, previous, cancellationToken); - working = AfterHook(write, working, validated, writable, document); + await hooks.RunAsync(before, write.Type, write.Operation, working, CopyOf(stored), cancellationToken); + working = AfterHook(write, working, validated, writable, document, stored); if (!JsonNode.DeepEquals(validated, EntryJson.Fields(write.Type, working))) { - await ValidateAsync(write, working, document.DocumentId, cancellationToken); + await ValidateAsync(write, working, document.DocumentId, was, sent, cancellationToken); } return working; } - private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, WritableFields writable, Document document) + private static ContentDocument? CopyOf(ContentDocument? stored) => stored is null ? null : ContentCopy.Of(stored); + + private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, WritableFields writable, Document document, ContentDocument? stored) { - var after = Prepare(write, working, document); + var after = Prepare(write, working, document, stored); var fields = EntryJson.Fields(write.Type, after); write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(baseline[name], fields[name])), write.Type, writable); return after; } - private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid id, CancellationToken cancellationToken) + private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid id, JsonObject? was, JsonObject sent, CancellationToken cancellationToken) { var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, working) : null; + var values = mapper.ValidationValues(write.Type, working); + var checkedUnique = false; var result = await validation.ValidateAsync( write.Type, - mapper.ValidationValues(write.Type, working), + values, entry, write.Operation, ValidationMode.Draft, cancellationToken, - (context, token) => new ValueTask(store.CheckUniqueAsync(write.Type, working, id, write.Site, write.Locale, context, token))); + (context, token) => + { + checkedUnique = true; + return new ValueTask(store.CheckUniqueAsync(write.Type, working, id, write.Site, write.Locale, context, token)); + }); + + var now = EntryJson.Fields(write.Type, working); + var errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Reportable(result.Errors, now, was, sent); + + // The pipeline skips uniqueness after an error, and a dropped error is no reason to. + if (errors.Count == 0 && !checkedUnique) + { + var context = new ValidationContext(write.Type, values, write.Operation, ValidationMode.Draft, entry); + await store.CheckUniqueAsync(write.Type, working, id, write.Site, write.Locale, context, cancellationToken); + errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Reportable(context.Errors, now, was, sent); + } - if (!result.IsValid) + if (errors.Count > 0) { - throw new ValidationException(result.Errors); + throw new ValidationException(errors); } } // One entry is one document: it goes to the trash and comes back in every locale, so the principal must hold the // operation on all of them. No row that it may reach reads as no entry, which keeps a client from probing. - private async Task<(Document Row, ContentDocument Stored)> ReachAllLocalesAsync(WriteScope write, ContentOperation operation, Guid id, bool trashed, CancellationToken cancellationToken) + private async Task<(List Documents, Document Row, ContentDocument Stored)> ReachAllLocalesAsync(WriteScope write, ContentOperation operation, Guid id, bool trashed, CancellationToken cancellationToken) { var documents = await store.DocumentsAsync(write.Type, id, write.Site, trashed, cancellationToken); var reached = 0; @@ -642,22 +830,23 @@ private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid } var row = documents.Find(document => document.Locale == write.DocumentLocale) ?? documents[0]; - return (row, await store.ReadAsync(write.Type, row, ContentState.Draft, cancellationToken)); + return (documents, row, await store.ReadAsync(write.Type, row, ContentState.Draft, cancellationToken)); } private async Task ConflictAsync(WriteScope write, Guid id, int expected, int current, CancellationToken cancellationToken) => - new(expected, current, await ReadDraftOrNullAsync(write, id, cancellationToken)); + new(expected, current, await ReadDraftOrNullAsync(write, id, write.Locale, cancellationToken)); - // A principal may hold a write without the read. It still gets the id and the version, which it needs for its next write. - private async Task RespondAsync(WriteScope write, Document row, CancellationToken cancellationToken) => - await ReadDraftOrNullAsync(write, row.DocumentId, cancellationToken) + // A principal may hold a write without the read. It still gets the id and the version, which it needs for its next + // write. The write has committed by now, so a cancelled caller does not turn it into a failure. + private async Task RespondAsync(WriteScope write, Document row) => + await ReadDraftOrNullAsync(write, row.DocumentId, write.Locale, CancellationToken.None) ?? new ContentDocument { ["id"] = row.DocumentId, ["version"] = row.DraftVersion }; - private async Task ReadDraftOrNullAsync(WriteScope write, Guid id, CancellationToken cancellationToken) + private async Task ReadDraftOrNullAsync(WriteScope write, Guid id, string locale, CancellationToken cancellationToken) { try { - return await GetAsync(write.Type.Name, id, new GetOptions { Status = QueryStatus.Draft, Locale = write.Locale, Fallback = LocaleFallback.None }, everySystemField: false, cancellationToken); + return await GetAsync(write.Type.Name, id, new GetOptions { Status = QueryStatus.Draft, Locale = string.IsNullOrEmpty(locale) ? write.Locale : locale, Fallback = LocaleFallback.None }, everySystemField: false, cancellationToken); } catch (ValidationException exception) when (exception.Errors.All(error => error.Path is "type" or "status")) { diff --git a/src/FluentCMS.Core/Content/EntryCanonicalizer.cs b/src/FluentCMS.Core/Content/EntryCanonicalizer.cs index 8816ad297..ee97b9295 100644 --- a/src/FluentCMS.Core/Content/EntryCanonicalizer.cs +++ b/src/FluentCMS.Core/Content/EntryCanonicalizer.cs @@ -21,7 +21,9 @@ internal sealed class EntryCanonicalizer(SchemaModel schema) private const string IdKey = "id"; private const int SlugLength = 200; - public ContentDocument Canonicalize(SchemaType type, ContentDocument entry) + // mayDerive says whether a slug may be made from a source field. Where it may not, the slug keeps what stored holds: + // a slug shows its source to whoever reads it, so it must not be how a principal learns what it may not read. + public ContentDocument Canonicalize(SchemaType type, ContentDocument entry, Func mayDerive, ContentDocument? stored = null) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(entry); @@ -42,7 +44,7 @@ public ContentDocument Canonicalize(SchemaType type, ContentDocument entry) try { - ReadFields(type, entry, result, "", flattened: false, errors); + ReadFields(type, entry, result, "", flattened: false, errors, mayDerive, stored); EntryJson.ThrowIfUnreadable(EntryJson.Fields(type, result)); } catch (ArgumentException exception) @@ -58,33 +60,37 @@ public ContentDocument Canonicalize(SchemaType type, ContentDocument entry) public bool TryRead(SchemaField field, object? raw, out object? value) { List errors = []; - value = Top(field, raw, field.Name, flattened: false, errors); + value = Top(field, raw, field.Name, flattened: false, errors, NeverDerive, null); return errors.Count == 0; } - private void ReadFields(SchemaType owner, IEnumerable> values, ContentDocument into, string prefix, bool flattened, List errors) + private static bool NeverDerive(SchemaType owner, SchemaField source) => false; + + private void ReadFields(SchemaType owner, IEnumerable> values, ContentDocument into, string prefix, bool flattened, List errors, Func mayDerive, ContentDocument? stored) { var known = values as ContentDocument ?? new ContentDocument(values); foreach (var field in owner.Fields) { known.TryGetValue(field.Name, out var raw); - into[field.Name] = Top(field, raw, prefix + field.Name, flattened, errors); + into[field.Name] = Top(field, raw, prefix + field.Name, flattened, errors, mayDerive, stored?.GetValueOrDefault(field.Name) as ContentDocument); } // After the loop, so that the source has its value whichever comes first. foreach (var field in owner.Fields.Where(field => field.FieldType == FieldTypeNames.Slug)) { - if (into[field.Name] is null or "" && Source(field, into) is { } source) + if (into[field.Name] is null or "" && Source(owner, field) is { } source && into.GetValueOrDefault(source.Name) is string text) { - into[field.Name] = SlugFieldType.Slugify(source, field.MaxLength ?? SlugLength); + into[field.Name] = mayDerive(owner, source) + ? SlugFieldType.Slugify(text, field.MaxLength ?? SlugLength) + : stored?.GetValueOrDefault(field.Name) ?? into[field.Name]; } } } - private static string? Source(SchemaField field, ContentDocument values) => - field.Settings.TryGetValue(FieldSettings.Source, out var name) && name is string source && values.TryGetValue(source, out var value) ? value as string : null; + private static SchemaField? Source(SchemaType owner, SchemaField field) => + field.Settings.TryGetValue(FieldSettings.Source, out var name) && name is string source ? owner.FindField(source) : null; - private object? Top(SchemaField field, object? raw, string path, bool flattened, List errors) + private object? Top(SchemaField field, object? raw, string path, bool flattened, List errors, Func mayDerive, ContentDocument? storedGroup) { var kind = schema.FieldTypes.Find(field.FieldType)!.StorageKind(field); if (kind == FieldStorageKind.Reference) @@ -94,7 +100,7 @@ private void ReadFields(SchemaType owner, IEnumerable errors) + private ContentDocument? ReadGroup(SchemaField field, object? raw, string path, List errors, Func mayDerive, ContentDocument? stored) { if (schema.FindComponent((string)field.Settings[FieldSettings.Component]) is not { } component) { @@ -168,7 +174,7 @@ private void ReadFields(SchemaType owner, IEnumerable value is null or JsonArray { Count: 0 }) ? null : group; } diff --git a/src/FluentCMS.Core/Content/EntryChange.cs b/src/FluentCMS.Core/Content/EntryChange.cs index a4558d6c5..8146948d4 100644 --- a/src/FluentCMS.Core/Content/EntryChange.cs +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -11,8 +11,11 @@ internal enum EntryChangeKind internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation); -// The seam for the event bus of #2697: the service tells it what changed once the outermost transaction has committed, -// and tells it nothing when that rolls back. The write has happened by then, so an implementation must not throw. +// The seam for the event bus of #2697: the service tells it what changed once its transaction has committed, one change +// per locale, and tells it nothing when that rolls back. The write has happened by then, so a failure here is logged and +// goes no further (spec 4.11). The service cannot see the end of a transaction it did not begin, so it tells nothing of +// a write inside one: whoever needs the changes of several writes in one transaction, such as #2703, runs them in +// ContentService.InTransactionAsync. internal interface IEntryChangeSink { ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken); diff --git a/src/FluentCMS.Core/Content/EntryComposer.cs b/src/FluentCMS.Core/Content/EntryComposer.cs index d8701417d..197ba8291 100644 --- a/src/FluentCMS.Core/Content/EntryComposer.cs +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -111,10 +111,12 @@ private static JsonObject MergeJson(JsonNode? stored, JsonObject patch) return result; } - // What a client cannot see or change inside a component or a block stays when it sends the rest back. In a list, a - // block keeps those values of the block at the same index, if that is of the same type. A single component only needs - // this on a full update: a patch merged into it already keeps them, and may be the system principal clearing one. - public void CarryHidden(SchemaType type, ContentDocument stored, ContentDocument composed, bool includingGroups) + // What a client cannot see or change inside a component or a block stays when it sends the rest back, even when it + // sends no component at all. A list has no ids, so an incoming block keeps the values of the stored block of the + // same type whose visible content is identical, each used once, and what is left pairs up in order. A block both + // moved and edited can still pair with the wrong one. A single component is left alone on a patch only for the + // system principal, which may be clearing a value, since a patch merged into a component already keeps the rest. + public void CarryProtected(SchemaType type, ContentDocument stored, ContentDocument composed, bool includingGroups) { foreach (var field in type.Fields) { @@ -122,10 +124,9 @@ public void CarryHidden(SchemaType type, ContentDocument stored, ContentDocument composed.TryGetValue(field.Name, out var after); if (field.FieldType == FieldTypeNames.Component && !field.Multiple) { - if (includingGroups && before is ContentDocument oldGroup && after is ContentDocument newGroup - && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component) + if (includingGroups && before is ContentDocument oldGroup && ComponentOf(field) is { } component) { - CarryIntoGroup(component, oldGroup, newGroup); + CarryGroup(component, oldGroup, after as ContentDocument, group => composed[field.Name] = group); } } else if (before is JsonNode oldNode && after is JsonNode newNode) @@ -135,59 +136,123 @@ public void CarryHidden(SchemaType type, ContentDocument stored, ContentDocument } } - private void CarryIntoGroup(SchemaType component, ContentDocument before, ContentDocument after) + private SchemaType? ComponentOf(SchemaField field) => + field.Settings.TryGetValue(FieldSettings.Component, out var name) && name is string component ? schema.FindComponent(component) : null; + + // An absent component comes back only if it has something to carry. + private void CarryGroup(SchemaType component, ContentDocument before, ContentDocument? after, Action set) { + var group = after ?? []; foreach (var field in component.Fields) { - if ((field.Hidden || field.ReadOnly) && before.GetValueOrDefault(field.Name) is { } value && after.GetValueOrDefault(field.Name) is null) + var old = before.GetValueOrDefault(field.Name); + var current = group.GetValueOrDefault(field.Name); + if ((field.Hidden || field.ReadOnly) && old is not null && current is null) { - after[field.Name] = value; + group[field.Name] = old; } - else if (before.GetValueOrDefault(field.Name) is JsonNode oldNode && after.GetValueOrDefault(field.Name) is JsonNode newNode) + else if (old is JsonNode oldNode && current is JsonNode newNode) { CarryInside(field, oldNode, newNode); } - else if (field.FieldType == FieldTypeNames.Component && !field.Multiple - && before.GetValueOrDefault(field.Name) is ContentDocument oldGroup && after.GetValueOrDefault(field.Name) is ContentDocument newGroup - && schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } inner) + else if (field.FieldType == FieldTypeNames.Component && !field.Multiple && old is ContentDocument oldGroup && ComponentOf(field) is { } inner) { - CarryIntoGroup(inner, oldGroup, newGroup); + CarryGroup(inner, oldGroup, current as ContentDocument, value => group[field.Name] = value); } } + + if (after is null && group.Values.Any(value => value is not null)) + { + set(group); + } } private void CarryInside(SchemaField field, JsonNode before, JsonNode after) { if (before is JsonArray oldItems && after is JsonArray newItems) { - for (var index = 0; index < Math.Min(oldItems.Count, newItems.Count); index++) + foreach (var (oldItem, newItem) in PairUp(field, [.. oldItems.OfType()], [.. newItems.OfType()])) { - if (oldItems[index] is JsonObject oldItem && newItems[index] is JsonObject newItem && OwnerOf(field, oldItem, newItem) is { } owner) + if (OwnerOf(field, oldItem) is { } owner) { CarryIntoObject(owner, oldItem, newItem); } } } - else if (before is JsonObject oldObject && after is JsonObject newObject && OwnerOf(field, oldObject, newObject) is { } owner) + else if (before is JsonObject oldObject && after is JsonObject newObject && OwnerOf(field, oldObject) is { } single && OwnerOf(field, newObject) == single) { - CarryIntoObject(owner, oldObject, newObject); + CarryIntoObject(single, oldObject, newObject); } } - private SchemaType? OwnerOf(SchemaField field, JsonObject before, JsonObject after) + private List<(JsonObject Old, JsonObject New)> PairUp(SchemaField field, List before, List after) { - if (field.FieldType == FieldTypeNames.Component) + List<(JsonObject Old, JsonObject New)> pairs = []; + List oldLeft = [.. before]; + List newLeft = []; + foreach (var item in after) { - return schema.FindComponent((string)field.Settings[FieldSettings.Component]); + var same = OwnerOf(field, item) is { } owner + ? oldLeft.FindIndex(old => OwnerOf(field, old) == owner && JsonNode.DeepEquals(Comparable(owner, old), Comparable(owner, item))) + : -1; + + if (same < 0) + { + newLeft.Add(item); + } + else + { + pairs.Add((oldLeft[same], item)); + oldLeft.RemoveAt(same); + } } - return field.FieldType == FieldTypeNames.Blocks - && before[Discriminator] is JsonValue oldName && after[Discriminator] is JsonValue newName - && oldName.TryGetValue(out var name) && newName.TryGetValue(out var same) && name == same - ? schema.FindBlock(name) - : null; + for (var index = 0; index < Math.Min(oldLeft.Count, newLeft.Count); index++) + { + if (OwnerOf(field, oldLeft[index]) is { } owner && OwnerOf(field, newLeft[index]) == owner) + { + pairs.Add((oldLeft[index], newLeft[index])); + } + } + + return pairs; } + // What a client saw of a block: no protected values, and no key that holds nothing, which is the same as no key. + private JsonObject Comparable(SchemaType owner, JsonObject item) + { + JsonObject result = []; + foreach (var (key, value) in item) + { + if (value is null || owner.FindField(key) is { Hidden: true } or { ReadOnly: true }) + { + continue; + } + + var inner = owner.FindField(key) is { } field ? ComparableValue(field, value) : value.DeepClone(); + if (inner is not JsonObject { Count: 0 }) + { + result[key] = inner; + } + } + + return result; + } + + private JsonNode ComparableValue(SchemaField field, JsonNode value) => value switch + { + JsonObject single when OwnerOf(field, single) is { } owner => Comparable(owner, single), + JsonArray items => new JsonArray([.. items.Select(item => item is null ? null : ComparableValue(field, item))]), + _ => value.DeepClone(), + }; + + private SchemaType? OwnerOf(SchemaField field, JsonObject item) => field.FieldType switch + { + FieldTypeNames.Component => ComponentOf(field), + FieldTypeNames.Blocks when item[Discriminator] is JsonValue name && name.TryGetValue(out var block) => schema.FindBlock(block), + _ => null, + }; + private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject after) { foreach (var field in owner.Fields) @@ -199,12 +264,24 @@ private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject aft if (field.Hidden || field.ReadOnly) { - after[field.Name] ??= value.DeepClone(); + if (!after.ContainsKey(field.Name)) + { + after[field.Name] = value.DeepClone(); + } } else if (after[field.Name] is { } inner) { CarryInside(field, value, inner); } + else if (field.FieldType == FieldTypeNames.Component && !field.Multiple && value is JsonObject nested && ComponentOf(field) is { } component) + { + JsonObject kept = []; + CarryIntoObject(component, nested, kept); + if (kept.Count > 0) + { + after[field.Name] = kept; + } + } } } } diff --git a/src/FluentCMS.Core/Content/EntryErrors.cs b/src/FluentCMS.Core/Content/EntryErrors.cs new file mode 100644 index 000000000..f903fb43a --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryErrors.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// Which validation errors a principal may hear of. An error names a field and tells something of its value, so one on a +// field it cannot read is only for it if it wrote that value itself. If nobody changed the value, the error is not about +// this write and does not block it. If a hook did, the write fails, without saying on what. +internal sealed class EntryErrors(SchemaModel schema, SchemaType type, EffectivePermissions permissions, AccessTarget target) +{ + private const string Discriminator = "type"; + + private static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); + + public List Reportable(IReadOnlyList errors, JsonObject now, JsonObject? was, JsonObject sent) + { + List result = []; + foreach (var error in errors) + { + if (permissions.IsSystem || Describe(error.Path, now) is not { } path || path.Readable) + { + result.Add(error); + continue; + } + + var value = ValueAt(now, error.Path); + if (was is not null && JsonNode.DeepEquals(value, ValueAt(was, error.Path))) + { + continue; + } + + var fromClient = JsonNode.DeepEquals(value, ValueAt(sent, error.Path)); + if (fromClient && !path.Hidden) + { + result.Add(error); + } + else if (!fromClient && !result.Contains(ProducedValue)) + { + result.Add(ProducedValue); + } + } + + return result; + } + + // Null when the path is not a field, such as an error on the entry as a whole. + private (bool Readable, bool Hidden)? Describe(string path, JsonObject now) + { + var segments = Parse(path); + if (segments.Count == 0 || type.FindField(segments[0].Name) is not { } top) + { + return null; + } + + var hidden = false; + SchemaType? owner = type; + JsonNode? value = now; + foreach (var (name, index) in segments) + { + var field = owner?.FindField(name); + hidden |= field?.Hidden == true; + value = value is JsonObject container ? container[name] : null; + if (index is { } position) + { + value = value is JsonArray items && position < items.Count ? items[position] : null; + } + + owner = field?.FieldType switch + { + FieldTypeNames.Component => schema.FindComponent((string)field.Settings[FieldSettings.Component]), + FieldTypeNames.Blocks when value is JsonObject block && block[Discriminator] is JsonValue kind && kind.TryGetValue(out var blockName) => schema.FindBlock(blockName), + _ => null, + }; + } + + return (!hidden && permissions.CanReadField(type, top.Name, target), hidden); + } + + private static JsonNode? ValueAt(JsonObject root, string path) + { + JsonNode? value = root; + foreach (var (name, index) in Parse(path)) + { + value = value is JsonObject container ? container[name] : null; + if (index is { } position) + { + value = value is JsonArray items && position < items.Count ? items[position] : null; + } + } + + return value; + } + + // sections[2].heading is (sections, 2), (heading, null). + private static List<(string Name, int? Index)> Parse(string path) + { + List<(string Name, int? Index)> segments = []; + if (path.Length == 0) + { + return segments; + } + + foreach (var part in path.Split('.')) + { + var open = part.IndexOf('[', StringComparison.Ordinal); + segments.Add(open < 0 + ? (part, null) + : (part[..open], int.TryParse(part.AsSpan(open + 1, part.Length - open - 2), NumberStyles.None, CultureInfo.InvariantCulture, out var index) ? index : null)); + } + + return segments; + } +} diff --git a/src/FluentCMS.Core/Content/EntryJson.cs b/src/FluentCMS.Core/Content/EntryJson.cs index 96c71c786..343b348e8 100644 --- a/src/FluentCMS.Core/Content/EntryJson.cs +++ b/src/FluentCMS.Core/Content/EntryJson.cs @@ -113,5 +113,24 @@ internal static class EntryPaths return current; } + // Sets a stored field, making the groups above it where a single component was absent. + public static void SetAt(ContentDocument entry, StoredField stored, SchemaType type, object? value) + { + var names = Query.QueryPaths.ApiPath(type, stored).Split('.'); + var group = entry; + foreach (var name in names[..^1]) + { + if (group.GetValueOrDefault(name) is not ContentDocument inner) + { + inner = []; + group[name] = inner; + } + + group = inner; + } + + group[names[^1]] = value; + } + public static string ReferenceTarget(SchemaField field) => field.Relation?.Target ?? field.FieldType; } diff --git a/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs b/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs index b64f2b7c1..5a8ad4788 100644 --- a/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs +++ b/src/FluentCMS.Core/Content/Hooks/ContentCopy.cs @@ -26,6 +26,7 @@ public static ContentDocument Of(ContentDocument document) IReadOnlyDictionary map => map.ToDictionary(item => item.Key, item => Value(item.Value), StringComparer.Ordinal), List list => list.ConvertAll(Value), object?[] array => Array.ConvertAll(array, Value), + Guid[] ids => (Guid[])ids.Clone(), _ => value, }; } diff --git a/src/FluentCMS.Core/Content/TypedContentService.cs b/src/FluentCMS.Core/Content/TypedContentService.cs index e52a7ab1b..9651586ff 100644 --- a/src/FluentCMS.Core/Content/TypedContentService.cs +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -33,24 +33,27 @@ public async Task CreateAsync(T entry, CancellationToken cancellationToken = { ArgumentNullException.ThrowIfNull(entry); - return await ReadBackAsync(await documents.CreateAsync(_type, mapper.ToDocument(entry), cancellationToken), cancellationToken); + var limits = await documents.TypedWriteLimitsAsync(_type, cancellationToken); + return await ReadBackAsync(await documents.CreateAsync(_type, mapper.ToDocument(entry, static _ => true, limits.IsSystem), cancellationToken)); } public async Task UpdateAsync(Guid id, T entry, int expectedVersion, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); - return await ReadBackAsync(await documents.UpdateAsync(_type, id, mapper.ToDocument(entry), expectedVersion, cancellationToken), cancellationToken); + // What the principal may not read came back as a default: sending it would overwrite the stored value. + var limits = await documents.TypedWriteLimitsAsync(_type, cancellationToken); + return await ReadBackAsync(await documents.UpdateAsync(_type, id, mapper.ToDocument(entry, limits.CanRead, limits.IsSystem), expectedVersion, cancellationToken)); } public async Task PatchAsync(Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default) => - await ReadBackAsync(await documents.PatchAsync(_type, id, patch, expectedVersion, cancellationToken), cancellationToken); + await ReadBackAsync(await documents.PatchAsync(_type, id, patch, expectedVersion, cancellationToken)); public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) => documents.DeleteAsync(_type, id, cancellationToken); public async Task RestoreAsync(Guid id, CancellationToken cancellationToken = default) => - await ReadBackAsync(await documents.RestoreAsync(_type, id, cancellationToken), cancellationToken); + await ReadBackAsync(await documents.RestoreAsync(_type, id, cancellationToken)); public Task CountAsync(ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default) => documents.CountAsync(_type, filter, status, cancellationToken); @@ -58,10 +61,11 @@ public Task CountAsync(ContentFilter? filter = null, QueryStatus status = private T Map(ContentDocument entry) => (T)mapper.ToEntry(typeof(T), entry); // What a write returned, which is all a principal without the read gets, with the system fields a class also holds. - private async Task ReadBackAsync(ContentDocument written, CancellationToken cancellationToken) + // The write has committed by now, so a cancelled caller does not turn it into a failure. + private async Task ReadBackAsync(ContentDocument written) { var options = new GetOptions { Status = QueryStatus.Draft, Fallback = LocaleFallback.None }; - return Map(written.ContainsKey("status") && await documents.GetAsync(_type, (Guid)written["id"]!, options, everySystemField: true, cancellationToken) is { } entry ? entry : written); + return Map(written.ContainsKey("status") && await documents.GetAsync(_type, (Guid)written["id"]!, options, everySystemField: true, CancellationToken.None) is { } entry ? entry : written); } private static void ThrowIfUnfit(string? locale, string? richTextFormat) diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs index 0f33131e9..885921a92 100644 --- a/src/FluentCMS.Sdk/Content/IContentService.cs +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -20,7 +20,7 @@ public interface IContentService /// Reads a page of entries. /// The name of the content type. - /// What to read. A missing site or locale is that of the operation. + /// What to read. A missing site or locale is that of the operation. Another site than that of the operation is refused, except for the system principal. /// A token to cancel the work. /// The page. /// The query is not valid, or the principal may not read the type or its drafts. @@ -37,7 +37,8 @@ public interface IContentService /// /// Replaces the draft in the locale of the operation. A field left out is cleared, unless the principal may not write - /// or read it, in which case it keeps its value. With an expected version of 0, this adds a locale the entry does not have yet. + /// or read it, in which case it keeps its value; so do relation targets it may not read. Fields named under + /// fallbacks count as left out. With an expected version of 0, this adds a locale the entry does not have yet. /// /// The name of the content type. /// The id of the entry. @@ -123,7 +124,7 @@ public interface IContentService /// A value is refused or not valid. Task CreateAsync(T entry, CancellationToken cancellationToken = default); - /// Replaces the draft in the locale of the operation. A property with no value clears its field, unless the principal may not write or read it. + /// Replaces the draft in the locale of the operation. A property with no value clears its field. A property the principal may not read, and one that is hidden, is not sent, so it keeps its value; use a patch to write a field that cannot be read back. /// The id of the entry. /// The values. /// The version the change was based on. diff --git a/src/FluentCMS.Sdk/Schema/ContentItem.cs b/src/FluentCMS.Sdk/Schema/ContentItem.cs index 0e78173c2..2f093f442 100644 --- a/src/FluentCMS.Sdk/Schema/ContentItem.cs +++ b/src/FluentCMS.Sdk/Schema/ContentItem.cs @@ -10,9 +10,9 @@ namespace FluentCMS.Schema; public abstract class ContentItem { /// - /// The reserved system field names, as they appear in the API. Compared without regard to case, - /// so a C# property named CreatedAt matches createdAt. A content type field with one - /// of these names fails schema validation. + /// The reserved names, as they appear in the API: the system fields and fallbacks, which a response adds. + /// Compared without regard to case, so a C# property named CreatedAt matches createdAt. A content + /// type field with one of these names fails schema validation. /// public static IReadOnlySet ReservedFieldNames { get; } = new[] { @@ -25,6 +25,7 @@ public abstract class ContentItem "updatedAt", "updatedBy", "publishedAt", + "fallbacks", }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// The id of the entry. diff --git a/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs b/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs new file mode 100644 index 000000000..c82e9a8b5 --- /dev/null +++ b/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs @@ -0,0 +1,71 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace FluentCMS.Architecture.Tests; + +// The calls that reach a content table without naming a class, read from the IL: a property bag by table name, and SQL. +internal static class ContentTableReaches +{ + private static readonly string[] Allowed = ["FluentCMS.Core.Content", "FluentCMS.Core.Storage", "FluentCMS.Data", "FluentCMS.Cli"]; + + public static IEnumerable SourceAssemblies() => + ProjectFile.SourceProjects.Select(project => Path.Combine(AppContext.BaseDirectory, project.Name + ".dll")).Where(File.Exists); + + public static IEnumerable InEveryAssembly() => SourceAssemblies().SelectMany(In); + + public static List In(string assembly) + { + using var module = ModuleDefinition.ReadModule(assembly); + + List violations = []; + foreach (var type in module.GetTypes().Where(type => !MayReach(type))) + { + foreach (var method in type.Methods.Where(method => method.HasBody)) + { + violations.AddRange(method.Body.Instructions + .Where(instruction => instruction.OpCode.FlowControl == FlowControl.Call && instruction.Operand is MethodReference called && IsReach(called)) + .Select(instruction => $"{type.FullName}.{method.Name} calls {((MethodReference)instruction.Operand).FullName}")); + } + } + + return violations; + } + + private static bool MayReach(TypeDefinition type) + { + var root = type; + while (root.DeclaringType is not null) + { + root = root.DeclaringType; + } + + return Allowed.Any(name => root.Namespace == name || root.Namespace.StartsWith(name + ".", StringComparison.Ordinal)); + } + + private static bool IsReach(MethodReference called) + { + var declaring = called.DeclaringType.FullName; + return called switch + { + GenericInstanceMethod { ElementMethod.Name: "Set" } set => set.GenericArguments[0].FullName.StartsWith("System.Collections.Generic.Dictionary`2", StringComparison.Ordinal), + { Name: "get_Database" } => true, + { Name: "Rows" } => declaring == typeof(CmsDbContext).FullName, + _ => declaring is "Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions" or "Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade" + || (declaring == "Microsoft.EntityFrameworkCore.RelationalQueryableExtensions" && called.Name.StartsWith("FromSql", StringComparison.Ordinal)), + }; + } +} + +// What the scan must catch, in a namespace it does not allow. It is never run. +internal static class CarelessCode +{ + public static object ByName(CmsDbContext context) => context.Set>("cms_article"); + + public static object ByRows(CmsDbContext context) => context.Rows("cms_article"); + + public static int BySql(CmsDbContext context) => context.Database.ExecuteSqlRaw("UPDATE cms_article SET views = 0"); + + public static object ByQuery(CmsDbContext context) => context.Documents.FromSqlRaw("SELECT * FROM cms_article"); +} diff --git a/tests/FluentCMS.Architecture.Tests/FluentCMS.Architecture.Tests.csproj b/tests/FluentCMS.Architecture.Tests/FluentCMS.Architecture.Tests.csproj index 04699d78d..0b1aed71e 100644 --- a/tests/FluentCMS.Architecture.Tests/FluentCMS.Architecture.Tests.csproj +++ b/tests/FluentCMS.Architecture.Tests/FluentCMS.Architecture.Tests.csproj @@ -12,6 +12,12 @@ + + + + + + diff --git a/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs b/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs index 45842e934..2f6954841 100644 --- a/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs +++ b/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs @@ -18,10 +18,15 @@ public class LayeringRulesTests private const string AuthorizationNamespace = "FluentCMS.Core.Authorization"; private const string ContentNamespace = "FluentCMS.Core.Content"; private const string SitesNamespace = "FluentCMS.Core.Sites"; + private const string ProvidersNamespace = "FluentCMS.Data"; + private const string CliNamespace = "FluentCMS.Cli"; private static readonly Lazy core = new( () => new ArchLoader().LoadAssemblies(Assembly.Load("FluentCMS.Core")).Build()); + private static readonly Lazy everything = new( + () => new ArchLoader().LoadAssemblies([.. ContentTableReaches.SourceAssemblies().Select(Assembly.LoadFrom)]).Build()); + // FluentCMS.Core.Api does not exist until the Delivery API (B-47), so the rule has nothing to check yet and starts checking with the first type there. [Fact] public void ApiDoesNotReferenceStorageOrCmsDbContext() @@ -77,45 +82,36 @@ public void OnlyAuthorizationReadsRolePermissionEntities() } // Issue #2693. CmsDbContext also holds the system tables, so these may reach it for their own tables only, which the - // next rule checks: the evaluator for roles, Sites and LocaleRegistry for cms_site and cms_site_locale, and - // the extensions that register it. + // next rules check: the evaluator for roles, Sites and LocaleRegistry for cms_site and cms_site_locale, the extensions + // that register it, and the provider packages and the tool, which are the storage layer's other half. [Fact] public void OnlyTheServiceAndTheStorageLayerTouchCmsDbContext() { Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) .And().DoNotResideInNamespaceOrBelow(StorageNamespace) + .And().DoNotResideInNamespaceOrBelow(ProvidersNamespace) + .And().DoNotResideInNamespaceOrBelow(CliNamespace) .And().DoNotResideInNamespaceOrBelow(SitesNamespace) .And().DoNotHaveName("AuthorizationEvaluator") .And().DoNotHaveName("LocaleRegistry") .And().DoNotHaveName("FluentCmsServiceCollectionExtensions") .Should().NotDependOnAnyTypesThat().HaveName("CmsDbContext") - .Check(core.Value); + .Check(everything.Value); } // The evaluator asks whether an entry is in a site, and SiteManager whether a site has entries; neither reads content. - // Nothing outside Content and Storage reads a content table: Rows is the only way to one, and it is checked below. [Fact] public void OnlyTheServiceAndTheStorageLayerTouchTheContentSystemTables() { Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) .And().DoNotResideInNamespaceOrBelow(StorageNamespace) + .And().DoNotResideInNamespaceOrBelow(ProvidersNamespace) .And().DoNotHaveName("AuthorizationEvaluator") .And().DoNotHaveName("SiteManager") .Should().NotDependOnAnyTypesThat().HaveName("Document") .AndShould().NotDependOnAnyTypesThat().HaveName("Reference") .AndShould().NotDependOnAnyTypesThat().HaveName("ContentVersion") - .Check(core.Value); - } - - [Fact] - public void OnlyTheServiceAndTheStorageLayerReadContentRows() - { - var rows = MethodMembers().That().AreDeclaredIn(typeof(FluentCMS.Core.Storage.CmsDbContext)).And().HaveName("Rows(System.String)"); - - Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) - .And().DoNotResideInNamespaceOrBelow(StorageNamespace) - .Should().NotCallAny(rows) - .Check(core.Value); + .Check(everything.Value); } // Inside Content, the store and the query engine are what the service and the evaluator go through, not a way around them. @@ -125,7 +121,36 @@ public void NothingOutsideContentUsesTheEntryStoreOrWritesAroundTheService() Types().That().DoNotResideInNamespaceOrBelow(ContentNamespace) .And().DoNotHaveName("FluentCmsServiceCollectionExtensions") .Should().NotDependOnAnyTypesThat().HaveName("EntryStore") - .Check(core.Value); + .Check(everything.Value); + } + + // A content table has no class to depend on, so these look at the calls: its rows by name, or SQL. + [Fact] + public void OnlyTheServiceAndTheStorageLayerReachContentTablesByNameOrBySql() + { + var violations = ContentTableReaches.InEveryAssembly().ToList(); + + violations.ShouldBeEmpty(); + } + + [Fact] + public void EveryProjectUnderSrcIsScanned() + { + var scanned = ContentTableReaches.SourceAssemblies().Select(Path.GetFileNameWithoutExtension).ToList(); + + scanned.ShouldBe(ProjectFile.SourceProjects.Select(project => project.Name), ignoreOrder: true); + } + + [Fact] + public void TheCallScanFindsEveryWayToAContentTable() + { + var found = ContentTableReaches.In(typeof(CarelessCode).Assembly.Location).Where(violation => violation.Contains(nameof(CarelessCode), StringComparison.Ordinal)).ToList(); + + found.ShouldContain(violation => violation.Contains("Set", StringComparison.Ordinal)); + found.ShouldContain(violation => violation.Contains("Rows", StringComparison.Ordinal)); + found.ShouldContain(violation => violation.Contains("get_Database", StringComparison.Ordinal)); + found.ShouldContain(violation => violation.Contains("ExecuteSqlRaw", StringComparison.Ordinal)); + found.ShouldContain(violation => violation.Contains("FromSqlRaw", StringComparison.Ordinal)); } private static IEnumerable FluentCmsReferences(ProjectFile project) => diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs new file mode 100644 index 000000000..849d5d5c5 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs @@ -0,0 +1,292 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +// What a client could not see surviving what it sends back. +public sealed class ContentServiceCarryTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private Task SystemPatchAsync(Guid id, ContentDocument patch, int version) => + _database.AsAsync(Principal.System, services => services.GetRequiredService().PatchAsync("article", id, patch, version, Token), origin: Origin.Job); + + private JsonNode SectionsOf(Guid id) => JsonNode.Parse((string)_database.Row("cms_article", id)["sections"])!; + + [Fact] + public async Task AnAuthorSendingBackWhatItReadKeepsTargetsItCannotRead() + { + var other = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Not mine" }, "author")); + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Global" }, "tag")); + var ann = AuthorRole(Guid.CreateVersion7()); + var own = IdOf(await _database.CreateAsync(ann, new() { ["name"] = "Mine" }, "author")); + var entry = await _database.CreateAsync(ann, new() { ["title"] = "Mine", ["author"] = other, ["sponsor"] = own, ["tags"] = new[] { tag } }); + var id = IdOf(entry); + + var read = (await _database.GetAsync(ann, id))!; + read["author"].ShouldBeNull(); + ((List)read["tags"]!).ShouldBeEmpty(); + read["title"] = "Still mine"; + await _database.UpdateAsync(ann, id, read, 1); + + var editorSees = (await _database.GetAsync(Editor, id))!; + ((ContentDocument)editorSees["author"]!)["id"].ShouldBe(other); + ((ContentDocument)editorSees["sponsor"]!)["id"].ShouldBe(own); + ((List)editorSees["tags"]!).ShouldHaveSingleItem()["id"].ShouldBe(tag); + editorSees["title"].ShouldBe("Still mine"); + } + + [Fact] + public async Task AMultipleRelationKeepsUnreadableTargetsAlongsideWhatWasSent() + { + var role = _database.Database.AddRoleForTest( + "Own tags", + ("content.article.read", null), + ("content.article.update", null), + ("content.tag.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }), + ("content.tag.create", null)); + var principal = WithRole(role, Guid.CreateVersion7()); + var hiddenTag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Theirs" }, "tag")); + var ownTag = IdOf(await _database.CreateAsync(principal, new() { ["name"] = "Mine" }, "tag")); + var newTag = IdOf(await _database.CreateAsync(principal, new() { ["name"] = "New" }, "tag")); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["tags"] = new[] { hiddenTag, ownTag } }); + var id = IdOf(entry); + + (await _database.GetAsync(principal, id))!["tags"].ShouldBeOfType>().ShouldHaveSingleItem()["id"].ShouldBe(ownTag); + + await _database.UpdateAsync(principal, id, new() { ["title"] = "T", ["tags"] = new[] { newTag } }, 1); + + _database.References(id).Where(row => row.Field == "tags").Select(row => row.TargetId).ShouldBe([newTag, hiddenTag]); + } + + [Fact] + public async Task APatchNullClearsARelationEvenWhenItsTargetIsUnreadable() + { + var role = _database.Database.AddRoleForTest("Articles", ("content.article.read", null), ("content.article.update", null)); + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["author"] = ann }); + + await _database.PatchAsync(WithRole(role), IdOf(entry), new() { ["author"] = null }, 1); + + _database.References(IdOf(entry)).ShouldBeEmpty(); + } + + [Fact] + public async Task AReadableTargetLeftOutOfAFullUpdateIsCleared() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["author"] = ann }); + + await _database.UpdateAsync(Editor, IdOf(entry), Titled("T"), 1); + + _database.References(IdOf(entry)).ShouldBeEmpty(); + } + + public static TheoryData GroupsSentWithoutTheHiddenValue() => new() + { + { false, new() { ["title"] = "T" } }, + { false, new() { ["title"] = "T", ["seo"] = null } }, + { false, new() { ["title"] = "T", ["seo"] = new ContentDocument() } }, + { false, new() { ["title"] = "T", ["seo"] = new ContentDocument { ["metaTitle"] = null } } }, + { false, new() { ["title"] = "T", ["seo"] = new ContentDocument { ["metaTitle"] = "x" } } }, + { true, new() { ["seo"] = null } }, + { true, new() { ["seo"] = new ContentDocument { ["metaTitle"] = null, ["image"] = null } } }, + }; + + [Theory] + [MemberData(nameof(GroupsSentWithoutTheHiddenValue))] + public async Task AHiddenValueInAComponentSurvivesWhateverAClientSends(bool isPatch, ContentDocument change) + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["seo"] = new ContentDocument { ["metaTitle"] = "m" } }); + var id = IdOf(entry); + await SystemPatchAsync(id, new() { ["seo"] = new ContentDocument { ["internal"] = "kept" } }, 1); + + if (isPatch) + { + await _database.PatchAsync(Editor, id, change, 2); + } + else + { + await _database.UpdateAsync(Editor, id, change, 2); + } + + _database.Row("cms_article", id)["seo_internal"].ShouldBe("kept"); + JsonNode.Parse(_database.Versions(id).Last().Snapshot)!["seo"]!["internal"]!.GetValue().ShouldBe("kept"); + } + + [Fact] + public async Task TheSystemPrincipalMayStillClearAHiddenValueInAComponent() + { + var entry = await _database.CreateAsync(Editor, Titled("T")); + await SystemPatchAsync(IdOf(entry), new() { ["seo"] = new ContentDocument { ["internal"] = "kept" } }, 1); + + await SystemPatchAsync(IdOf(entry), new() { ["seo"] = new ContentDocument { ["internal"] = null } }, 2); + + _database.Row("cms_article", IdOf(entry))["seo_internal"].ShouldBeNull(); + } + + [Fact] + public async Task AHiddenValueInAComponentInsideABlockSurvivesTheComponentBeingLeftOut() + { + var entry = await _database.CreateAsync(Editor, Titled("T")); + var id = IdOf(entry); + await SystemPatchAsync(id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A","internal":"I","details":{"metaTitle":"d","internal":"NESTED"}}]""") }, 1); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A2"}]""") }, 2); + + var block = SectionsOf(id)[0]!; + block["heading"]!.GetValue().ShouldBe("A2"); + block["internal"]!.GetValue().ShouldBe("I"); + block["details"]!["internal"]!.GetValue().ShouldBe("NESTED"); + } + + private async Task TwoBlocksAsync() + { + var entry = await _database.CreateAsync(Editor, Titled("T")); + await SystemPatchAsync(IdOf(entry), new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A","internal":"A-approved"},{"type":"hero","heading":"B","internal":"B-rejected"}]""") }, 1); + return IdOf(entry); + } + + [Fact] + public async Task HiddenValuesFollowBlocksThatMove() + { + var id = await TwoBlocksAsync(); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"B"},{"type":"hero","heading":"A"}]""") }, 2); + + var sections = SectionsOf(id); + sections[0]!["heading"]!.GetValue().ShouldBe("B"); + sections[0]!["internal"]!.GetValue().ShouldBe("B-rejected"); + sections[1]!["internal"]!.GetValue().ShouldBe("A-approved"); + } + + [Fact] + public async Task ARemovedBlockTakesItsHiddenValueWithIt() + { + var id = await TwoBlocksAsync(); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"B"}]""") }, 2); + + SectionsOf(id)[0]!["internal"]!.GetValue().ShouldBe("B-rejected"); + } + + [Fact] + public async Task AnEditedBlockFallsBackToItsPlaceAmongTheBlocksThatDidNotMatch() + { + var id = await TwoBlocksAsync(); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A"},{"type":"hero","heading":"B2"}]""") }, 2); + + SectionsOf(id)[1]!["internal"]!.GetValue().ShouldBe("B-rejected"); + } + + [Fact] + public async Task TheSameBlockTwiceUsesEachStoredOneOnce() + { + var entry = await _database.CreateAsync(Editor, Titled("T")); + await SystemPatchAsync(IdOf(entry), new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A","internal":"first"},{"type":"hero","heading":"A","internal":"second"}]""") }, 1); + + await _database.PatchAsync(Editor, IdOf(entry), new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A"},{"type":"hero","heading":"A"}]""") }, 2); + + var sections = SectionsOf(IdOf(entry)); + sections[0]!["internal"]!.GetValue().ShouldBe("first"); + sections[1]!["internal"]!.GetValue().ShouldBe("second"); + } + + [Fact] + public async Task ABlockOfAnotherTypeDoesNotInheritAHiddenValue() + { + var id = await TwoBlocksAsync(); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"quote","text":"A"},{"type":"hero","heading":"B"}]""") }, 2); + + var sections = SectionsOf(id); + sections[0]!["internal"].ShouldBeNull(); + sections[1]!["internal"]!.GetValue().ShouldBe("B-rejected"); + } + + [Fact] + public async Task ATypedRoundTripKeepsWhatThePrincipalCouldNotRead() + { + using var database = new ServiceDatabase(schema: SampleSchema.FromClasses().Build()); + var created = await database.AsAsync(Editor, services => services.GetRequiredService>().CreateAsync(new Article { Title = "T", Category = ArticleCategory.Video, VideoUrl = "https://example.com/v" }, Token)); + var role = database.Database.AddRoleForTest("Titles", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + await database.AsAsync(WithRole(role), async services => + { + var service = services.GetRequiredService>(); + var read = (await service.GetAsync(created.Id, new GetOptions { Status = QueryStatus.Draft }, Token))!; + read.Category.ShouldBe(default); + read.Title = "Changed"; + return await service.UpdateAsync(created.Id, read, read.Version, Token); + }); + + var stored = (await database.AsAsync(Editor, services => services.GetRequiredService>().GetAsync(created.Id, new GetOptions { Status = QueryStatus.Draft }, Token)))!; + stored.Title.ShouldBe("Changed"); + stored.Category.ShouldBe(ArticleCategory.Video); + stored.VideoUrl.ShouldBe("https://example.com/v"); + } + + [FluentCMS.Schema.Collection("scored")] + public sealed class Scored : ContentItem + { + [Text] + public string Name { get; set; } = ""; + + [Integer, Hidden] + public int Score { get; set; } + + [Integer, FluentCMS.Schema.ReadOnly] + public int Stamp { get; set; } + } + + [Fact] + public async Task ATypedWriteLeavesHiddenAndReadOnlyPropertiesOut() + { + using var database = new ServiceDatabase(schema: SampleSchema.NewBuilder().AddContentType().Build()); + + var created = await database.AsAsync(Editor, services => services.GetRequiredService>().CreateAsync(new Scored { Name = "n", Score = 7, Stamp = 8 }, Token)); + + created.Name.ShouldBe("n"); + var row = database.Row("cms_scored", created.Id); + row["score"].ShouldBeNull(); + row["stamp"].ShouldBeNull(); + + created.Name = "m"; + var updated = await database.AsAsync(Editor, services => services.GetRequiredService>().UpdateAsync(created.Id, created, created.Version, Token)); + updated.Name.ShouldBe("m"); + } + + [Fact] + public async Task ATypedHookStillFillsAHiddenProperty() + { + using var database = new ServiceDatabase( + cms => cms.Services.AddSingleton, ScoreIt>(), + SampleSchema.NewBuilder().AddContentType().Build()); + + var created = await database.AsAsync(Editor, services => services.GetRequiredService>().CreateAsync(new Scored { Name = "n" }, Token)); + + database.Row("cms_scored", created.Id)["score"].ShouldBe(9L); + } + + private sealed class ScoreIt : FluentCMS.Hooks.IBeforeCreateHook + { + public ValueTask BeforeCreateAsync(FluentCMS.Hooks.HookContext context, CancellationToken cancellationToken) + { + context.Entry.Score = 9; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs new file mode 100644 index 000000000..974275fd0 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs @@ -0,0 +1,353 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Data.Tests.Query; +using FluentCMS.Hooks; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +// What a principal cannot read, and may not reach, staying that way through every path. +public sealed class ContentServiceLimitsTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + private static ServiceDatabase WithRegionRule() => + new(cms => cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region"))); + + private static Principal InRegion(Guid role, string region) => + AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = region }); + + [Fact] + public async Task AQueryCannotNameAnotherSite() + { + await _database.CreateAsync(Editor, Titled("In A")); + await _database.CreateAsync(Editor, Titled("In B"), site: SiteB); + _database.Database.AddArticle("Published in B", site: QueryDatabase.SiteB); + _database.Database.GrantForTest(BuiltInRoles.Public, "content.article.read"); + _database.Database.AddRoleForTest("Public", QueryDatabase.SiteB); + + foreach (var (principal, status) in new[] { (Editor, QueryStatus.Draft), (Principal.Anonymous, QueryStatus.Published) }) + { + var refused = await Should.ThrowAsync(() => _database.QueryAsync(principal, new ContentQuery { Status = status, Site = SiteB })); + refused.Errors.ShouldHaveSingleItem().Path.ShouldBe("site"); + } + + // In its own site the anonymous principal gets that site's empty Public role. + await Should.ThrowAsync(() => _database.QueryAsync(Principal.Anonymous, new ContentQuery(), site: SiteB)); + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft, Site = SiteA })).Items.ShouldHaveSingleItem()["title"].ShouldBe("In A"); + } + + [Fact] + public async Task TheSystemPrincipalMayNameAnotherSite() + { + await _database.CreateAsync(Editor, Titled("In B"), site: SiteB); + + var page = await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().QueryAsync("article", new ContentQuery { Status = QueryStatus.Draft, Site = SiteB }, Token), + origin: Origin.Job); + + page.Items.ShouldHaveSingleItem()["title"].ShouldBe("In B"); + } + + [Fact] + public async Task ASlugDoesNotShowASourceThePrincipalCannotRead() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Top Secret Plan", ["slug"] = "public-slug" }); + var role = _database.Database.AddRoleForTest("Slugs", ("content.article.read", new PermissionConstraints { Fields = ["slug", "subtitle"] }), ("content.article.update", new PermissionConstraints { Fields = ["slug"] })); + + var patched = await _database.PatchAsync(WithRole(role), IdOf(entry), new() { ["slug"] = "" }, 1); + + patched["slug"].ShouldBe("public-slug"); + _database.Row("cms_article_i18n", IdOf(entry), "en")["slug"].ShouldBe("public-slug"); + + // A principal that may read the source still gets a slug from it. + (await _database.PatchAsync(Editor, IdOf(entry), new() { ["slug"] = "" }, 2))["slug"].ShouldBe("top-secret-plan"); + } + + [Fact] + public async Task ASlugIsNotMadeFromAHiddenSource() + { + var schema = SampleSchema.NewBuilder() + .AddCollection("page", type => type + .Field("code", "text", field => field.Hidden()) + .Field("slug", "slug", field => field.Setting(FieldSettings.Source, "code"))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + + var entry = await database.AsAsync( + Principal.System, + services => services.GetRequiredService().CreateAsync("page", new() { ["code"] = "Secret Code" }, Token), + origin: Origin.Seed); + + entry["slug"].ShouldBe(""); + } + + [Fact] + public async Task AGrantLimitedToALocaleDoesNotReachSharedFields() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5, ["region"] = "eu" }); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, new() { ["title"] = "Bonjour", ["views"] = 5, ["region"] = "eu" }, 0, locale: "fr"); + var role = _database.Database.AddRoleForTest("Translator", ("content.article.update", new PermissionConstraints { Locales = ["fr"] }), ("content.article.read", null)); + var translator = WithRole(role); + + (await Should.ThrowAsync(() => _database.PatchAsync(translator, id, new() { ["views"] = 999 }, 1, locale: "fr"))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("views"), + error => error.Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable)); + + // A full update that leaves them out keeps them, and one that sends them back unchanged is no write. + await _database.UpdateAsync(translator, id, Titled("Salut"), 1, locale: "fr"); + var read = (await _database.GetAsync(translator, id, locale: "fr"))!; + read["title"] = "Salut encore"; + await _database.UpdateAsync(translator, id, read, 2, locale: "fr"); + + var english = (await _database.GetAsync(Editor, id))!; + english["views"].ShouldBe(5L); + english["region"].ShouldBe("eu"); + (await _database.GetAsync(Editor, id, locale: "fr"))!["title"].ShouldBe("Salut encore"); + } + + [Fact] + public async Task AGrantCoveringEveryLocaleReachesSharedFields() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5 }); + var id = IdOf(entry); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + var role = _database.Database.AddRoleForTest("Both", ("content.article.update", new PermissionConstraints { Locales = ["fr", "en"] }), ("content.article.read", null)); + + (await _database.PatchAsync(WithRole(role), id, new() { ["views"] = 6 }, 1, locale: "fr"))["views"].ShouldBe(6L); + + // Adding a locale it does not cover leaves it unable to change what that locale will share too. + await _database.UpdateAsync(Editor, id, Titled("Salut"), 0, locale: "fr-CA"); + await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), id, new() { ["views"] = 7 }, 2, locale: "fr")); + } + + [Fact] + public async Task AGrantLimitedToALocaleAddsThatLocaleWithoutTheSharedFields() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5 }); + var role = _database.Database.AddRoleForTest("Translator", ("content.article.update", new PermissionConstraints { Locales = ["fr"] }), ("content.article.read", null)); + + await Should.ThrowAsync(() => _database.UpdateAsync(WithRole(role), IdOf(entry), new() { ["title"] = "Bonjour", ["views"] = 9 }, 0, locale: "fr")); + (await _database.UpdateAsync(WithRole(role), IdOf(entry), Titled("Bonjour"), 0, locale: "fr"))["views"].ShouldBe(5L); + } + + [Fact] + public async Task CreateHonoursTheRowRuleOfItsGrant() + { + using var database = WithRegionRule(); + var role = database.Database.AddRoleForTest( + "Regional", + ("content.article.create", new PermissionConstraints { RowRule = "region" }), + ("content.article.read", new PermissionConstraints { RowRule = "region" })); + var before = database.Footprint(); + + await Should.ThrowAsync(() => database.CreateAsync(InRegion(role, "eu"), new() { ["title"] = "T", ["region"] = "us" })); + database.Footprint().ShouldBe(before); + + (await database.CreateAsync(InRegion(role, "eu"), new() { ["title"] = "T", ["region"] = "eu" }))["region"].ShouldBe("eu"); + } + + [Fact] + public async Task ARowThePrincipalCannotReadIsNoWayToGuessItsValues() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["views"] = 5 }); + var role = _database.Database.AddRoleForTest( + "Own", + ("content.article.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }), + ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + var principal = WithRole(role, Guid.CreateVersion7()); + + (await _database.GetAsync(principal, IdOf(entry))).ShouldBeNull(); + foreach (var guess in new[] { 5, 6 }) + { + (await Should.ThrowAsync(() => _database.UpdateAsync(principal, IdOf(entry), new() { ["title"] = "T", ["views"] = guess }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + } + } + + [Fact] + public async Task ATargetThePrincipalCannotReadIsNoWayToGuessARelation() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["author"] = ann }); + var role = _database.Database.AddRoleForTest("Articles", ("content.article.read", null), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + var limited = WithRole(role); + + (await _database.GetAsync(limited, IdOf(entry)))!["author"].ShouldBeNull(); + foreach (var guess in new[] { ann, Guid.CreateVersion7() }) + { + (await Should.ThrowAsync(() => _database.UpdateAsync(limited, IdOf(entry), new() { ["title"] = "T", ["author"] = guess }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("author"); + } + + // What the read showed goes back as no change. + var read = (await _database.GetAsync(limited, IdOf(entry)))!; + await _database.UpdateAsync(limited, IdOf(entry), read, 1); + _database.References(IdOf(entry)).ShouldHaveSingleItem().TargetId.ShouldBe(ann); + } + + [Fact] + public async Task AnErrorOnAFieldThePrincipalCannotReadAndDidNotChangeNeitherBlocksNorShows() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["views"] = 5 }); + var id = IdOf(entry); + _database.Read(context => context.Rows("cms_article").Where(row => EF.Property(row, "document_id") == id).ExecuteUpdate(set => set.SetProperty(row => EF.Property(row, "views"), 5000L))); + var role = _database.Database.AddRoleForTest("Titles", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + + (await _database.PatchAsync(WithRole(role), id, Titled("Changed"), 1))["title"].ShouldBe("Changed"); + + // For someone who can read the field it is an error like any other. + (await Should.ThrowAsync(() => _database.PatchAsync(Editor, id, Titled("Again"), 2))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + } + + [Fact] + public async Task UniquenessStillRunsAfterAnErrorThatDoesNotCount() + { + await _database.CreateAsync(Editor, new() { ["title"] = "Taken" }); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "T", ["views"] = 5 }); + var id = IdOf(entry); + _database.Read(context => context.Rows("cms_article").Where(row => EF.Property(row, "document_id") == id).ExecuteUpdate(set => set.SetProperty(row => EF.Property(row, "views"), 5000L))); + var role = _database.Database.AddRoleForTest("Titles", ("content.article.read", new PermissionConstraints { Fields = ["title", "slug"] }), ("content.article.update", new PermissionConstraints { Fields = ["title", "slug"] })); + + (await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), id, new() { ["slug"] = "taken" }, 1))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("slug"), + error => error.Code.ShouldBe(ValidationErrorCodes.NotUnique)); + } + + private sealed class SetViews : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["views"] = 5000; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AHookThatMakesAnUnreadableFieldInvalidFailsWithoutNamingIt() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, SetViews>()); + var entry = await database.CreateAsync(Editor, Titled("T")); + var role = database.Database.AddRoleForTest("Writer", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + var refused = await Should.ThrowAsync(() => database.PatchAsync(WithRole(role), IdOf(entry), Titled("Changed"), 1)); + + var error = refused.Errors.ShouldHaveSingleItem(); + error.Path.ShouldBe(""); + error.Code.ShouldBe(ValidationErrorCodes.InvalidValue); + error.Message.ShouldNotContain("views"); + error.Message.ShouldNotContain("5000"); + error.Message.ShouldNotContain("1000"); + } + + [Fact] + public async Task AnInvalidValueAClientSentIsReportedEvenWhenItCannotReadTheField() + { + var entry = await _database.CreateAsync(Editor, Titled("T")); + var role = _database.Database.AddRoleForTest("Writer", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + (await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), IdOf(entry), new() { ["views"] = 5000 }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + } + + private sealed class ChangePrevious : IBeforeUpdateHook + { + public ValueTask BeforeUpdateAsync(HookContext context, CancellationToken cancellationToken) + { + context.Previous!["views"] = context.Entry["views"]; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AHookCannotChangeTheStoredStateTheWriteComparesWith() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, ChangePrevious>()); + var entry = await database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 }); + var id = IdOf(entry); + await database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + database.Read(context => context.Documents.Where(document => document.DocumentId == id).ExecuteUpdate(set => set.SetProperty(document => document.Status, EntryStatus.Published))); + + await database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 1); + + database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == id && document.Locale == "fr").Status).ShouldBe(EntryStatus.Modified); + } + + [Fact] + public async Task ARoleOfOneSiteWritesAGlobalTypeOnlyFromThatSite() + { + var role = _database.Database.AddRoleForTest("Site A", QueryDatabase.SiteA, ("content.tag.create", null), ("content.tag.read", null), ("content.tag.update", null), ("content.tag.delete", null)); + var inA = WithRole(role); + + var tag = await _database.CreateAsync(inA, new() { ["name"] = "Net" }, "tag"); + await Should.ThrowAsync(() => _database.CreateAsync(inA, new() { ["name"] = "Other" }, "tag", site: SiteB)); + await Should.ThrowAsync(() => _database.UpdateAsync(inA, IdOf(tag), new() { ["name"] = "x" }, 1, "tag", site: SiteB)); + + (await _database.UpdateAsync(inA, IdOf(tag), new() { ["name"] = "Changed" }, 1, "tag"))["name"].ShouldBe("Changed"); + (await _database.GetAsync(Editor, IdOf(tag), type: "tag", site: SiteB))!["name"].ShouldBe("Changed"); + } + + [Fact] + public void FallbacksIsAReservedFieldName() + { + Should.Throw(() => SampleSchema.NewBuilder().AddCollection("page", type => type.Field("fallbacks", "text")).Build()); + } + + [Fact] + public async Task WhatAFallbackServedDoesNotComeBackAsAValue() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "English" }); + var id = IdOf(entry); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + + var read = (await _database.GetAsync(Editor, id, locale: "fr"))!; + read["subtitle"].ShouldBe("English"); + read.ContainsKey("fallbacks").ShouldBeTrue(); + + var saved = await _database.UpdateAsync(Editor, id, read, 1, locale: "fr"); + + saved["version"].ShouldBe(2); + _database.Row("cms_article_i18n", id, "fr")["subtitle"].ShouldBeNull(); + JsonNode.Parse(_database.Versions(id).Last(version => version.Locale == "fr").Snapshot)!["subtitle"].ShouldBeNull(); + } + + [Fact] + public async Task APatchIgnoresFallbacksAndKeepsTheFieldsItNames() + { + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Sub" }); + + var patched = await _database.PatchAsync(Editor, IdOf(entry), new() + { + ["fallbacks"] = new ContentDocument { ["subtitle"] = "fr" }, + ["views"] = 3, + }, 1); + + patched["subtitle"].ShouldBe("Sub"); + patched["views"].ShouldBe(3L); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs index b3ec813bc..706383f3e 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs @@ -46,11 +46,21 @@ public async Task AQueryMapsToAPagedResult() [Fact] public async Task AReadWithoutASiteOrLocaleStaysInTheOperation() { - await _database.CreateAsync(Editor, Titled("In A")); + var query = new ContentQuery { Status = QueryStatus.Draft }; + query.Site.ShouldBeNull(); + query.Locale.ShouldBeNull(); + + await _database.CreateAsync(Editor, Titled("In A"), site: SiteA); await _database.CreateAsync(Editor, Titled("In B"), site: SiteB); + await _database.CreateAsync(Editor, Titled("In French"), locale: "fr", site: SiteA); + + (await _database.QueryAsync(Editor, query, locale: "en", site: SiteA)).Items.ShouldHaveSingleItem()["title"].ShouldBe("In A"); + (await _database.QueryAsync(Editor, query, locale: "en", site: SiteB)).Items.ShouldHaveSingleItem()["title"].ShouldBe("In B"); + var french = (await _database.QueryAsync(Editor, query, locale: "fr", site: SiteA)).Items.ShouldHaveSingleItem(); + french["title"].ShouldBe("In French"); + french["locale"].ShouldBe("fr"); - (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft })).Items.ShouldHaveSingleItem()["title"].ShouldBe("In A"); - (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Draft }, site: SiteB)).Items.ShouldHaveSingleItem()["title"].ShouldBe("In B"); + (await _database.AsAsync(Editor, services => services.GetRequiredService().CountAsync("article", null, QueryStatus.Draft, Token), locale: "en", site: SiteB)).ShouldBe(1); } [Fact] diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceTransactionTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceTransactionTests.cs new file mode 100644 index 000000000..6f75c3f1f --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceTransactionTests.cs @@ -0,0 +1,210 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceTransactionTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private sealed class Recorder : IEntryChangeSink + { + public List Changes { get; } = []; + + public ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) + { + Changes.Add(change); + return ValueTask.CompletedTask; + } + } + + private sealed class Throwing : IEntryChangeSink + { + public ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) => throw new InvalidOperationException("The sink is down."); + } + + private sealed class Cancelling(CancellationTokenSource source) : IEntryChangeSink + { + public async ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) + { + await source.CancelAsync(); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + [Fact] + public async Task ARefusedWriteCaughtInsideAnOuterTransactionLeavesNothingToCommit() + { + using var database = new ServiceDatabase(cms => cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region"))); + var entry = await database.CreateAsync(Editor, new() { ["title"] = "T", ["region"] = "eu" }); + var id = IdOf(entry); + var role = database.Database.AddRoleForTest("Regional", ("content.article.read", new PermissionConstraints { RowRule = "region" }), ("content.article.update", new PermissionConstraints { RowRule = "region" })); + var regional = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + + await database.AsAsync(regional, async services => + { + var service = services.GetRequiredService(); + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + + await Should.ThrowAsync(() => service.PatchAsync("article", id, new() { ["region"] = "us", ["subtitle"] = "Refused" }, 1, Token)); + var kept = await service.PatchAsync("article", id, new() { ["subtitle"] = "Kept" }, 1, Token); + + await transaction.CommitAsync(Token); + return kept; + }); + + var stored = (await database.GetAsync(Editor, id))!; + stored["region"].ShouldBe("eu"); + stored["subtitle"].ShouldBe("Kept"); + stored["version"].ShouldBe(2); + database.Versions(id).Count.ShouldBe(2); + } + + [Fact] + public async Task AWriteInATransactionSomeoneElseBeganIsNeverTold() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + + var (rolledBack, next) = await database.AsAsync(Editor, async services => + { + var context = services.GetRequiredService(); + var service = services.GetRequiredService(); + Guid first; + await using (var transaction = await context.Database.BeginTransactionAsync(Token)) + { + first = IdOf(await service.CreateAsync("article", Titled("Rolled back"), Token)); + recorder.Changes.ShouldBeEmpty(); + await transaction.RollbackAsync(Token); + } + + var second = IdOf(await service.CreateAsync("article", Titled("Next"), Token)); + return (first, second); + }); + + recorder.Changes.ShouldHaveSingleItem().Id.ShouldBe(next); + recorder.Changes.ShouldNotContain(change => change.Id == rolledBack); + + // The end of that transaction cannot be seen, so a commit is not told either. + recorder.Changes.Clear(); + await database.AsAsync(Editor, async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + await services.GetRequiredService().CreateAsync("article", Titled("Committed"), Token); + await transaction.CommitAsync(Token); + return true; + }); + + recorder.Changes.ShouldBeEmpty(); + database.Read(context => context.Documents.Count()).ShouldBe(2); + } + + [Fact] + public async Task WritesInTheServicesOwnTransactionAreToldAfterItCommits() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + + await database.AsAsync(Editor, async services => + { + var service = services.GetRequiredService(); + return await service.InTransactionAsync( + async () => + { + await service.CreateAsync("article", Titled("One"), Token); + await Should.ThrowAsync(() => service.CreateAsync("article", new() { ["views"] = 5000 }, Token)); + await service.CreateAsync("article", Titled("Two"), Token); + recorder.Changes.ShouldBeEmpty(); + return true; + }, + Token); + }); + + recorder.Changes.Count.ShouldBe(2); + database.Read(context => context.Documents.Count()).ShouldBe(2); + } + + [Fact] + public async Task WritesInTheServicesOwnTransactionAreNotToldWhenItRollsBack() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + + await Should.ThrowAsync(() => database.AsAsync(Editor, async services => + { + var service = services.GetRequiredService(); + return await service.InTransactionAsync( + async () => + { + await service.CreateAsync("article", Titled("One"), Token); + throw new InvalidOperationException("Not this time."); + }, + Token); + })); + + recorder.Changes.ShouldBeEmpty(); + database.Read(context => context.Documents.Count()).ShouldBe(0); + } + + [Fact] + public async Task AThrowingSinkDoesNotFailACommittedWriteAndTheOthersStillHear() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => + { + cms.Services.AddSingleton(); + cms.Services.AddSingleton(recorder); + }); + + var entry = await database.CreateAsync(Editor, Titled("Hello")); + + entry["title"].ShouldBe("Hello"); + recorder.Changes.ShouldHaveSingleItem(); + database.Read(context => context.Documents.Count()).ShouldBe(1); + } + + [Fact] + public async Task ACallerCancelledAfterTheCommitStillGetsItsResult() + { + using var source = CancellationTokenSource.CreateLinkedTokenSource(Token); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(new Cancelling(source))); + + var entry = await database.AsAsync(Editor, services => services.GetRequiredService().CreateAsync("article", Titled("Hello"), source.Token)); + + source.IsCancellationRequested.ShouldBeTrue(); + entry["title"].ShouldBe("Hello"); + } + + [Fact] + public async Task DeleteAndRestoreTellOneChangePerLocale() + { + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + var entry = await database.CreateAsync(Editor, Titled("Hello")); + var id = IdOf(entry); + await database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + recorder.Changes.Clear(); + + await database.DeleteAsync(Editor, id); + recorder.Changes.Select(change => (change.Kind, change.Locale)).ShouldBe([(EntryChangeKind.Trashed, "en"), (EntryChangeKind.Trashed, "fr")], ignoreOrder: true); + + recorder.Changes.Clear(); + await database.RestoreAsync(Editor, id); + recorder.Changes.Select(change => (change.Kind, change.Locale)).ShouldBe([(EntryChangeKind.Restored, "en"), (EntryChangeKind.Restored, "fr")], ignoreOrder: true); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs index 814cbe315..672c178d3 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs @@ -658,12 +658,18 @@ public async Task AddingALocaleNeedsTheExpectedVersionZeroAndKeepsTheOwner() [Fact] public async Task AGrantLimitedToALocaleWritesOnlyThatLocale() { - var entry = await _database.CreateAsync(Editor, Titled("Hello")); + var entry = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5 }); var role = _database.Database.AddRoleForTest("Translator", ("content.article.update", new PermissionConstraints { Locales = ["fr"] }), ("content.article.read", null)); var translator = WithRole(role); - await Should.NotThrowAsync(() => _database.UpdateAsync(translator, IdOf(entry), Titled("Bonjour"), 0, locale: "fr")); + var french = await _database.UpdateAsync(translator, IdOf(entry), Titled("Bonjour"), 0, locale: "fr"); + french["title"].ShouldBe("Bonjour"); + french["views"].ShouldBe(5L); + await Should.ThrowAsync(() => _database.PatchAsync(translator, IdOf(entry), Titled("Changed"), 1)); + (await Should.ThrowAsync(() => _database.PatchAsync(translator, IdOf(entry), new() { ["views"] = 6 }, 1, locale: "fr"))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("views"); + (await _database.GetAsync(Editor, IdOf(entry)))!["views"].ShouldBe(5L); } [Fact] @@ -734,5 +740,11 @@ public async Task TheChangeSinkHearsOnlyWhatCommitted() recorder.Changes.Select(change => change.Kind).ShouldBe([EntryChangeKind.DraftSaved, EntryChangeKind.Trashed, EntryChangeKind.Restored]); recorder.Changes[0].Version.ShouldBe(1); + + // A write that was refused after it reached the tables is not told either. + recorder.Changes.Clear(); + var role = database.Database.AddRoleForTest("Regional", ("content.article.create", new PermissionConstraints { RowRule = "nothing" })); + await Should.ThrowAsync(() => database.CreateAsync(WithRole(role), Titled("Refused"))); + recorder.Changes.ShouldBeEmpty(); } } diff --git a/tests/FluentCMS.Sdk.Tests/Schema/ContentItemTests.cs b/tests/FluentCMS.Sdk.Tests/Schema/ContentItemTests.cs index 6613bc818..eb53f3d9c 100644 --- a/tests/FluentCMS.Sdk.Tests/Schema/ContentItemTests.cs +++ b/tests/FluentCMS.Sdk.Tests/Schema/ContentItemTests.cs @@ -15,10 +15,10 @@ private sealed class Article : ContentItem } [Fact] - public void ReservedFieldNamesAreTheNineSystemFields() + public void ReservedFieldNamesAreTheNineSystemFieldsAndFallbacks() { ContentItem.ReservedFieldNames.Order(StringComparer.Ordinal).ShouldBe( - ["createdAt", "createdBy", "id", "locale", "publishedAt", "status", "updatedAt", "updatedBy", "version"]); + ["createdAt", "createdBy", "fallbacks", "id", "locale", "publishedAt", "status", "updatedAt", "updatedBy", "version"]); } [Fact] From 2605f586501ff6cb383cba3fb288e218faeb2072 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 15:47:41 -0500 Subject: [PATCH 3/4] Close the second review round on the content service --- .../Authorization/AuthorizationEvaluator.cs | 2 +- .../Authorization/EffectivePermissions.cs | 15 +- .../Authorization/IAuthorizationEvaluator.cs | 4 +- .../Content/ContentEntryMapper.cs | 4 +- src/FluentCMS.Core/Content/ContentService.cs | 217 +++++-- src/FluentCMS.Core/Content/EntryComposer.cs | 13 +- src/FluentCMS.Core/Content/EntryErrors.cs | 34 +- src/FluentCMS.Core/Content/EntryStore.cs | 111 +++- .../Localization/LocaleFallbackReader.cs | 2 +- .../ContentTableReaches.cs | 2 +- .../Content/ContentServiceCarryTests.cs | 2 +- .../Content/ContentServiceLimitsTests.cs | 14 - .../Content/ContentServiceSecondRoundTests.cs | 583 ++++++++++++++++++ .../Content/ContentServiceWriteTests.cs | 2 +- 14 files changed, 880 insertions(+), 125 deletions(-) create mode 100644 tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index f037b2c5a..fbf3c3abe 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -97,7 +97,7 @@ public async ValueTask WritableFieldsAsync( foreach (var grant in permissions.GrantsOf(type, operation, target)) { - if (operation != ContentOperation.Create && grant.Constraints.RowRule is { } name) + if ((operation != ContentOperation.Create || entry is not null) && grant.Constraints.RowRule is { } name) { if (!matched.TryGetValue(name, out var matches)) { diff --git a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs index 5c035ae83..e22b59cfa 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -20,6 +20,9 @@ public bool Applies(string permission, AccessTarget target) => && (Constraints.Sites is null || (target.Site is { } site && Constraints.Sites.Contains(site))) && (Constraints.Locales is null || (!string.IsNullOrEmpty(target.Locale) && Constraints.Locales.Contains(target.Locale, StringComparer.OrdinalIgnoreCase))); + // A global entry is shared by every site, so what changes it must come from a grant that no site limits. + public bool IsInstanceWide => RoleSite is null && Constraints.Sites is null; + // A field that is not localizable has one value for all the locales of an entry, so a grant limited to some of them // may change it only when that covers them all. public bool ReachesEveryLocale(AccessTarget target) => @@ -54,7 +57,7 @@ public bool Allows(string permission, AccessTarget target) } public bool Allows(SchemaType type, ContentOperation operation, AccessTarget target) => - Allows(CorePermissions.Content(type.Name, operation), target); + IsSystem || GrantsOf(type, operation, target).Count > 0; // The roles must allow reading the type. On top of that a Delivery principal needs a preview key or the draft // scope, and a managing one is bounded by its roles alone (spec 4.8). @@ -66,7 +69,13 @@ public bool CanReadDraft(SchemaType type, AccessTarget target) => public IReadOnlyList GrantsOf(SchemaType type, ContentOperation operation, AccessTarget target) { var permission = CorePermissions.Content(type.Name, operation); - return ScopeAllows(permission) ? [.. grants.Where(grant => grant.Applies(permission, target))] : []; + return ScopeAllows(permission) ? [.. Applying(type, operation, permission, target)] : []; + } + + private IEnumerable Applying(SchemaType type, ContentOperation operation, string permission, AccessTarget target) + { + var writesEverySite = type.Scope == ContentScope.Global && operation != ContentOperation.Read; + return grants.Where(grant => grant.Applies(permission, target) && (!writesEverySite || grant.IsInstanceWide)); } // Null when every field is readable. A response has one set of fields for all its rows, and rows are the union of @@ -151,7 +160,7 @@ public bool CanReadField(SchemaType type, string field, AccessTarget target) } SortedSet names = new(StringComparer.Ordinal); - foreach (var grant in grants.Where(grant => grant.Applies(permission, target))) + foreach (var grant in Applying(type, operation, permission, target)) { if (grant.Constraints.RowRule is not { } name) { diff --git a/src/FluentCMS.Core/Authorization/IAuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/IAuthorizationEvaluator.cs index a21a366f6..ec7943869 100644 --- a/src/FluentCMS.Core/Authorization/IAuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/IAuthorizationEvaluator.cs @@ -23,8 +23,8 @@ ValueTask CanAccessEntryAsync( CancellationToken cancellationToken = default); // Which fields a write may touch: a field counts when a grant that allows it also matches the entry. A create has - // no entry yet, so a row rule on its grant cannot hold anything back; any other write without an entry gets only - // what grants with no row rule allow. + // no entry at first, so a row rule on its grant holds nothing back until it is asked again with the new entry; any + // other write without an entry gets only what grants with no row rule allow. ValueTask WritableFieldsAsync( OperationContext context, SchemaType type, diff --git a/src/FluentCMS.Core/Content/ContentEntryMapper.cs b/src/FluentCMS.Core/Content/ContentEntryMapper.cs index 02fa0472f..62547681e 100644 --- a/src/FluentCMS.Core/Content/ContentEntryMapper.cs +++ b/src/FluentCMS.Core/Content/ContentEntryMapper.cs @@ -55,13 +55,13 @@ public void CopyTo(object entry, ContentDocument document) // The document of a typed write: a property with no value is not named, because a class cannot leave a field out. // Nor is one that include turns down, at the top level. A hidden property holds nothing a read gave it, and a - // read-only one is not the client's to send, at any level; only the system principal sends a read-only value. + // read-only one is not the client's to send, at any level. The system principal sends both. public ContentDocument ToDocument(object entry, Func include, bool isSystem) { ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(include); - bool Inside(SchemaField field) => !field.Hidden && (isSystem || !field.ReadOnly); + bool Inside(SchemaField field) => isSystem || (!field.Hidden && !field.ReadOnly); return ToFields(entry, field => Inside(field) && include(field), Inside); } diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index ca436aea5..4aebd6370 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -109,7 +109,7 @@ public async Task CreateAsync(string type, ContentDocument entr var row = await InTransactionAsync( async () => { - var sent = WithoutFallbacks(entry, isPatch: false); + var sent = WithoutFallbacks(entry); var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, null, write.Target, cancellationToken); Reject(write, sent, writable); @@ -142,17 +142,23 @@ public async Task CreateAsync(string type, ContentDocument entr } var working = Prepare(write, composed, document, null); - working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, null, writable, document, cancellationToken); + var prepared = EntryJson.Fields(write.Type, working); + working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, new SaveContext(null, Named(sent), writable, document), cancellationToken); - store.AddDocument(document); - await store.WriteDraftAsync(write.Type, document, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); + await store.WriteDraftAsync(write.Type, document, isNewDocument: true, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); - // A row rule on a create grant has no entry to read until now. + // A row rule on a create grant has no entry to read until now. Each grant then answers for its own + // fields (spec 4.8): what was written must be allowed by a grant whose rule holds for this entry. if (!await evaluator.CanAccessEntryAsync(write.Operation, write.Type, ContentOperation.Create, id, write.Target, QueryStatus.Draft, cancellationToken)) { throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Create)); } + var allowed = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, id, write.Target, cancellationToken); + Reject(write, sent, allowed); + var fields = EntryJson.Fields(write.Type, working); + write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(prepared[name], fields[name])), write.Type, allowed); + await hooks.RunAsync(HookKind.AfterCreate, write.Type, write.Operation, working, null, cancellationToken); _pending.Add(new EntryChange(EntryChangeKind.DraftSaved, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); return document; @@ -405,8 +411,8 @@ internal async Task InTransactionAsync(Func> work, CancellationTok // On SQLite this takes the write lock at once, so a second writer waits here and then sees the first one's version. var committed = false; T result; - _ownsTransaction = true; var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + _ownsTransaction = true; await using (transaction) { try @@ -420,7 +426,6 @@ internal async Task InTransactionAsync(Func> work, CancellationTok _ownsTransaction = false; if (!committed) { - context.ChangeTracker.Clear(); _pending.Clear(); } } @@ -446,12 +451,18 @@ private async Task InSavepointAsync(IDbContextTransaction outer, Func SaveAsync(string typeName, Guid id, ContentD var writable = await evaluator.WritableFieldsAsync(write.Operation, type, ContentOperation.Update, id, target, cancellationToken); var stored = await store.ReadAsync(type, document, ContentState.Draft, cancellationToken); - var sent = WithoutFallbacks(changes, isPatch); + var sent = WithoutFallbacks(changes); + if (!isPatch) + { + sent = await WithoutFallbackEchoesAsync(write, id, sent, stored, cancellationToken); + } - // A full update answers what a read showed, so what that read left out is not the client's to change. - var shown = isPatch ? null : await ReadDraftOrNullAsync(write, id, addsLocale ? documents[0].Locale : write.Locale, cancellationToken); + // A write answers what a read showed, so what that read left out is not the client's to change. + var shown = NeedsWhatARead(write, sent, isPatch) ? await ReadShownAsync(write, id, documents, addsLocale, cancellationToken) : null; var refused = isPatch ? sent : WithoutUnchanged(write, sent, shown, writable, addsLocale); Reject(write, refused, writable); @@ -548,12 +563,9 @@ private async Task SaveAsync(string typeName, Guid id, ContentD : EntryComposer.Replace(type, stored, refused, field => Keeps(write, field, writable, addsLocale)); var working = Prepare(write, composed, document, stored); composer.CarryProtected(type, stored, working, includingGroups: !isPatch || !write.Permissions.IsSystem); - if (!isPatch) - { - KeepUnreadableTargets(type, stored, shown, working); - } + await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch, cancellationToken); - working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, stored, writable, document, cancellationToken); + working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, Named(refused), writable, document), cancellationToken); var sharedChanged = type.Localized && type.Fields.Any(field => !field.Localizable && !EntryJson.Same(working[field.Name], stored[field.Name])); var next = expectedVersion + 1; @@ -561,7 +573,6 @@ private async Task SaveAsync(string typeName, Guid id, ContentD if (addsLocale) { document.DraftVersion = next; - store.AddDocument(document); } else if (await store.AdvanceVersionAsync(id, document.Locale, expectedVersion, status, write.Operation.Timestamp, write.Operation.Principal.Id, cancellationToken)) { @@ -575,7 +586,7 @@ private async Task SaveAsync(string typeName, Guid id, ContentD throw await ConflictAsync(write, id, expectedVersion, await store.DraftVersionAsync(id, document.Locale, cancellationToken), cancellationToken); } - await store.WriteDraftAsync(type, document, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); + await store.WriteDraftAsync(type, document, isNewDocument: addsLocale, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); if (sharedChanged) { await store.MarkOtherLocalesModifiedAsync(id, document.Locale, cancellationToken); @@ -624,13 +635,38 @@ private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument chang return result; } - // What a read gave comes back with the targets it may not read missing. They are not the client's to remove: a - // multiple relation keeps them after what was sent, and a single one sent empty keeps its target. A patch names - // what it changes, so an explicit null there still clears. - private void KeepUnreadableTargets(SchemaType type, ContentDocument stored, ContentDocument? shown, ContentDocument working) + private bool NeedsWhatARead(WriteScope write, ContentDocument sent, bool isPatch) => + !write.Permissions.IsSystem + && (!isPatch || FieldStorage.Of(schema, write.Type).Any(item => item.Kind == FieldStorageKind.Reference && item.Field.Multiple && sent.ContainsKey(QueryPaths.ApiPath(write.Type, item).Split('.')[0]))); + + // A locale being added has no row to read, so this reads a locale it has for the shared fields. One that has since + // been disabled cannot be read, and the next one may. + private async Task ReadShownAsync(WriteScope write, Guid id, List documents, bool addsLocale, CancellationToken cancellationToken) + { + foreach (var locale in addsLocale ? documents.Select(document => document.Locale) : [write.Locale]) + { + if (await ReadDraftOrNullAsync(write, id, locale, LocaleFallback.None, cancellationToken) is { } shown) + { + return shown; + } + } + + return null; + } + + // What a read gave comes back with the targets it may not read missing. They are not the client's to remove, so each + // goes back where it was in a multiple relation, which leaves a list sent as it was read unchanged, and a single + // relation sent empty keeps its target. A patch names what it changes, so an explicit null on a single one clears. + // A target that no longer exists is nothing to keep, and the system principal reads everything and keeps nothing. + private async Task KeepUnreadableTargetsAsync(WriteScope write, ContentDocument stored, ContentDocument? shown, ContentDocument working, bool isPatch, CancellationToken cancellationToken) { + if (write.Permissions.IsSystem) + { + return; + } + ContentDocument seen = []; - foreach (var field in type.Fields) + foreach (var field in write.Type.Fields) { if (shown is not null && shown.TryGetValue(field.Name, out var visible) && canonicalizer.TryRead(field, visible, out var read)) { @@ -638,22 +674,32 @@ private void KeepUnreadableTargets(SchemaType type, ContentDocument stored, Cont } } - foreach (var item in FieldStorage.Of(schema, type).Where(item => item.Kind == FieldStorageKind.Reference)) + foreach (var item in FieldStorage.Of(schema, write.Type).Where(item => item.Kind == FieldStorageKind.Reference && (!isPatch || item.Field.Multiple))) { - var kept = IdsOf(EntryPaths.ValueAt(stored, item, type)).Except(IdsOf(EntryPaths.ValueAt(seen, item, type))).ToList(); - if (kept.Count == 0) + var before = IdsOf(EntryPaths.ValueAt(stored, item, write.Type)); + var unseen = before.Except(IdsOf(EntryPaths.ValueAt(seen, item, write.Type))).ToList(); + if (unseen.Count == 0) { continue; } - var sent = IdsOf(EntryPaths.ValueAt(working, item, type)); + var kept = await store.ExistingTargetsAsync(item.Field, unseen, cancellationToken); + var sent = IdsOf(EntryPaths.ValueAt(working, item, write.Type)); if (item.Field.Multiple) { - EntryPaths.SetAt(working, item, type, sent.Concat(kept.Except(sent)).ToArray()); + for (var index = 0; index < before.Count; index++) + { + if (kept.Contains(before[index]) && !sent.Contains(before[index])) + { + sent.Insert(Math.Min(index, sent.Count), before[index]); + } + } + + EntryPaths.SetAt(working, item, write.Type, sent.ToArray()); } - else if (sent.Count == 0) + else if (sent.Count == 0 && kept.Count > 0) { - EntryPaths.SetAt(working, item, type, kept[0]); + EntryPaths.SetAt(working, item, write.Type, kept[0]); } } } @@ -665,27 +711,58 @@ private void KeepUnreadableTargets(SchemaType type, ContentDocument stored, Cont _ => [], }; - // What a read adds is no value to write. On a full update, a field it names came from another locale, so it was not sent. - private static ContentDocument WithoutFallbacks(ContentDocument changes, bool isPatch) + // What a read adds is no value to write. + private static ContentDocument WithoutFallbacks(ContentDocument changes) { - if (!changes.TryGetValue(LocaleFallbackReader.FallbacksKey, out var named)) + if (!changes.ContainsKey(LocaleFallbackReader.FallbacksKey)) { return changes; } var result = new ContentDocument(changes); result.Remove(LocaleFallbackReader.FallbacksKey); - if (!isPatch && EntryCanonicalizer.TryReadMap(named, out var map) && map is not null) + return result; + } + + // A full update sends back what a read gave it, and for a field empty in this locale that was another locale's + // value. Sent unchanged it is not a translation, so it counts as not sent; a different value is an edit. A patch + // names what it changes. The chain is only read when there is such a field. + private async Task WithoutFallbackEchoesAsync(WriteScope write, Guid id, ContentDocument sent, ContentDocument stored, CancellationToken cancellationToken) + { + List<(SchemaField Field, object? Read)> candidates = []; + foreach (var field in write.Type.Fields.Where(field => field.Localizable && !field.Hidden && sent.ContainsKey(field.Name))) + { + if (LocaleFallbackReader.IsMissing(stored.GetValueOrDefault(field.Name)) + && canonicalizer.TryRead(field, sent[field.Name], out var read) + && !LocaleFallbackReader.IsMissing(read)) + { + candidates.Add((field, read)); + } + } + + if (candidates.Count == 0 + || await ReadDraftOrNullAsync(write, id, write.Locale, LocaleFallback.Chain, cancellationToken) is not { } served + || served.GetValueOrDefault(LocaleFallbackReader.FallbacksKey) is not ContentDocument fallenBack) { - foreach (var name in map.Keys) + return sent; + } + + var result = new ContentDocument(sent); + foreach (var (field, read) in candidates) + { + if (fallenBack.ContainsKey(field.Name) + && canonicalizer.TryRead(field, served.GetValueOrDefault(field.Name), out var chain) + && JsonNode.DeepEquals(Visible(field, read), Visible(field, chain))) { - result.Remove(name); + result.Remove(field.Name); } } return result; } + private static HashSet Named(ContentDocument sent) => new(sent.Keys, StringComparer.Ordinal); + private JsonNode? Visible(SchemaField field, object? value) { var node = EntryJson.ToNode(value); @@ -738,22 +815,21 @@ private ContentDocument Prepare(WriteScope write, ContentDocument composed, Docu // a value past it. A hook is held to the principal's field limits as well, except on a hidden or read-only field, // which no client can reach and which is what a hook is for. Hooks get a copy of the stored state, so they cannot // change what the write compares with. - private async Task RunBeforeHooksAsync(WriteScope write, HookKind before, ContentDocument working, ContentDocument? stored, WritableFields writable, Document document, CancellationToken cancellationToken) + private async Task RunBeforeHooksAsync(WriteScope write, HookKind before, ContentDocument working, SaveContext save, CancellationToken cancellationToken) { var sent = EntryJson.Fields(write.Type, working); - var was = stored is null ? null : EntryJson.Fields(write.Type, stored); - await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, CopyOf(stored), cancellationToken); - working = AfterHook(write, working, sent, writable, document, stored); - await ValidateAsync(write, working, document.DocumentId, was, sent, cancellationToken); + await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, CopyOf(save.Stored), cancellationToken); + working = AfterHook(write, working, sent, save); + await ValidateAsync(write, working, sent, save, cancellationToken); var validated = EntryJson.Fields(write.Type, working); - await hooks.RunAsync(before, write.Type, write.Operation, working, CopyOf(stored), cancellationToken); - working = AfterHook(write, working, validated, writable, document, stored); + await hooks.RunAsync(before, write.Type, write.Operation, working, CopyOf(save.Stored), cancellationToken); + working = AfterHook(write, working, validated, save); if (!JsonNode.DeepEquals(validated, EntryJson.Fields(write.Type, working))) { - await ValidateAsync(write, working, document.DocumentId, was, sent, cancellationToken); + await ValidateAsync(write, working, sent, save, cancellationToken); } return working; @@ -761,16 +837,17 @@ private async Task RunBeforeHooksAsync(WriteScope write, HookKi private static ContentDocument? CopyOf(ContentDocument? stored) => stored is null ? null : ContentCopy.Of(stored); - private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, WritableFields writable, Document document, ContentDocument? stored) + private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, SaveContext save) { - var after = Prepare(write, working, document, stored); + var after = Prepare(write, working, save.Document, save.Stored); var fields = EntryJson.Fields(write.Type, after); - write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(baseline[name], fields[name])), write.Type, writable); + write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(baseline[name], fields[name])), write.Type, save.Writable); return after; } - private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid id, JsonObject? was, JsonObject sent, CancellationToken cancellationToken) + private async Task ValidateAsync(WriteScope write, ContentDocument working, JsonObject sent, SaveContext save, CancellationToken cancellationToken) { + var id = save.Document.DocumentId; var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, working) : null; var values = mapper.ValidationValues(write.Type, working); var checkedUnique = false; @@ -788,14 +865,14 @@ private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid }); var now = EntryJson.Fields(write.Type, working); - var errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Reportable(result.Errors, now, was, sent); + var errors = await ReportableAsync(write, result.Errors, now, sent, save, cancellationToken); // The pipeline skips uniqueness after an error, and a dropped error is no reason to. if (errors.Count == 0 && !checkedUnique) { var context = new ValidationContext(write.Type, values, write.Operation, ValidationMode.Draft, entry); await store.CheckUniqueAsync(write.Type, working, id, write.Site, write.Locale, context, cancellationToken); - errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Reportable(context.Errors, now, was, sent); + errors = await ReportableAsync(write, context.Errors, now, sent, save, cancellationToken); } if (errors.Count > 0) @@ -804,6 +881,33 @@ private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid } } + private async Task> ReportableAsync(WriteScope write, IReadOnlyList errors, JsonObject now, JsonObject sent, SaveContext save, CancellationToken cancellationToken) + { + var (reportable, toCompare, produced) = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Sort(errors, now, sent, save.Named); + if (!produced && toCompare.Count > 0) + { + var before = save.Stored is { } stored ? await ValidateStoredAsync(write, stored, save.Document.DocumentId, cancellationToken) : []; + produced = toCompare.Any(error => !before.Any(old => old.Path == error.Path && old.Code == error.Code)); + } + + if (produced) + { + reportable.Add(EntryErrors.ProducedValue); + } + + return reportable; + } + + private async Task> ValidateStoredAsync(WriteScope write, ContentDocument stored, Guid id, CancellationToken cancellationToken) + { + var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, stored) : null; + var values = mapper.ValidationValues(write.Type, stored); + var result = await validation.ValidateAsync(write.Type, values, entry, write.Operation, ValidationMode.Draft, cancellationToken); + var unique = new ValidationContext(write.Type, values, write.Operation, ValidationMode.Draft, entry); + await store.CheckUniqueAsync(write.Type, stored, id, write.Site, write.Locale, unique, cancellationToken); + return [.. result.Errors, .. unique.Errors]; + } + // One entry is one document: it goes to the trash and comes back in every locale, so the principal must hold the // operation on all of them. No row that it may reach reads as no entry, which keeps a client from probing. private async Task<(List Documents, Document Row, ContentDocument Stored)> ReachAllLocalesAsync(WriteScope write, ContentOperation operation, Guid id, bool trashed, CancellationToken cancellationToken) @@ -834,26 +938,29 @@ private async Task ValidateAsync(WriteScope write, ContentDocument working, Guid } private async Task ConflictAsync(WriteScope write, Guid id, int expected, int current, CancellationToken cancellationToken) => - new(expected, current, await ReadDraftOrNullAsync(write, id, write.Locale, cancellationToken)); + new(expected, current, await ReadDraftOrNullAsync(write, id, write.Locale, LocaleFallback.None, cancellationToken)); // A principal may hold a write without the read. It still gets the id and the version, which it needs for its next // write. The write has committed by now, so a cancelled caller does not turn it into a failure. private async Task RespondAsync(WriteScope write, Document row) => - await ReadDraftOrNullAsync(write, row.DocumentId, write.Locale, CancellationToken.None) + await ReadDraftOrNullAsync(write, row.DocumentId, write.Locale, LocaleFallback.None, CancellationToken.None) ?? new ContentDocument { ["id"] = row.DocumentId, ["version"] = row.DraftVersion }; - private async Task ReadDraftOrNullAsync(WriteScope write, Guid id, string locale, CancellationToken cancellationToken) + private async Task ReadDraftOrNullAsync(WriteScope write, Guid id, string locale, LocaleFallback fallbacks, CancellationToken cancellationToken) { try { - return await GetAsync(write.Type.Name, id, new GetOptions { Status = QueryStatus.Draft, Locale = string.IsNullOrEmpty(locale) ? write.Locale : locale, Fallback = LocaleFallback.None }, everySystemField: false, cancellationToken); + return await GetAsync(write.Type.Name, id, new GetOptions { Status = QueryStatus.Draft, Locale = string.IsNullOrEmpty(locale) ? write.Locale : locale, Fallback = fallbacks }, everySystemField: false, cancellationToken); } - catch (ValidationException exception) when (exception.Errors.All(error => error.Path is "type" or "status")) + catch (ValidationException exception) when (exception.Errors.All(error => error.Path is "type" or "status" or "locale")) { return null; } } + // What the steps of one save share. + private sealed record SaveContext(ContentDocument? Stored, IReadOnlySet Named, WritableFields Writable, Document Document); + private sealed record WriteScope(OperationContext Operation, SchemaType Type, SiteId Site, string Locale, AccessTarget Target, EffectivePermissions Permissions, FieldAccess Access) { public string DocumentLocale => Type.Localized ? Locale : ""; diff --git a/src/FluentCMS.Core/Content/EntryComposer.cs b/src/FluentCMS.Core/Content/EntryComposer.cs index 197ba8291..b3ad58f49 100644 --- a/src/FluentCMS.Core/Content/EntryComposer.cs +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -113,8 +113,8 @@ private static JsonObject MergeJson(JsonNode? stored, JsonObject patch) // What a client cannot see or change inside a component or a block stays when it sends the rest back, even when it // sends no component at all. A list has no ids, so an incoming block keeps the values of the stored block of the - // same type whose visible content is identical, each used once, and what is left pairs up in order. A block both - // moved and edited can still pair with the wrong one. A single component is left alone on a patch only for the + // same type whose visible content is identical, each used once, and what is left pairs up in order within its type. + // A block both moved and edited can still pair with the wrong one. A single component is left alone on a patch only for the // system principal, which may be clearing a value, since a patch merged into a component already keeps the rest. public void CarryProtected(SchemaType type, ContentDocument stored, ContentDocument composed, bool includingGroups) { @@ -207,11 +207,14 @@ private void CarryInside(SchemaField field, JsonNode before, JsonNode after) } } - for (var index = 0; index < Math.Min(oldLeft.Count, newLeft.Count); index++) + foreach (var item in newLeft) { - if (OwnerOf(field, oldLeft[index]) is { } owner && OwnerOf(field, newLeft[index]) == owner) + var owner = OwnerOf(field, item); + var next = oldLeft.FindIndex(old => OwnerOf(field, old) == owner); + if (owner is not null && next >= 0) { - pairs.Add((oldLeft[index], newLeft[index])); + pairs.Add((oldLeft[next], item)); + oldLeft.RemoveAt(next); } } diff --git a/src/FluentCMS.Core/Content/EntryErrors.cs b/src/FluentCMS.Core/Content/EntryErrors.cs index f903fb43a..c092a8762 100644 --- a/src/FluentCMS.Core/Content/EntryErrors.cs +++ b/src/FluentCMS.Core/Content/EntryErrors.cs @@ -8,43 +8,45 @@ namespace FluentCMS.Core.Content; // Which validation errors a principal may hear of. An error names a field and tells something of its value, so one on a -// field it cannot read is only for it if it wrote that value itself. If nobody changed the value, the error is not about -// this write and does not block it. If a hook did, the write fails, without saying on what. +// field it cannot read is only for it if it sent that value itself. Otherwise a hook or a rule across fields produced it: +// a hook's value fails the write without saying on what. For the rest the stored entry decides: the same error there is +// not about this write and does not block it, and an error that is new fails the write in the same way. A value the +// client sent is always reported, or a refusal would say whether it equals the stored one. internal sealed class EntryErrors(SchemaModel schema, SchemaType type, EffectivePermissions permissions, AccessTarget target) { private const string Discriminator = "type"; - private static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); + public static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); - public List Reportable(IReadOnlyList errors, JsonObject now, JsonObject? was, JsonObject sent) + public (List Reportable, List ToCompare, bool Produced) Sort(IReadOnlyList errors, JsonObject now, JsonObject sent, IReadOnlySet named) { - List result = []; + List reportable = []; + List toCompare = []; + var produced = false; foreach (var error in errors) { if (permissions.IsSystem || Describe(error.Path, now) is not { } path || path.Readable) { - result.Add(error); + reportable.Add(error); continue; } - var value = ValueAt(now, error.Path); - if (was is not null && JsonNode.DeepEquals(value, ValueAt(was, error.Path))) + var same = JsonNode.DeepEquals(ValueAt(now, error.Path), ValueAt(sent, error.Path)); + if (same && !path.Hidden && named.Contains(Parse(error.Path)[0].Name)) { - continue; + reportable.Add(error); } - - var fromClient = JsonNode.DeepEquals(value, ValueAt(sent, error.Path)); - if (fromClient && !path.Hidden) + else if (same) { - result.Add(error); + toCompare.Add(error); } - else if (!fromClient && !result.Contains(ProducedValue)) + else { - result.Add(ProducedValue); + produced = true; } } - return result; + return (reportable, toCompare, produced); } // Null when the path is not a field, such as an error on the entry as a whole. diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index f24feefe5..6b2fb5b21 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -11,6 +11,7 @@ using FluentCMS.Schema; using FluentCMS.Sites; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace FluentCMS.Core.Content; @@ -140,8 +141,6 @@ private void FillFields( return stored.Field.FieldType == FieldTypeNames.RichText ? new RichText(json) : JsonNode.Parse(json); } - public void AddDocument(Document row) => context.Documents.Add(row); - // The compare and swap that makes one of two concurrent writers lose. The filter on Document keeps trash out. public async Task AdvanceVersionAsync(Guid id, string locale, int expected, EntryStatus status, DateTimeOffset at, Guid by, CancellationToken cancellationToken) { @@ -174,34 +173,100 @@ public Task SetTrashedAsync(Guid id, DateTimeOffset? at, Guid? by, CancellationT .SetProperty(document => document.DeletedBy, by), cancellationToken); - // Saves the draft of one locale: the shared row, the locale row, the references and the version, in one save. - public async Task WriteDraftAsync(SchemaType type, Document row, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken) + // Saves the draft of one locale: the document when it is new, the shared row, the locale row, the references and the + // version, in one save. The context may hold what a caller has not saved, in a transaction of its own: that is not + // this write's to save, or to lose if this write is rolled back, so it stays pending and only this write's rows go. + public async Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocument, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken) { - var stored = FieldStorage.Of(schema, type).ToList(); + context.ChangeTracker.DetectChanges(); + var known = context.ChangeTracker.Entries().Select(item => item.Entity).ToHashSet(ReferenceEqualityComparer.Instance); + var held = context.ChangeTracker.Entries().Where(item => item.State != EntityState.Unchanged).Select(Hold).ToList(); + try + { + if (isNewDocument) + { + context.Documents.Add(row); + } + + var stored = FieldStorage.Of(schema, type).ToList(); + await WriteRowAsync(StorageNames.Table(type), type, row, null, stored.Where(item => !item.Localized), entry, cancellationToken); + if (type.Localized) + { + await WriteRowAsync(StorageNames.LocaleTable(type), type, row, row.Locale, stored.Where(item => item.Localized), entry, cancellationToken); + } + + await ReplaceReferencesAsync(type, row, stored, entry, cancellationToken); - await WriteRowAsync(StorageNames.Table(type), type, row, null, stored.Where(item => !item.Localized), entry, cancellationToken); - if (type.Localized) + context.Versions.Add(new ContentVersion + { + DocumentId = row.DocumentId, + Locale = row.Locale, + Number = row.DraftVersion, + Snapshot = EntryJson.Text(EntryJson.Fields(type, entry)), + StatusAfter = row.Status, + AuthorId = row.UpdatedBy, + Origin = origin, + ClientId = clientId, + CreatedAt = row.UpdatedAt, + }); + + await context.SaveChangesAsync(cancellationToken); + } + finally { - await WriteRowAsync(StorageNames.LocaleTable(type), type, row, row.Locale, stored.Where(item => item.Localized), entry, cancellationToken); + foreach (var own in context.ChangeTracker.Entries().Where(item => !known.Contains(item.Entity)).ToList()) + { + own.State = EntityState.Detached; + } + + foreach (var pending in held) + { + pending.Restore(); + } } + } - await ReplaceReferencesAsync(type, row, stored, entry, cancellationToken); + private static HeldEntry Hold(EntityEntry entry) + { + var original = entry.State == EntityState.Modified ? entry.OriginalValues.Clone() : null; + var modified = entry.Properties.Where(property => property.IsModified).Select(property => property.Metadata.Name).ToList(); + var state = entry.State; + entry.State = EntityState.Detached; + return new HeldEntry(entry, state, original, modified); + } + + private sealed record HeldEntry(EntityEntry Entry, EntityState State, PropertyValues? Original, List Modified) + { + public void Restore() + { + if (Original is null) + { + Entry.State = State; + return; + } + + Entry.State = EntityState.Unchanged; + Entry.OriginalValues.SetValues(Original); + foreach (var name in Modified) + { + Entry.Property(name).IsModified = true; + } + } + } - context.Versions.Add(new ContentVersion + // Which of these relation targets exist, in the trash too. A media target has no row here, so it counts. + public async Task> ExistingTargetsAsync(SchemaField field, List ids, CancellationToken cancellationToken) + { + if (schema.FindContentType(EntryPaths.ReferenceTarget(field)) is null) { - DocumentId = row.DocumentId, - Locale = row.Locale, - Number = row.DraftVersion, - Snapshot = EntryJson.Text(EntryJson.Fields(type, entry)), - StatusAfter = row.Status, - AuthorId = row.UpdatedBy, - Origin = origin, - ClientId = clientId, - CreatedAt = row.UpdatedAt, - }); - - await context.SaveChangesAsync(cancellationToken); - context.ChangeTracker.Clear(); + return ids; + } + + return await context.Documents.IgnoreQueryFilters().AsNoTracking() + .Where(document => ids.Contains(document.DocumentId)) + .Select(document => document.DocumentId) + .Distinct() + .ToListAsync(cancellationToken); } private async Task WriteRowAsync(string table, SchemaType type, Document document, string? locale, IEnumerable fields, ContentDocument entry, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs b/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs index 69eb772ac..ee06b9ad5 100644 --- a/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs +++ b/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs @@ -197,7 +197,7 @@ private static bool RefusesTheLocale(ValidationError error) => (error.Path, error.Code) is ("type", QueryErrorCodes.UnknownField) or ("status", QueryErrorCodes.DraftNotAllowed); // A draft writes '' and the empty rich text document for what it lacks (spec 4.3), so those count as no value. - private static bool IsMissing(object? value) => value switch + public static bool IsMissing(object? value) => value switch { null => true, string text => text.Length == 0, diff --git a/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs b/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs index c82e9a8b5..c4c07a64c 100644 --- a/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs +++ b/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs @@ -49,7 +49,7 @@ private static bool IsReach(MethodReference called) var declaring = called.DeclaringType.FullName; return called switch { - GenericInstanceMethod { ElementMethod.Name: "Set" } set => set.GenericArguments[0].FullName.StartsWith("System.Collections.Generic.Dictionary`2", StringComparison.Ordinal), + GenericInstanceMethod { ElementMethod.Name: "Set" } set => set.Parameters.Count > 0 || set.GenericArguments[0].FullName.StartsWith("System.Collections.Generic.Dictionary`2", StringComparison.Ordinal), { Name: "get_Database" } => true, { Name: "Rows" } => declaring == typeof(CmsDbContext).FullName, _ => declaring is "Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions" or "Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade" diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs index 849d5d5c5..06a832245 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs @@ -68,7 +68,7 @@ public async Task AMultipleRelationKeepsUnreadableTargetsAlongsideWhatWasSent() await _database.UpdateAsync(principal, id, new() { ["title"] = "T", ["tags"] = new[] { newTag } }, 1); - _database.References(id).Where(row => row.Field == "tags").Select(row => row.TargetId).ShouldBe([newTag, hiddenTag]); + _database.References(id).Where(row => row.Field == "tags").Select(row => row.TargetId).ShouldBe([hiddenTag, newTag]); } [Fact] diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs index 974275fd0..cc270a394 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs @@ -298,20 +298,6 @@ public async Task AHookCannotChangeTheStoredStateTheWriteComparesWith() database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == id && document.Locale == "fr").Status).ShouldBe(EntryStatus.Modified); } - [Fact] - public async Task ARoleOfOneSiteWritesAGlobalTypeOnlyFromThatSite() - { - var role = _database.Database.AddRoleForTest("Site A", QueryDatabase.SiteA, ("content.tag.create", null), ("content.tag.read", null), ("content.tag.update", null), ("content.tag.delete", null)); - var inA = WithRole(role); - - var tag = await _database.CreateAsync(inA, new() { ["name"] = "Net" }, "tag"); - await Should.ThrowAsync(() => _database.CreateAsync(inA, new() { ["name"] = "Other" }, "tag", site: SiteB)); - await Should.ThrowAsync(() => _database.UpdateAsync(inA, IdOf(tag), new() { ["name"] = "x" }, 1, "tag", site: SiteB)); - - (await _database.UpdateAsync(inA, IdOf(tag), new() { ["name"] = "Changed" }, 1, "tag"))["name"].ShouldBe("Changed"); - (await _database.GetAsync(Editor, IdOf(tag), type: "tag", site: SiteB))!["name"].ShouldBe("Changed"); - } - [Fact] public void FallbacksIsAReservedFieldName() { diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs new file mode 100644 index 000000000..f7d2b8592 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs @@ -0,0 +1,583 @@ +using System.Data.Common; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Data.Tests.Query; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ContentServiceRulesAcrossFieldsTests +{ + private sealed class VideoNeedsUrl : IEntryValidator
+ { + public ValueTask ValidateAsync(Article entry, ValidationContext context, CancellationToken cancellationToken) + { + if (entry.Category == ArticleCategory.Video && string.IsNullOrEmpty(entry.VideoUrl)) + { + context.AddError("videoUrl", "X-RULE", "A video needs its url."); + } + + return ValueTask.CompletedTask; + } + } + + private static ServiceDatabase Database() => + new(cms => cms.Services.AddSingleton, VideoNeedsUrl>(), SampleSchema.FromClasses().Build()); + + private static Guid Limited(ServiceDatabase database) => database.Database.AddRoleForTest( + "Limited", + ("content.article.read", new PermissionConstraints { Fields = ["title", "category"] }), + ("content.article.update", new PermissionConstraints { Fields = ["title", "category"] }), + ("content.article.create", new PermissionConstraints { Fields = ["title", "category"] })); + + [Fact] + public async Task ARuleAcrossFieldsCannotBeDodgedByNotBeingAbleToReadTheField() + { + using var database = Database(); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "T", ["category"] = "news" })); + var before = database.Footprint(); + + (await Should.ThrowAsync(() => database.PatchAsync(Editor, id, new() { ["category"] = "video" }, 1))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("videoUrl"), + error => error.Code.ShouldBe("X-RULE")); + + var refused = await Should.ThrowAsync(() => database.PatchAsync(WithRole(Limited(database)), id, new() { ["category"] = "video" }, 1)); + + var produced = refused.Errors.ShouldHaveSingleItem(); + produced.Path.ShouldBe(""); + produced.Code.ShouldBe(ValidationErrorCodes.InvalidValue); + produced.Message.ShouldNotContain("video", Case.Insensitive); + database.Footprint().ShouldBe(before); + (await database.GetAsync(Editor, id))!["category"].ShouldBe("news"); + } + + [Fact] + public async Task ACreateThatBreaksARuleOnAnUnreadableFieldFailsWithoutNamingIt() + { + using var database = Database(); + + var refused = await Should.ThrowAsync(() => database.CreateAsync(WithRole(Limited(database)), new() { ["title"] = "T", ["category"] = "video" })); + + refused.Errors.ShouldHaveSingleItem().Path.ShouldBe(""); + } + + [Fact] + public async Task ARuleAlreadyBrokenInTheStoredEntryDoesNotBlockAnUnrelatedWrite() + { + using var database = Database(); + var id = IdOf(await database.AsAsync(Editor, services => services.GetRequiredService().CreateAsync("article", new() { ["title"] = "T", ["category"] = "news" }, Token))); + database.Read(context => context.Rows("cms_article").Where(row => EF.Property(row, "document_id") == id).ExecuteUpdate(set => set.SetProperty(row => EF.Property(row, "category"), "video"))); + + (await database.PatchAsync(WithRole(Limited(database)), id, new() { ["title"] = "Changed" }, 1))["title"].ShouldBe("Changed"); + } + + [Fact] + public async Task AValueAClientSentIsReportedEvenWhenItEqualsTheStoredOne() + { + using var database = new ServiceDatabase(); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "T", ["views"] = 5 })); + database.Read(context => context.Rows("cms_article").Where(row => EF.Property(row, "document_id") == id).ExecuteUpdate(set => set.SetProperty(row => EF.Property(row, "views"), 5000L))); + var role = database.Database.AddRoleForTest("WriteOnly", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + foreach (var guess in new[] { 6000, 5000 }) + { + (await Should.ThrowAsync(() => database.PatchAsync(WithRole(role), id, new() { ["views"] = guess }, 1))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("views"), + error => error.Code.ShouldBe(ValidationErrorCodes.AboveMaximum)); + } + } +} + +public sealed class ContentServiceFallbackEchoTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private async Task EnglishAndFrenchAsync() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "English" })); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Bonjour" }, 0, locale: "fr"); + return id; + } + + [Fact] + public async Task AnEditToAFieldThatFellBackIsWritten() + { + var id = await EnglishAndFrenchAsync(); + var read = (await _database.GetAsync(Editor, id, locale: "fr"))!; + read.ContainsKey("fallbacks").ShouldBeTrue(); + read["subtitle"] = "Ma traduction"; + + var saved = await _database.UpdateAsync(Editor, id, read, 1, locale: "fr"); + + saved["subtitle"].ShouldBe("Ma traduction"); + _database.Row("cms_article_i18n", id, "fr")["subtitle"].ShouldBe("Ma traduction"); + } + + [Fact] + public async Task WhatTheChainServedComesBackAsNotSentWithOrWithoutTheMap() + { + var id = await EnglishAndFrenchAsync(); + var read = (await _database.GetAsync(Editor, id, locale: "fr"))!; + read.Remove("fallbacks"); + + await _database.UpdateAsync(Editor, id, read, 1, locale: "fr"); + + _database.Row("cms_article_i18n", id, "fr")["subtitle"].ShouldBeNull(); + } + + [Fact] + public async Task AValueAlreadyInThisLocaleIsKeptEvenWhenEqualToTheChain() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "Same" })); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Bonjour", ["subtitle"] = "Same" }, 0, locale: "fr"); + + var read = (await _database.GetAsync(Editor, id, locale: "fr"))!; + await _database.UpdateAsync(Editor, id, read, 1, locale: "fr"); + + _database.Row("cms_article_i18n", id, "fr")["subtitle"].ShouldBe("Same"); + } + + [Fact] + public async Task APatchIsExplicitAndWritesTheSameTextAsTheChain() + { + var id = await EnglishAndFrenchAsync(); + + await _database.PatchAsync(Editor, id, new() { ["subtitle"] = "English" }, 1, locale: "fr"); + + _database.Row("cms_article_i18n", id, "fr")["subtitle"].ShouldBe("English"); + } + + [Fact] + public async Task AddingALocaleFromAFallbackReadWritesOnlyWhatWasTranslated() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["subtitle"] = "English", ["views"] = 3 })); + var read = (await _database.GetAsync(Editor, id, locale: "fr"))!; + read["title"] = "Traduit"; + + await _database.UpdateAsync(Editor, id, read, 0, locale: "fr"); + + var row = _database.Row("cms_article_i18n", id, "fr"); + row["title"].ShouldBe("Traduit"); + row["subtitle"].ShouldBeNull(); + row["slug"].ShouldBe("traduit"); + } + + [Fact] + public async Task ATypedRoundTripDoesNotWriteAFallbackIntoTheLocale() + { + const string Body = """{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"English body"}]}]}"""; + using var database = new ServiceDatabase(schema: SampleSchema.FromClasses().Build()); + var created = await database.AsAsync(Editor, services => services.GetRequiredService>().CreateAsync(new Article { Title = "Hello", Body = new RichText(Body) }, Token)); + await database.PatchAsync(Editor, created.Id, new() { ["title"] = "Bonjour" }, 0, locale: "fr"); + + await database.AsAsync( + Editor, + async services => + { + var service = services.GetRequiredService>(); + var read = (await service.GetAsync(created.Id, new GetOptions { Status = QueryStatus.Draft }, Token))!; + read.Body.Json.ShouldBe(Body); + return await service.UpdateAsync(created.Id, read, read.Version, Token); + }, + locale: "fr"); + + database.Row("cms_article_i18n", created.Id, "fr")["body"].ShouldBe(RichText.Empty.Json); + } +} + +public sealed class ContentServiceGrantTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + [Fact] + public async Task ACreateIsDecidedPerGrantAgainstTheNewEntry() + { + using var database = new ServiceDatabase(cms => cms.Authorization + .AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region")) + .AddRowRule("never", ctx => article => article.Region == "no-such-region")); + var role = database.Database.AddRoleForTest( + "Pair", + ("content.article.create", new PermissionConstraints { RowRule = "never" }), + ("content.article.create", new PermissionConstraints { RowRule = "region", Fields = ["title", "region"] })); + var principal = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + var before = database.Footprint(); + + (await Should.ThrowAsync(() => database.CreateAsync(principal, new() { ["title"] = "T", ["region"] = "eu", ["views"] = 7 }))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("views"), + error => error.Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable)); + database.Footprint().ShouldBe(before); + + await Should.NotThrowAsync(() => database.CreateAsync(principal, new() { ["title"] = "T", ["region"] = "eu" })); + } + + [Fact] + public async Task ASiteRoleCannotChangeAGlobalEntry() + { + var tag = await _database.CreateAsync(Editor, new() { ["name"] = "Shared" }, "tag"); + var trashed = await _database.CreateAsync(Editor, new() { ["name"] = "Trashed" }, "tag"); + await _database.DeleteAsync(Editor, IdOf(trashed), "tag"); + var role = _database.Database.AddRoleForTest( + "Site A", + QueryDatabase.SiteA, + ("content.tag.create", null), + ("content.tag.read", null), + ("content.tag.update", null), + ("content.tag.delete", null), + ("content.tag.restore", null)); + var inA = WithRole(role); + + await Should.ThrowAsync(() => _database.CreateAsync(inA, new() { ["name"] = "x" }, "tag")); + await Should.ThrowAsync(() => _database.UpdateAsync(inA, IdOf(tag), new() { ["name"] = "x" }, 1, "tag")); + await Should.ThrowAsync(() => _database.PatchAsync(inA, IdOf(tag), new() { ["name"] = "x" }, 1, "tag")); + await Should.ThrowAsync(() => _database.DeleteAsync(inA, IdOf(tag), "tag")); + await Should.ThrowAsync(() => _database.RestoreAsync(inA, IdOf(trashed), "tag")); + + // Reading stays open to it. + (await _database.GetAsync(inA, IdOf(tag), type: "tag"))!["name"].ShouldBe("Shared"); + } + + [Fact] + public async Task AGrantLimitedToSitesCannotChangeAGlobalEntry() + { + var tag = await _database.CreateAsync(Editor, new() { ["name"] = "Shared" }, "tag"); + var role = _database.Database.AddRoleForTest("Limited", ("content.tag.read", null), ("content.tag.update", new PermissionConstraints { Sites = [SiteA] })); + + await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), IdOf(tag), new() { ["name"] = "x" }, 1, "tag")); + } + + [Fact] + public async Task AnInstanceWideRoleWritesAGlobalEntryFromAnySiteAndASiteRoleItsOwnEntries() + { + var tag = await _database.CreateAsync(Editor, new() { ["name"] = "Shared" }, "tag", site: SiteB); + (await _database.UpdateAsync(Editor, IdOf(tag), new() { ["name"] = "Changed" }, 1, "tag"))["name"].ShouldBe("Changed"); + await _database.DeleteAsync(Editor, IdOf(tag), "tag"); + await _database.RestoreAsync(Editor, IdOf(tag), "tag"); + + var author = AuthorRole(Guid.CreateVersion7()); + var own = await _database.CreateAsync(author, new() { ["name"] = "Mine" }, "tag"); + (await _database.PatchAsync(author, IdOf(own), new() { ["name"] = "Still mine" }, 1, "tag"))["name"].ShouldBe("Still mine"); + + var role = _database.Database.AddRoleForTest("Site A", QueryDatabase.SiteA, ("content.article.create", null)); + await Should.NotThrowAsync(() => _database.CreateAsync(WithRole(role), new() { ["title"] = "Here" })); + } + + [FluentCMS.Schema.Collection("scored")] + public sealed class Scored : ContentItem + { + [Text] + public string Name { get; set; } = ""; + + [Integer, Hidden] + public int Score { get; set; } + } + + [Fact] + public async Task ASystemTypedWriteKeepsItsHiddenValues() + { + using var database = new ServiceDatabase(schema: SampleSchema.NewBuilder().AddContentType().Build()); + + var created = await database.AsAsync( + Principal.System, + services => services.GetRequiredService>().CreateAsync(new Scored { Name = "seeded", Score = 42 }, Token), + origin: Origin.Seed); + + database.Row("cms_scored", created.Id)["score"].ShouldBe(42L); + } +} + +public sealed class ContentServiceRelationKeepTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private List TagsOf(Guid id) => [.. _database.References(id).Where(row => row.Field == "tags").OrderBy(row => row.Position).Select(row => row.TargetId)]; + + private async Task<(Principal Ann, Guid Hidden, Guid Mine, Guid Entry)> AnnWithAHiddenTagAsync() + { + var ann = AuthorRole(Guid.CreateVersion7()); + var hidden = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Theirs" }, "tag")); + var mine = IdOf(await _database.CreateAsync(ann, new() { ["name"] = "Mine" }, "tag")); + var entry = IdOf(await _database.CreateAsync(ann, new() { ["title"] = "T", ["tags"] = new[] { hidden, mine } })); + return (ann, hidden, mine, entry); + } + + [Fact] + public async Task ATitleOnlyRoundTripLeavesARelationAndTheOtherLocalesAlone() + { + var (ann, hidden, mine, id) = await AnnWithAHiddenTagAsync(); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Fr" }, 0, locale: "fr"); + _database.Read(context => context.Documents.Where(document => document.DocumentId == id).ExecuteUpdate(set => set.SetProperty(document => document.Status, EntryStatus.Published))); + + var read = (await _database.GetAsync(ann, id))!; + read["title"] = "Only the title"; + await _database.UpdateAsync(ann, id, read, 1); + + TagsOf(id).ShouldBe([hidden, mine]); + _database.Read(context => context.Documents.AsNoTracking().Single(document => document.DocumentId == id && document.Locale == "fr").Status).ShouldBe(EntryStatus.Published); + } + + [Fact] + public async Task AKeptTargetGoesBackToItsPlaceClamped() + { + var (ann, hidden, mine, id) = await AnnWithAHiddenTagAsync(); + var other = IdOf(await _database.CreateAsync(ann, new() { ["name"] = "Other" }, "tag")); + + await _database.UpdateAsync(ann, id, new() { ["title"] = "T", ["tags"] = new[] { other, mine } }, 1); + TagsOf(id).ShouldBe([hidden, other, mine]); + } + + [Fact] + public async Task APatchCannotRemoveATargetThePrincipalCannotSee() + { + var (ann, hidden, mine, id) = await AnnWithAHiddenTagAsync(); + + await _database.PatchAsync(ann, id, new() { ["tags"] = new[] { mine } }, 1); + TagsOf(id).ShouldBe([hidden, mine]); + + await _database.PatchAsync(ann, id, new() { ["tags"] = Array.Empty() }, 2); + TagsOf(id).ShouldBe([hidden]); + } + + [Fact] + public async Task AGhostIdIsClearedByAFullUpdateFromAnyone() + { + var ghost = Guid.CreateVersion7(); + var first = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "G", ["author"] = ghost, ["tags"] = new[] { ghost } })); + + await _database.UpdateAsync(Editor, first, new() { ["title"] = "G" }, 1); + + _database.References(first).ShouldBeEmpty(); + } + + [Fact] + public async Task ATrashedTargetTheEditorCanStillReadIsClearedAndOneThePrincipalCannotReadIsKept() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var entry = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "T", ["author"] = author })); + await _database.DeleteAsync(Editor, author, "author"); + + var role = _database.Database.AddRoleForTest("Articles", ("content.article.read", null), ("content.article.update", null)); + await _database.UpdateAsync(WithRole(role), entry, new() { ["title"] = "T", ["author"] = null }, 1); + _database.References(entry).ShouldHaveSingleItem().TargetId.ShouldBe(author); + + await _database.UpdateAsync(Editor, entry, new() { ["title"] = "T", ["author"] = null }, 2); + _database.References(entry).ShouldBeEmpty(); + } + + [Fact] + public async Task TheSystemPrincipalKeepsNothingItDidNotSend() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var inOtherSite = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Bob" }, "author", site: SiteB)); + var entry = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "T", ["author"] = inOtherSite, ["sponsor"] = author })); + + await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().UpdateAsync("article", entry, new() { ["title"] = "T" }, 1, Token), + origin: Origin.Job); + + _database.References(entry).ShouldBeEmpty(); + } + + [Fact] + public async Task LeftoverBlocksPairWithinTheirType() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "T" })); + await _database.AsAsync( + Principal.System, + services => services.GetRequiredService().PatchAsync("article", id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"A","internal":"hA"},{"type":"quote","text":"q"},{"type":"hero","heading":"B","internal":"hB"}]""") }, 1, Token), + origin: Origin.Job); + + await _database.PatchAsync(Editor, id, new() { ["sections"] = JsonNode.Parse("""[{"type":"quote","text":"q2"},{"type":"hero","heading":"B2"}]""") }, 2); + + var sections = JsonNode.Parse((string)_database.Row("cms_article", id)["sections"])!; + sections[0]!["internal"].ShouldBeNull(); + sections[1]!["heading"]!.GetValue().ShouldBe("B2"); + sections[1]!["internal"]!.GetValue().ShouldBe("hA"); + } +} + +public sealed class ContentServiceJoinedTransactionTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + [Fact] + public async Task AFailedWriteLeavesTheCallersUnsavedWorkForItToSave() + { + using var database = new ServiceDatabase(cms => cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region"))); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "T", ["region"] = "eu" })); + var keep = database.Database.AddRoleForTest("Existing"); + var role = database.Database.AddRoleForTest("Regional", ("content.article.read", new PermissionConstraints { RowRule = "region" }), ("content.article.update", new PermissionConstraints { RowRule = "region" })); + var regional = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + + await database.AsAsync(regional, async services => + { + var context = services.GetRequiredService(); + var service = services.GetRequiredService(); + await using var transaction = await context.Database.BeginTransactionAsync(Token); + context.Add(new Role { Name = "New from the caller" }); + var existing = await context.Set().SingleAsync(item => item.Id == keep, Token); + existing.Description = "Changed by the caller"; + + // One that fails before it writes, and one that fails after: its rows are saved, then refused. + await Should.ThrowAsync(() => service.PatchAsync("article", id, Titled("x"), 99, Token)); + await Should.ThrowAsync(() => service.PatchAsync("article", id, new() { ["region"] = "us" }, 1, Token)); + + context.ChangeTracker.Entries().Count(entry => entry.State == EntityState.Added).ShouldBe(1); + context.ChangeTracker.Entries().Count(entry => entry.State == EntityState.Modified).ShouldBe(1); + + await context.SaveChangesAsync(Token); + await transaction.CommitAsync(Token); + return true; + }); + + database.Read(context => context.Set().Count(item => item.Name == "New from the caller")).ShouldBe(1); + database.Read(context => context.Set().Single(item => item.Id == keep).Description).ShouldBe("Changed by the caller"); + (await database.GetAsync(Editor, id))!["region"].ShouldBe("eu"); + } + + [Fact] + public async Task ASuccessfulWriteDoesNotSaveTheCallersWorkEither() + { + using var database = new ServiceDatabase(); + var id = IdOf(await database.CreateAsync(Editor, Titled("T"))); + + await database.AsAsync(Editor, async services => + { + var context = services.GetRequiredService(); + await using var transaction = await context.Database.BeginTransactionAsync(Token); + context.Add(new Role { Name = "Not saved" }); + + await services.GetRequiredService().PatchAsync("article", id, Titled("Changed"), 1, Token); + + context.ChangeTracker.Entries().ShouldHaveSingleItem().State.ShouldBe(EntityState.Added); + await transaction.CommitAsync(Token); + return true; + }); + + database.Read(context => context.Set().Count(item => item.Name == "Not saved")).ShouldBe(0); + (await database.GetAsync(Editor, id))!["title"].ShouldBe("Changed"); + } + + private sealed class NoSavepoints( + IRelationalConnection connection, + DbTransaction transaction, + Guid id, + IDiagnosticsLogger logger, + bool owned, + ISqlGenerationHelper helper) + : RelationalTransaction(connection, transaction, id, logger, owned, helper) + { + public override bool SupportsSavepoints => false; + } + + private sealed class NoSavepointsFactory(RelationalTransactionFactoryDependencies dependencies) : IRelationalTransactionFactory + { + public RelationalTransaction Create(IRelationalConnection connection, DbTransaction transaction, Guid transactionId, IDiagnosticsLogger logger, bool transactionOwned) => + new NoSavepoints(connection, transaction, transactionId, logger, transactionOwned, dependencies.SqlGenerationHelper); + } + + [Fact] + public async Task WithoutSavepointsAFailedJoinedWriteRollsTheOuterTransactionBack() + { + using var database = new ServiceDatabase(cms => cms.Services.ConfigureDbContext(options => options.ReplaceService())); + var before = database.Footprint(); + + await database.AsAsync(Editor, async services => + { + var context = services.GetRequiredService(); + var service = services.GetRequiredService(); + var transaction = await context.Database.BeginTransactionAsync(Token); + await using (transaction) + { + transaction.SupportsSavepoints.ShouldBeFalse(); + await service.CreateAsync("article", Titled("One"), Token); + await Should.ThrowAsync(() => service.CreateAsync("article", new() { ["views"] = 5000 }, Token)); + await Should.ThrowAsync(() => transaction.CommitAsync(Token)); + } + + return true; + }); + + database.Footprint().ShouldBe(before); + } + + [Fact] + public async Task AFailedBeginDoesNotLeaveTheServiceOwningATransaction() + { + using var source = new CancellationTokenSource(); + await source.CancelAsync(); + var recorder = new Recorder(); + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton(recorder)); + + await database.AsAsync(Editor, async services => + { + var service = services.GetRequiredService(); + await Should.ThrowAsync(() => service.CreateAsync("article", Titled("One"), source.Token)); + + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + await service.CreateAsync("article", Titled("In a foreign transaction"), Token); + await transaction.RollbackAsync(Token); + await service.CreateAsync("article", Titled("Own"), Token); + return true; + }); + + recorder.Changes.ShouldHaveSingleItem(); + } + + private sealed class Recorder : FluentCMS.Core.Content.IEntryChangeSink + { + public List Changes { get; } = []; + + public ValueTask PublishAsync(FluentCMS.Core.Content.EntryChange change, CancellationToken cancellationToken) + { + Changes.Add(change); + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task AddingALocaleWorksWhenTheOldestLocaleIsDisabled() + { + using var database = new ServiceDatabase(); + var id = IdOf(await database.CreateAsync(Editor, Titled("Bonjour"), locale: "fr")); + database.Read(context => context.Set().Where(locale => locale.Code == "fr").ExecuteUpdate(set => set.SetProperty(locale => locale.Enabled, false))); + + (await database.UpdateAsync(Editor, id, Titled("Hello"), 0, locale: "en"))["version"].ShouldBe(1); + (await database.PatchAsync(Editor, id, Titled("Salut"), 0, locale: "fr-CA"))["version"].ShouldBe(1); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs index 672c178d3..0d49b8077 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs @@ -528,7 +528,7 @@ public async Task UniquenessIsPerSiteAndPerLocale() var first = await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }); await Should.NotThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c1" }, site: SiteB)); - await Should.NotThrowAsync(() => _database.UpdateAsync(Editor, IdOf(first), new() { ["title"] = "Hello" }, 0, locale: "fr")); + await Should.NotThrowAsync(() => _database.PatchAsync(Editor, IdOf(first), new() { ["title"] = "Hello" }, 0, locale: "fr")); await Should.NotThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "c2" }, locale: "fr-CA")); (await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Hello" }, locale: "fr"))) From 59cf13b84642a4b148bf42d1a91922db6d8e2f4b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 15:54:55 -0500 Subject: [PATCH 4/4] Keep hidden values on a typed update and decide sent values by path --- .../Content/ContentEntryMapper.cs | 25 +++-- src/FluentCMS.Core/Content/ContentService.cs | 10 +- src/FluentCMS.Core/Content/EntryErrors.cs | 42 +++++++- .../Content/TypedContentService.cs | 4 +- src/FluentCMS.Sdk/Content/IContentService.cs | 2 +- .../Content/ContentServiceSecondRoundTests.cs | 97 +++++++++++++++++++ 6 files changed, 161 insertions(+), 19 deletions(-) diff --git a/src/FluentCMS.Core/Content/ContentEntryMapper.cs b/src/FluentCMS.Core/Content/ContentEntryMapper.cs index 62547681e..8ca6233e4 100644 --- a/src/FluentCMS.Core/Content/ContentEntryMapper.cs +++ b/src/FluentCMS.Core/Content/ContentEntryMapper.cs @@ -55,14 +55,16 @@ public void CopyTo(object entry, ContentDocument document) // The document of a typed write: a property with no value is not named, because a class cannot leave a field out. // Nor is one that include turns down, at the top level. A hidden property holds nothing a read gave it, and a - // read-only one is not the client's to send, at any level. The system principal sends both. - public ContentDocument ToDocument(object entry, Func include, bool isSystem) + // read-only one is not the client's to send, at any level. The system principal sends both, but a read leaves a + // hidden property at its default even for it, so on an update a default means it was not set, and a patch is how to + // put a hidden value back to its default. + public ContentDocument ToDocument(object entry, Func include, bool isSystem, bool isUpdate) { ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(include); bool Inside(SchemaField field) => isSystem || (!field.Hidden && !field.ReadOnly); - return ToFields(entry, field => Inside(field) && include(field), Inside); + return ToFields(entry, field => Inside(field) && include(field), Inside, isUpdate); } // What validation reads: nested objects as documents and each value as the property of its class declares it, where @@ -429,7 +431,7 @@ private static bool TryConvertScalar(object raw, Type target, out object? result } } - private ContentDocument ToFields(object instance, Func? include, Func? inside) + private ContentDocument ToFields(object instance, Func? include, Func? inside, bool hiddenDefaultIsUnset) { ContentDocument fields = []; var type = instance.GetType(); @@ -445,7 +447,12 @@ private ContentDocument ToFields(object instance, Func? inclu continue; } - var value = ToApi(property.GetValue(instance), inside); + var value = ToApi(property.GetValue(instance), inside, hiddenDefaultIsUnset); + if (hiddenDefaultIsUnset && field.Hidden && EntryJson.Same(value, ToApi(property.GetValue(Activator.CreateInstance(type)), inside, hiddenDefaultIsUnset))) + { + continue; + } + if (!IsEmpty(value)) { fields[field.Name] = value; @@ -464,7 +471,7 @@ private ContentDocument ToFields(object instance, Func? inclu _ => false, }; - private object? ToApi(object? value, Func? inside = null) + private object? ToApi(object? value, Func? inside = null, bool hiddenDefaultIsUnset = false) { switch (value) { @@ -477,7 +484,7 @@ private ContentDocument ToFields(object instance, Func? inclu case MediaRefList medias: return medias.Where(media => !media.IsEmpty).Select(media => media.Id).ToArray(); case BlockList blocks: - return blocks.Select(block => (object?)ToFields(block, inside, inside)).ToList(); + return blocks.Select(block => (object?)ToFields(block, inside, inside, hiddenDefaultIsUnset)).ToList(); case not null when RawValue.IsNumber(value): return value; } @@ -495,10 +502,10 @@ private ContentDocument ToFields(object instance, Func? inclu if (Attribute.IsDefined(type, typeof(ComponentAttribute)) || Attribute.IsDefined(type, typeof(BlockAttribute))) { - return ToFields(value, inside, inside); + return ToFields(value, inside, inside, hiddenDefaultIsUnset); } - return value is IEnumerable items and not IEnumerable> ? items.Cast().Select(item => ToApi(item, inside)).ToList() : value; + return value is IEnumerable items and not IEnumerable> ? items.Cast().Select(item => ToApi(item, inside, hiddenDefaultIsUnset)).ToList() : value; } private static List ItemsOf(object? value) => value switch diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index 4aebd6370..e902ec46b 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -143,7 +143,7 @@ public async Task CreateAsync(string type, ContentDocument entr var working = Prepare(write, composed, document, null); var prepared = EntryJson.Fields(write.Type, working); - working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, new SaveContext(null, Named(sent), writable, document), cancellationToken); + working = await RunBeforeHooksAsync(write, HookKind.BeforeCreate, working, new SaveContext(null, Client(sent), writable, document), cancellationToken); await store.WriteDraftAsync(write.Type, document, isNewDocument: true, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken); @@ -565,7 +565,7 @@ private async Task SaveAsync(string typeName, Guid id, ContentD composer.CarryProtected(type, stored, working, includingGroups: !isPatch || !write.Permissions.IsSystem); await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch, cancellationToken); - working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, Named(refused), writable, document), cancellationToken); + working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, Client(refused), writable, document), cancellationToken); var sharedChanged = type.Localized && type.Fields.Any(field => !field.Localizable && !EntryJson.Same(working[field.Name], stored[field.Name])); var next = expectedVersion + 1; @@ -761,7 +761,7 @@ private async Task WithoutFallbackEchoesAsync(WriteScope write, return result; } - private static HashSet Named(ContentDocument sent) => new(sent.Keys, StringComparer.Ordinal); + private static JsonObject Client(ContentDocument sent) => (JsonObject)EntryJson.ToNode(sent)!; private JsonNode? Visible(SchemaField field, object? value) { @@ -883,7 +883,7 @@ private async Task ValidateAsync(WriteScope write, ContentDocument working, Json private async Task> ReportableAsync(WriteScope write, IReadOnlyList errors, JsonObject now, JsonObject sent, SaveContext save, CancellationToken cancellationToken) { - var (reportable, toCompare, produced) = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Sort(errors, now, sent, save.Named); + var (reportable, toCompare, produced) = new EntryErrors(schema, write.Type, write.Permissions, write.Target).Sort(errors, now, sent, save.FromClient); if (!produced && toCompare.Count > 0) { var before = save.Stored is { } stored ? await ValidateStoredAsync(write, stored, save.Document.DocumentId, cancellationToken) : []; @@ -959,7 +959,7 @@ await ReadDraftOrNullAsync(write, row.DocumentId, write.Locale, LocaleFallback.N } // What the steps of one save share. - private sealed record SaveContext(ContentDocument? Stored, IReadOnlySet Named, WritableFields Writable, Document Document); + private sealed record SaveContext(ContentDocument? Stored, JsonObject FromClient, WritableFields Writable, Document Document); private sealed record WriteScope(OperationContext Operation, SchemaType Type, SiteId Site, string Locale, AccessTarget Target, EffectivePermissions Permissions, FieldAccess Access) { diff --git a/src/FluentCMS.Core/Content/EntryErrors.cs b/src/FluentCMS.Core/Content/EntryErrors.cs index c092a8762..5c28b3dad 100644 --- a/src/FluentCMS.Core/Content/EntryErrors.cs +++ b/src/FluentCMS.Core/Content/EntryErrors.cs @@ -18,7 +18,9 @@ internal sealed class EntryErrors(SchemaModel schema, SchemaType type, Effective public static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); - public (List Reportable, List ToCompare, bool Produced) Sort(IReadOnlyList errors, JsonObject now, JsonObject sent, IReadOnlySet named) + // sent is the entry before any hook, and fromClient only what the client itself sent: a patch into a component names + // some of its fields, and the rest of them are stored values it did not send. + public (List Reportable, List ToCompare, bool Produced) Sort(IReadOnlyList errors, JsonObject now, JsonObject sent, JsonObject fromClient) { List reportable = []; List toCompare = []; @@ -32,7 +34,7 @@ internal sealed class EntryErrors(SchemaModel schema, SchemaType type, Effective } var same = JsonNode.DeepEquals(ValueAt(now, error.Path), ValueAt(sent, error.Path)); - if (same && !path.Hidden && named.Contains(Parse(error.Path)[0].Name)) + if (same && !path.Hidden && WasSent(fromClient, error.Path)) { reportable.Add(error); } @@ -82,6 +84,42 @@ internal sealed class EntryErrors(SchemaModel schema, SchemaType type, Effective return (!hidden && permissions.CanReadField(type, top.Name, target), hidden); } + private static bool WasSent(JsonObject fromClient, string path) + { + JsonNode? value = fromClient; + foreach (var (name, index) in Parse(path)) + { + if (AsJson(value) is not JsonObject container || !container.ContainsKey(name)) + { + return false; + } + + value = container[name]; + if (index is { } position) + { + if (AsJson(value) is not JsonArray items || position >= items.Count) + { + return false; + } + + value = items[position]; + } + } + + return true; + } + + // A component or block may arrive as JSON text. + private static JsonNode? AsJson(JsonNode? value) + { + if (value is not JsonValue text || !text.TryGetValue(out var json)) + { + return value; + } + + return RawValue.TryReadJson(json, out var parsed, out _) ? parsed : value; + } + private static JsonNode? ValueAt(JsonObject root, string path) { JsonNode? value = root; diff --git a/src/FluentCMS.Core/Content/TypedContentService.cs b/src/FluentCMS.Core/Content/TypedContentService.cs index 9651586ff..e65cae1bb 100644 --- a/src/FluentCMS.Core/Content/TypedContentService.cs +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -34,7 +34,7 @@ public async Task CreateAsync(T entry, CancellationToken cancellationToken = ArgumentNullException.ThrowIfNull(entry); var limits = await documents.TypedWriteLimitsAsync(_type, cancellationToken); - return await ReadBackAsync(await documents.CreateAsync(_type, mapper.ToDocument(entry, static _ => true, limits.IsSystem), cancellationToken)); + return await ReadBackAsync(await documents.CreateAsync(_type, mapper.ToDocument(entry, static _ => true, limits.IsSystem, isUpdate: false), cancellationToken)); } public async Task UpdateAsync(Guid id, T entry, int expectedVersion, CancellationToken cancellationToken = default) @@ -43,7 +43,7 @@ public async Task UpdateAsync(Guid id, T entry, int expectedVersion, Cancella // What the principal may not read came back as a default: sending it would overwrite the stored value. var limits = await documents.TypedWriteLimitsAsync(_type, cancellationToken); - return await ReadBackAsync(await documents.UpdateAsync(_type, id, mapper.ToDocument(entry, limits.CanRead, limits.IsSystem), expectedVersion, cancellationToken)); + return await ReadBackAsync(await documents.UpdateAsync(_type, id, mapper.ToDocument(entry, limits.CanRead, limits.IsSystem, isUpdate: true), expectedVersion, cancellationToken)); } public async Task PatchAsync(Guid id, ContentDocument patch, int expectedVersion, CancellationToken cancellationToken = default) => diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs index 885921a92..8561cf16b 100644 --- a/src/FluentCMS.Sdk/Content/IContentService.cs +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -124,7 +124,7 @@ public interface IContentService /// A value is refused or not valid. Task CreateAsync(T entry, CancellationToken cancellationToken = default); - /// Replaces the draft in the locale of the operation. A property with no value clears its field. A property the principal may not read, and one that is hidden, is not sent, so it keeps its value; use a patch to write a field that cannot be read back. + /// Replaces the draft in the locale of the operation. A property with no value clears its field. A property the principal may not read is not sent, so it keeps its value, and nor is a hidden one, except from the system principal when it holds a value other than its default. Use a patch to write a field that cannot be read back, or to put a hidden value back to its default. /// The id of the entry. /// The values. /// The version the change was based on. diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs index f7d2b8592..51b39ebc0 100644 --- a/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs @@ -104,6 +104,64 @@ public async Task AValueAClientSentIsReportedEvenWhenItEqualsTheStoredOne() } } +public sealed class ContentServiceSentValueTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private async Task<(Guid Id, Principal Writer)> TooLongStoredTitleAsync() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "T", ["seo"] = new ContentDocument { ["metaTitle"] = "short" } })); + _database.Read(context => context.Rows("cms_article").Where(row => EF.Property(row, "document_id") == id).ExecuteUpdate(set => set.SetProperty(row => EF.Property(row, "seo_meta_title"), "a stored value much too long"))); + var role = _database.Database.AddRoleForTest("WriteOnly", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + return (id, WithRole(role)); + } + + public static TheoryData PatchesOfAnotherFieldOfTheComponent() => new() + { + new ContentDocument { ["image"] = Guid.Parse("0192f2a4-3333-7000-8000-000000000003") }, + JsonNode.Parse("""{"image":"0192f2a4-3333-7000-8000-000000000003"}""")!, + """{"image":"0192f2a4-3333-7000-8000-000000000003"}""", + }; + + [Theory] + [MemberData(nameof(PatchesOfAnotherFieldOfTheComponent))] + public async Task APatchIntoAComponentDoesNotHearOfAStoredValueItDidNotSend(object seo) + { + var (id, writer) = await TooLongStoredTitleAsync(); + + var patched = await _database.PatchAsync(writer, id, new() { ["seo"] = seo }, 1); + + patched["version"].ShouldBe(2); + _database.Row("cms_article", id)["seo_meta_title"].ShouldBe("a stored value much too long"); + } + + [Fact] + public async Task AValueSentInsideAComponentIsReported() + { + var (id, writer) = await TooLongStoredTitleAsync(); + + foreach (var guess in new[] { "a stored value much too long", "another value much too long" }) + { + (await Should.ThrowAsync(() => _database.PatchAsync(writer, id, new() { ["seo"] = new ContentDocument { ["metaTitle"] = guess } }, 1))) + .Errors.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + error => error.Path.ShouldBe("seo.metaTitle"), + error => error.Code.ShouldBe(ValidationErrorCodes.MaxLength)); + } + } + + [Fact] + public async Task AValueSentInsideABlockIsReportedAtItsIndex() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "T" })); + var role = _database.Database.AddRoleForTest("WriteOnly", ("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.update", null)); + + (await Should.ThrowAsync(() => _database.PatchAsync(WithRole(role), id, new() { ["sections"] = JsonNode.Parse("""[{"type":"hero","heading":"ok"},{"type":"hero","heading":"much too long a heading"}]""") }, 1))) + .Errors.ShouldHaveSingleItem().Path.ShouldBe("sections[1].heading"); + } +} + public sealed class ContentServiceFallbackEchoTests : IDisposable { private readonly ServiceDatabase _database = new(); @@ -310,6 +368,45 @@ public async Task ASystemTypedWriteKeepsItsHiddenValues() database.Row("cms_scored", created.Id)["score"].ShouldBe(42L); } + + [Fact] + public async Task ASystemJobThatReadsAndUpdatesAClassKeepsAHiddenValue() + { + using var database = new ServiceDatabase(schema: SampleSchema.NewBuilder().AddContentType().Build()); + Task AsJob(Func, Task> work) => + database.AsAsync(Principal.System, services => work(services.GetRequiredService>()), origin: Origin.Job); + var created = await AsJob(service => service.CreateAsync(new Scored { Name = "seeded", Score = 42 }, Token)); + + var read = (await AsJob(service => service.GetAsync(created.Id, new GetOptions { Status = QueryStatus.Draft }, Token)))!; + read.Score.ShouldBe(0); + read.Name = "changed"; + await AsJob(service => service.UpdateAsync(created.Id, read, read.Version, Token)); + + var row = database.Row("cms_scored", created.Id); + row["name"].ShouldBe("changed"); + row["score"].ShouldBe(42L); + + // A value other than the default is a write, and a patch puts it back to the default. + read.Score = 50; + await AsJob(service => service.UpdateAsync(created.Id, read, 2, Token)); + database.Row("cms_scored", created.Id)["score"].ShouldBe(50L); + + await AsJob(service => service.PatchAsync(created.Id, new() { ["score"] = 0 }, 3, Token)); + database.Row("cms_scored", created.Id)["score"].ShouldBe(0L); + } + + [Fact] + public async Task ASystemTypedCreateSendsAHiddenDefaultToo() + { + using var database = new ServiceDatabase(schema: SampleSchema.NewBuilder().AddContentType().Build()); + + var created = await database.AsAsync( + Principal.System, + services => services.GetRequiredService>().CreateAsync(new Scored { Name = "seeded" }, Token), + origin: Origin.Seed); + + database.Row("cms_scored", created.Id)["score"].ShouldBe(0L); + } } public sealed class ContentServiceRelationKeepTests : IDisposable