diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index fb21e2927..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)) { @@ -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; @@ -141,7 +143,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..e22b59cfa 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -6,8 +6,10 @@ 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. 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) @@ -17,6 +19,16 @@ 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 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) => + 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 @@ -45,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). @@ -57,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 @@ -142,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/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/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 new file mode 100644 index 000000000..8ca6233e4 --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentEntryMapper.cs @@ -0,0 +1,542 @@ +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. + // 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, 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, isUpdate); + } + + // 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, Func? include, Func? inside, bool hiddenDefaultIsUnset) + { + 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) + { + if (include?.Invoke(field) == false) + { + continue; + } + + 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; + } + } + + 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, Func? inside = null, bool hiddenDefaultIsUnset = false) + { + 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, inside, inside, hiddenDefaultIsUnset)).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, inside, inside, hiddenDefaultIsUnset); + } + + 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 + { + 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/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 new file mode 100644 index 000000000..dc4528609 --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -0,0 +1,970 @@ +using System.Globalization; +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; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; + +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, + 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); + + 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 sent = WithoutFallbacks(entry); + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, null, write.Target, cancellationToken); + Reject(write, sent, 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.AuditPrincipal.Id, + UpdatedAt = write.Operation.Timestamp, + UpdatedBy = write.Operation.AuditPrincipal.Id, + }; + + ContentDocument composed = new(sent); + 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, null); + var prepared = EntryJson.Fields(write.Type, working); + 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); + + // 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; + }, + cancellationToken); + + return await RespondAsync(write, row); + } + + 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 (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, ContentCopy.Of(stored), cancellationToken); + await store.SetTrashedAsync(id, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken); + await hooks.RunAsync(HookKind.AfterDelete, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); + + _pending.AddRange(documents.Select(document => new EntryChange(EntryChangeKind.Trashed, write.Type.Name, id, document.Locale, document.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 (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, ContentCopy.Of(stored), cancellationToken); + await store.SetTrashedAsync(id, null, null, cancellationToken); + await hooks.RunAsync(HookKind.AfterRestore, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); + + _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); + } + + // 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); + + // 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; + 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; + } + + // 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(); + 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)); + } + + // 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 { } outer) + { + 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; + var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + _ownsTransaction = true; + await using (transaction) + { + try + { + result = await work(); + await transaction.CommitAsync(CancellationToken.None); + committed = true; + } + finally + { + _ownsTransaction = false; + if (!committed) + { + _pending.Clear(); + } + } + } + + 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 + { + _pending.RemoveRange(pending, _pending.Count - pending); + + // Without a savepoint the failed write's rows cannot be undone alone, and the transaction must not be + // able to commit them, so all of it goes. SQL Server with MARS reports none: #2711. + if (outer.SupportsSavepoints) + { + await outer.RollbackToSavepointAsync(savepoint, CancellationToken.None); + } + else + { + await outer.RollbackAsync(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) + { + try + { + await sink.PublishAsync(change, CancellationToken.None); + } + catch (Exception exception) + { + ContentLog.SinkFailed(logger, exception, change.Kind, change.Type, change.Id, change.Locale); + } + } + } + } + + 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; + 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)) + { + 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.AuditPrincipal.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 sent = WithoutFallbacks(changes); + if (!isPatch) + { + sent = await WithoutFallbackEchoesAsync(write, id, sent, stored, 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); + + var composed = isPatch + ? composer.Patch(type, stored, sent) + : 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); + await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch, 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; + var status = document.Status == EntryStatus.Published ? EntryStatus.Modified : document.Status; + if (addsLocale) + { + document.DraftVersion = next; + } + else if (await store.AdvanceVersionAsync(id, document.Locale, expectedVersion, status, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken)) + { + document.DraftVersion = next; + document.Status = status; + document.UpdatedAt = write.Operation.Timestamp; + document.UpdatedBy = write.Operation.AuditPrincipal.Id; + } + else + { + throw await ConflictAsync(write, id, expectedVersion, await store.DraftVersionAsync(id, document.Locale, cancellationToken), 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); + } + + // 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, 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); + } + + // 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. 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 = shown is not null + && write.Type.FindField(key) is { Hidden: false } field + && (field.ReadOnly || !writable.Includes(key)) + && (!addsLocale || !field.Localizable) + && shown.TryGetValue(key, out var visible) + && write.Access.IsClean(field, value, write.Type) + && canonicalizer.TryRead(field, value, out var read) + && canonicalizer.TryRead(field, visible, out var seen) + && JsonNode.DeepEquals(Visible(field, read), Visible(field, seen)); + + if (!unchanged) + { + result[key] = value; + } + } + + return result; + } + + 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 write.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, write.Type).Where(item => item.Kind == FieldStorageKind.Reference && (!isPatch || item.Field.Multiple))) + { + 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 kept = await store.ExistingTargetsAsync(item.Field, unseen, cancellationToken); + var sent = IdsOf(EntryPaths.ValueAt(working, item, write.Type)); + if (item.Field.Multiple) + { + 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 && kept.Count > 0) + { + EntryPaths.SetAt(working, item, write.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. + private static ContentDocument WithoutFallbacks(ContentDocument changes) + { + if (!changes.ContainsKey(LocaleFallbackReader.FallbacksKey)) + { + return changes; + } + + var result = new ContentDocument(changes); + result.Remove(LocaleFallbackReader.FallbacksKey); + 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) + { + 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(field.Name); + } + } + + return result; + } + + private static JsonObject Client(ContentDocument sent) => (JsonObject)EntryJson.ToNode(sent)!; + + 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)]); + } + } + + // 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, (owner, source) => MayDerive(write, owner, source), stored); + 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. 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, SaveContext save, CancellationToken cancellationToken) + { + var sent = EntryJson.Fields(write.Type, working); + + 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(save.Stored), cancellationToken); + working = AfterHook(write, working, validated, save); + + if (!JsonNode.DeepEquals(validated, EntryJson.Fields(write.Type, working))) + { + await ValidateAsync(write, working, sent, save, cancellationToken); + } + + return working; + } + + private static ContentDocument? CopyOf(ContentDocument? stored) => stored is null ? null : ContentCopy.Of(stored); + + private ContentDocument AfterHook(WriteScope write, ContentDocument working, JsonObject baseline, SaveContext save) + { + 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, save.Writable); + return after; + } + + 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; + var result = await validation.ValidateAsync( + write.Type, + values, + entry, + write.Operation, + ValidationMode.Draft, + cancellationToken, + (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 = 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 = await ReportableAsync(write, context.Errors, now, sent, save, cancellationToken); + } + + if (errors.Count > 0) + { + throw new ValidationException(errors); + } + } + + 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.FromClient); + 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) + { + 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 (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, 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, LocaleFallback.None, CancellationToken.None) + ?? new ContentDocument { ["id"] = row.DocumentId, ["version"] = row.DraftVersion }; + + 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 = fallbacks }, everySystemField: false, cancellationToken); + } + 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, JsonObject FromClient, 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 : ""; + + 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..ee97b9295 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryCanonicalizer.cs @@ -0,0 +1,471 @@ +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; + + // 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); + + 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, mayDerive, stored); + 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, NeverDerive, null); + return errors.Count == 0; + } + + 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, 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(owner, field) is { } source && into.GetValueOrDefault(source.Name) is string text) + { + into[field.Name] = mayDerive(owner, source) + ? SlugFieldType.Slugify(text, field.MaxLength ?? SlugLength) + : stored?.GetValueOrDefault(field.Name) ?? into[field.Name]; + } + } + } + + 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, Func mayDerive, ContentDocument? storedGroup) + { + 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, mayDerive, storedGroup); + } + + 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, Func mayDerive, ContentDocument? stored) + { + 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, mayDerive, stored); + 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..8146948d4 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -0,0 +1,22 @@ +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 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 new file mode 100644 index 000000000..b3ad58f49 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -0,0 +1,290 @@ +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, 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 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) + { + 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 && ComponentOf(field) is { } component) + { + CarryGroup(component, oldGroup, after as ContentDocument, group => composed[field.Name] = group); + } + } + else if (before is JsonNode oldNode && after is JsonNode newNode) + { + CarryInside(field, oldNode, newNode); + } + } + } + + 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) + { + var old = before.GetValueOrDefault(field.Name); + var current = group.GetValueOrDefault(field.Name); + if ((field.Hidden || field.ReadOnly) && old is not null && current is null) + { + group[field.Name] = old; + } + else if (old is JsonNode oldNode && current is JsonNode newNode) + { + CarryInside(field, oldNode, newNode); + } + else if (field.FieldType == FieldTypeNames.Component && !field.Multiple && old is ContentDocument oldGroup && ComponentOf(field) is { } inner) + { + 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) + { + foreach (var (oldItem, newItem) in PairUp(field, [.. oldItems.OfType()], [.. newItems.OfType()])) + { + if (OwnerOf(field, oldItem) is { } owner) + { + CarryIntoObject(owner, oldItem, newItem); + } + } + } + else if (before is JsonObject oldObject && after is JsonObject newObject && OwnerOf(field, oldObject) is { } single && OwnerOf(field, newObject) == single) + { + CarryIntoObject(single, oldObject, newObject); + } + } + + private List<(JsonObject Old, JsonObject New)> PairUp(SchemaField field, List before, List after) + { + List<(JsonObject Old, JsonObject New)> pairs = []; + List oldLeft = [.. before]; + List newLeft = []; + foreach (var item in after) + { + 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); + } + } + + foreach (var item in newLeft) + { + var owner = OwnerOf(field, item); + var next = oldLeft.FindIndex(old => OwnerOf(field, old) == owner); + if (owner is not null && next >= 0) + { + pairs.Add((oldLeft[next], item)); + oldLeft.RemoveAt(next); + } + } + + 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) + { + if (before[field.Name] is not { } value) + { + continue; + } + + if (field.Hidden || field.ReadOnly) + { + 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..5c28b3dad --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryErrors.cs @@ -0,0 +1,157 @@ +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 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"; + + public static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); + + // 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 = []; + var produced = false; + foreach (var error in errors) + { + if (permissions.IsSystem || Describe(error.Path, now) is not { } path || path.Readable) + { + reportable.Add(error); + continue; + } + + var same = JsonNode.DeepEquals(ValueAt(now, error.Path), ValueAt(sent, error.Path)); + if (same && !path.Hidden && WasSent(fromClient, error.Path)) + { + reportable.Add(error); + } + else if (same) + { + toCompare.Add(error); + } + else + { + produced = true; + } + } + + return (reportable, toCompare, produced); + } + + // 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 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; + 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 new file mode 100644 index 000000000..343b348e8 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryJson.cs @@ -0,0 +1,136 @@ +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; + } + + // 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/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs new file mode 100644 index 000000000..6b2fb5b21 --- /dev/null +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -0,0 +1,503 @@ +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; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +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); + } + + // 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 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) + { + 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); + + 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 + { + 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(); + } + } + } + + 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; + } + } + } + + // 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) + { + 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) + { + 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/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/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..e65cae1bb --- /dev/null +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -0,0 +1,83 @@ +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); + + var limits = await documents.TypedWriteLimitsAsync(_type, 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) + { + ArgumentNullException.ThrowIfNull(entry); + + // 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, isUpdate: true), 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)); + + 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)); + + 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. + // 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.None) 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 a44f6923c..2a0aa784e 100644 --- a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs @@ -3,9 +3,12 @@ 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.Jobs; using FluentCMS.Core.Localization; +using FluentCMS.Core.ProseMirror; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Core.Storage; @@ -83,6 +86,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<>)); + services.AddSingleton(builder.Jobs.Options); services.AddSingleton(); services.AddSingleton(); diff --git a/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs b/src/FluentCMS.Core/Localization/LocaleFallbackReader.cs index 80603f6e4..ee06b9ad5 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 "". @@ -147,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/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..8561cf16b --- /dev/null +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -0,0 +1,174 @@ +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. 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. + 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; 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. + /// 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. 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. + /// 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 65e00d1c1..16ec49387 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 @@ -145,6 +189,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! @@ -174,6 +225,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 @@ -209,6 +261,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? @@ -727,6 +784,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! @@ -754,6 +812,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 @@ -827,6 +888,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 @@ -839,6 +902,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/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..e21d14f4c --- /dev/null +++ b/tests/FluentCMS.Architecture.Tests/ContentTableReaches.cs @@ -0,0 +1,74 @@ +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"]; + + // These open transactions on SystemDbContext for the job tables, and reach no content. + private static readonly string[] AllowedTypes = ["FluentCMS.Core.Jobs.JobStore", "FluentCMS.Core.Jobs.RecurringJobs"]; + + 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 AllowedTypes.Contains(root.FullName) || 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.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" + || (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 33b8e9b0e..2f6954841 100644 --- a/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs +++ b/tests/FluentCMS.Architecture.Tests/LayeringRulesTests.cs @@ -16,10 +16,17 @@ 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 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() @@ -74,6 +81,78 @@ 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 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(everything.Value); + } + + // The evaluator asks whether an entry is in a site, and SiteManager whether a site has entries; neither reads content. + [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(everything.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(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) => 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/ContentServiceAuditTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceAuditTests.cs new file mode 100644 index 000000000..8e6552a83 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceAuditTests.cs @@ -0,0 +1,127 @@ +using FluentCMS.Content; +using FluentCMS.Core.Storage.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +// A job acts for someone: what it writes records them, and what it may do stays the system principal's. +public sealed class ContentServiceAuditTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static readonly Principal Scheduler = new(PrincipalKind.AdminUser, Guid.CreateVersion7(), "Scheduler"); + + private async Task AsJobAsync(Principal? actor, Func> work) + { + using var scope = _database.Database.NewScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + accessor.Current = new OperationContext(Principal.System, Origin.Job, SiteA, "en", null, "job", DateTimeOffset.UtcNow) { Actor = actor }; + try + { + return await work(scope.ServiceProvider.GetRequiredService()); + } + finally + { + accessor.Current = null; + } + } + + private List Documents(Guid id) => + _database.Read(context => context.Documents.IgnoreQueryFilters().AsNoTracking().Where(document => document.DocumentId == id).ToList()); + + [Fact] + public async Task AJobWithAnActorRecordsTheActorAndKeepsTheSystemPrincipalsAccess() + { + // The actor holds no role, so all of this is the system principal's access: a hidden and a read-only field, + // which no client may write, and the trash, with no grant behind any of it. + var entry = await AsJobAsync(Scheduler, service => service.CreateAsync("article", new() { ["title"] = "T", ["secret"] = "s", ["stamp"] = "x" }, Token)); + var id = IdOf(entry); + + var created = Documents(id).ShouldHaveSingleItem(); + created.CreatedBy.ShouldBe(Scheduler.Id); + created.UpdatedBy.ShouldBe(Scheduler.Id); + _database.Versions(id).ShouldHaveSingleItem().AuthorId.ShouldBe(Scheduler.Id); + _database.Row("cms_article", id)["secret"].ShouldBe("s"); + + var editor = await _database.CreateAsync(Editor, new() { ["title"] = "By an editor" }); + await AsJobAsync(Scheduler, service => service.PatchAsync("article", IdOf(editor), new() { ["stamp"] = "y" }, 1, Token)); + var updated = Documents(IdOf(editor)).ShouldHaveSingleItem(); + updated.CreatedBy.ShouldBe(Editor.Id); + updated.UpdatedBy.ShouldBe(Scheduler.Id); + _database.Versions(IdOf(editor)).Last().AuthorId.ShouldBe(Scheduler.Id); + + await AsJobAsync(Scheduler, async service => + { + await service.DeleteAsync("article", id, Token); + return null; + }); + Documents(id).ShouldHaveSingleItem().DeletedBy.ShouldBe(Scheduler.Id); + + await AsJobAsync(Scheduler, service => service.RestoreAsync("article", id, Token)); + Documents(id).ShouldHaveSingleItem().DeletedBy.ShouldBeNull(); + } + + [Fact] + public async Task AnActorGivesAClientPrincipalNothingEither() + { + var refused = _database.AsAsync( + Principal.Anonymous, + services => + { + var accessor = services.GetRequiredService(); + accessor.Current = accessor.Current! with { Actor = Scheduler }; + return services.GetRequiredService().CreateAsync("article", new() { ["title"] = "T" }, Token); + }); + + await Should.ThrowAsync(() => refused); + } + + [Fact] + public async Task AnEntryAJobWroteForAnActorIsTheActorsOwn() + { + var author = AuthorRole(Guid.CreateVersion7()); + + var entry = await AsJobAsync(author, service => service.CreateAsync("article", new() { ["title"] = "T" }, Token)); + + (await _database.GetAsync(author, IdOf(entry))).ShouldNotBeNull(); + (await _database.GetAsync(AuthorRole(Guid.CreateVersion7()), IdOf(entry))).ShouldBeNull(); + } + + [Fact] + public async Task ASystemOperationWithNoActorPassesAsAJobContextAndRecordsTheSystemPrincipal() + { + var context = new OperationContext(Principal.System, Origin.Job, SiteA, "en", null, "job", DateTimeOffset.UtcNow); + context.Actor.ShouldBeNull(); + context.AuditPrincipal.ShouldBeSameAs(Principal.System); + SystemOperation.Allows(context.Origin).ShouldBeTrue(); + + var entry = await AsJobAsync(null, service => service.CreateAsync("article", new() { ["title"] = "T" }, Token)); + + var created = Documents(IdOf(entry)).ShouldHaveSingleItem(); + created.CreatedBy.ShouldBe(Principal.System.Id); + created.UpdatedBy.ShouldBe(Principal.System.Id); + _database.Versions(IdOf(entry)).ShouldHaveSingleItem().AuthorId.ShouldBe(Principal.System.Id); + } + + [Fact] + public async Task AJobContextBegunThroughSystemOperationIsTheSame() + { + using var scope = _database.Database.NewScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + + using (SystemOperation.Begin(accessor, Origin.Job, SiteA)) + { + accessor.Current!.Actor.ShouldBeNull(); + accessor.Current.AuditPrincipal.ShouldBeSameAs(Principal.System); + + var entry = await scope.ServiceProvider.GetRequiredService().CreateAsync("article", new() { ["title"] = "T" }, Token); + entry["title"].ShouldBe("T"); + } + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceCarryTests.cs new file mode 100644 index 000000000..06a832245 --- /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([hiddenTag, newTag]); + } + + [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/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/ContentServiceLimitsTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs new file mode 100644 index 000000000..cc270a394 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceLimitsTests.cs @@ -0,0 +1,339 @@ +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 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 new file mode 100644 index 000000000..706383f3e --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceReadTests.cs @@ -0,0 +1,241 @@ +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() + { + 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.AsAsync(Editor, services => services.GetRequiredService().CountAsync("article", null, QueryStatus.Draft, Token), locale: "en", site: SiteB)).ShouldBe(1); + } + + [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/ContentServiceSecondRoundTests.cs b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs new file mode 100644 index 000000000..51b39ebc0 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceSecondRoundTests.cs @@ -0,0 +1,680 @@ +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 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(); + + 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); + } + + [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 +{ + 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/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 new file mode 100644 index 000000000..0d49b8077 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ContentServiceWriteTests.cs @@ -0,0 +1,750 @@ +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.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"))) + .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, 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); + + 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] + 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); + + // 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.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/Jobs/JobExecutionTests.cs b/tests/FluentCMS.Data.Tests/Jobs/JobExecutionTests.cs index 353063bab..c38e97166 100644 --- a/tests/FluentCMS.Data.Tests/Jobs/JobExecutionTests.cs +++ b/tests/FluentCMS.Data.Tests/Jobs/JobExecutionTests.cs @@ -333,6 +333,7 @@ public async Task AJobRunsAsTheSystemPrincipalWithOriginJob() operation.Origin.ShouldBe(Origin.Job); operation.Actor.ShouldBeNull(); operation.AuditPrincipal.ShouldBeSameAs(Principal.System); + SystemOperation.Allows(operation.Origin).ShouldBeTrue(); operation.Site.ShouldBe(new SiteId(DefaultSite.Id)); operation.Locale.ShouldBe(DefaultSite.Locale); operation.CorrelationId.ShouldBe(id.ToString()); 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); } 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]