From 15c42361487a049bcde1823230683c32eda7de24 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 17:49:28 -0500 Subject: [PATCH 1/8] Add publishing transitions, versions and reference integrity --- docs/planning/backend-technical-spec.md | 16 +- .../Content/ContentErrorCodes.cs | 1 + .../Content/ContentService.Publishing.cs | 612 +++++++++++++++ src/FluentCMS.Core/Content/ContentService.cs | 12 +- src/FluentCMS.Core/Content/EntryChange.cs | 4 + src/FluentCMS.Core/Content/EntryErrors.cs | 28 + src/FluentCMS.Core/Content/EntryStore.cs | 342 ++++++++- .../Content/Query/ContentQueryEngine.cs | 26 +- .../Content/Query/HiddenFields.cs | 19 +- .../Content/TypedContentService.cs | 29 +- .../Content/Validation/FieldValue.cs | 39 + src/FluentCMS.Core/Content/VersionDiff.cs | 71 ++ .../Schema/AttributeSchemaReader.cs | 16 + src/FluentCMS.Core/Schema/SchemaJsonReader.cs | 21 + src/FluentCMS.Core/Schema/SchemaJsonWriter.cs | 11 + .../Schema/SchemaModelFactory.cs | 2 + src/FluentCMS.Core/Schema/TypeBuilder.cs | 15 + src/FluentCMS.Core/Schema/TypeDraft.cs | 5 + .../Storage/Entities/ContentVersion.cs | 1 + src/FluentCMS.Sdk/Content/IContentService.cs | 112 +++ src/FluentCMS.Sdk/PublicAPI.Unshipped.txt | 112 +++ .../Publishing/PublishPolicyAttribute.cs | 15 + src/FluentCMS.Sdk/Publishing/PublishResult.cs | 7 + .../Publishing/PublishWarning.cs | 9 + .../Publishing/PublishWarningCodes.cs | 11 + .../Publishing/UnpublishedReferencePolicy.cs | 11 + src/FluentCMS.Sdk/Publishing/VersionChange.cs | 10 + .../Publishing/VersionChangeKind.cs | 14 + src/FluentCMS.Sdk/Publishing/VersionInfo.cs | 14 + src/FluentCMS.Sdk/Schema/ITypeBuilder.cs | 10 + src/FluentCMS.Sdk/Schema/SchemaType.cs | 8 + .../Schema/PublishPolicyTests.cs | 67 ++ .../Content/PublishingAccessTests.cs | 715 ++++++++++++++++++ .../Content/PublishingDatabase.cs | 67 ++ .../Content/PublishingTransitionTests.cs | 616 +++++++++++++++ .../Content/ReferenceIntegrityTests.cs | 312 ++++++++ .../Content/VersionDiffTests.cs | 106 +++ .../Query/PopulationTests.cs | 6 +- 38 files changed, 3423 insertions(+), 69 deletions(-) create mode 100644 src/FluentCMS.Core/Content/ContentService.Publishing.cs create mode 100644 src/FluentCMS.Core/Content/VersionDiff.cs create mode 100644 src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs create mode 100644 src/FluentCMS.Sdk/Publishing/PublishResult.cs create mode 100644 src/FluentCMS.Sdk/Publishing/PublishWarning.cs create mode 100644 src/FluentCMS.Sdk/Publishing/PublishWarningCodes.cs create mode 100644 src/FluentCMS.Sdk/Publishing/UnpublishedReferencePolicy.cs create mode 100644 src/FluentCMS.Sdk/Publishing/VersionChange.cs create mode 100644 src/FluentCMS.Sdk/Publishing/VersionChangeKind.cs create mode 100644 src/FluentCMS.Sdk/Publishing/VersionInfo.cs create mode 100644 tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/PublishingDatabase.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/VersionDiffTests.cs diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 775e89bce..6b9545f07 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -68,7 +68,7 @@ ASP.NET Core host (your app, or FluentCMS.Host) | -> authenticate, resolve site and locale, parse the query, map errors, cache | +-- In-process services: the only place rules are enforced -| IContentService, IPublishingService, IMediaService, IAuthorizationEvaluator, +| IContentService, IMediaService, IAuthorizationEvaluator, | IActivityLog, IEventBus, IJobScheduler | -> permissions, row rules, field filtering, validation, hooks, versions | @@ -289,7 +289,7 @@ Rows 8.3, 8.11, 8.14, 8.16 and every attribute and interface named below. | --- | --- | | `FluentCMS.Schema` | content type and field attributes, `ContentItem` base class, `RichText`, `BlockList`, `Ref`, `RefList`, `IFieldType`, fluent `ISchemaBuilder`, rich text contracts `IRichTextRenderer`, `IRichTextNodeRenderer`, `IRichTextNodeContext`, `RichTextRenderOptions`, `RichTextFormats` | | `FluentCMS.Content` | `IContentService`, `IContentService`, `ContentDocument`, `ContentQuery`, filter AST, `PagedResult`, `OperationContext`, `Origin`, `Principal` | -| `FluentCMS.Publishing` | `EntryStatus`, `VersionInfo`, `ScheduleRequest`, `IPublishingService` | +| `FluentCMS.Publishing` | `EntryStatus`, `VersionInfo`, `VersionChange`, `PublishResult`, `PublishWarning`, `PublishPolicyAttribute`, `ScheduleRequest` | | `FluentCMS.Hooks` | hook interfaces and `HookContext` | | `FluentCMS.Events` | `IEventBus`, `IEventHandler`, event records | | `FluentCMS.Jobs` | `IJob`, `IJobScheduler`, `JobOptions` | @@ -537,16 +537,20 @@ Rows 6.1 to 6.11; decisions 38, 39, 40, 41. | Save draft | upsert state 0 rows, `draft_version++`, write `cms_version` | draft rules | `EntryDraftSavedEvent` | | Publish | copy state 0 rows to state 1, set `published_version`, `published_at` | full rules plus reference integrity | `EntryPublishedEvent` | | Unpublish | delete state 1 rows, status `Unpublished` | permission | `EntryUnpublishedEvent` | -| Discard draft | copy state 1 rows to state 0 | permission | `EntryDraftDiscardedEvent` | -| Restore version | new draft from snapshot | draft rules | `EntryVersionRestoredEvent` | +| Discard draft | copy state 1 rows to state 0, as a new version; needs `update` | permission | `EntryDraftDiscardedEvent` | +| Restore version | new draft from snapshot; needs `update` | draft rules | `EntryVersionRestoredEvent` | | Trash | set `deleted_at`; excluded by the global query filter | permission | `EntryTrashedEvent` | | Restore from trash | clear `deleted_at` | permission | `EntryRestoredEvent` | | Purge | delete all rows; separate permission `content.{type}.purge` | | `EntryPurgedEvent` | | Schedule | row in `cms_schedule`, job enqueued | | `EntryScheduledEvent` | -**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50) prunes oldest drafts; versions that were published are never pruned. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. +**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, which `status_after` records. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. -**Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. +**Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. A target counts as published when it has a published row in the entry's locale or its fallback chain. The answer must not be a way to ask about an entry the principal may not read, since a write takes any id: a target of a type it may not read is never confirmed, so it is no warning and, under `Block`, always a refusal; one its row rule refuses, one in the trash or in another site and one that does not exist read the same and name no id; a reference in a field it may not read, or a hidden one, is no warning and, under `Block`, a refusal that does not say where. + +**Published reads.** A published read shows only what is published. A relation target that is trashed or has no published row in any locale is returned as null, or left out of a multiple relation. A target published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed or unpublished target as a reference, because that is what its author attached. A filter on the relation still matches such a target: it answers about the source entry and a value the caller named. + +**Shared fields.** Non-localizable fields live on one row per state, so publishing one locale publishes them for every published locale. A grant limited to some locales may publish only when those fields have not changed, or no other locale is published. The other locales' status is recomputed after a transition that moves the shared row. **Scheduling.** `cms_schedule (id, document_id, locale, action, run_at_utc, time_zone, status, job_id, created_by)`. The job runner executes `ScheduledPublishJob` and `ScheduledUnpublishJob`; `cms_document` mirrors the next scheduled times for list views. Cancel and reschedule update the row and the job. diff --git a/src/FluentCMS.Core/Content/ContentErrorCodes.cs b/src/FluentCMS.Core/Content/ContentErrorCodes.cs index a110ec06b..203e14872 100644 --- a/src/FluentCMS.Core/Content/ContentErrorCodes.cs +++ b/src/FluentCMS.Core/Content/ContentErrorCodes.cs @@ -3,4 +3,5 @@ namespace FluentCMS.Core.Content; internal static class ContentErrorCodes { public const string SingletonExists = "FCMS2401"; + public const string NotPublished = "FCMS2402"; } diff --git a/src/FluentCMS.Core/Content/ContentService.Publishing.cs b/src/FluentCMS.Core/Content/ContentService.Publishing.cs new file mode 100644 index 000000000..bd8af255d --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentService.Publishing.cs @@ -0,0 +1,612 @@ +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.Schema.FieldTypes; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Publishing; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +// The transitions of spec 4.5 and the versions behind them. Each is a write like any other: the permission, the entry and +// its row rule, then in one transaction the field limits, the hooks, validation and the write, and the sink and the +// response only after the commit. What a version holds is read and restored as this principal may, not as it was saved. +internal sealed partial class ContentService +{ + public async Task> PublishAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Publish, cancellationToken, locale); + List warnings = []; + var row = await InTransactionAsync( + async () => + { + var (documents, document, target) = await ReachLocaleAsync(write, ContentOperation.Publish, id, cancellationToken); + await ThrowIfNotExpectedAsync(write, id, document, expectedVersion, cancellationToken); + + var draft = await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken); + var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); + var previous = document.PublishedVersion is null ? null : published; + + // Publishing moves a shared field for every locale published with it, so those are the ones a grant must cover. + var moved = target with { EntryLocales = [.. documents.Where(other => other.PublishedVersion is not null || other.Id == document.Id).Select(other => other.Locale)] }; + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Publish, id, moved, cancellationToken); + ThrowIfMovesWhatItMayNotWrite(write, draft, published, writable, ContentOperation.Publish); + + var original = EntryJson.Fields(write.Type, draft); + var working = ContentCopy.Of(draft); + await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, CopyOf(previous), cancellationToken); + working = Settle(write, working, original, draft, writable); + await ValidateForPublishAsync(write, working, document, warnings, cancellationToken); + + var validated = EntryJson.Fields(write.Type, working); + await hooks.RunAsync(HookKind.BeforePublish, write.Type, write.Operation, working, CopyOf(previous), cancellationToken); + working = Settle(write, working, validated, draft, writable); + var settled = EntryJson.Fields(write.Type, working); + if (!JsonNode.DeepEquals(validated, settled)) + { + await ValidateForPublishAsync(write, working, document, warnings, cancellationToken); + } + + // What a hook changed is a new draft version, so that the published state is a version like any other. + if (!JsonNode.DeepEquals(original, settled)) + { + var status = document.Status == EntryStatus.Published ? EntryStatus.Modified : document.Status; + await WriteOverDraftAsync(write, id, document, working, status, null, cancellationToken); + await ThrowIfOutOfReachAsync(write, ContentOperation.Publish, id, cancellationToken); + } + + await store.CopyDraftToPublishedAsync(write.Type, document, cancellationToken); + if (!await store.MarkPublishedAsync(document, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken)) + { + throw await ConflictAsync(write, id, document.DraftVersion, await store.DraftVersionAsync(id, document.Locale, cancellationToken), cancellationToken); + } + + await RecomputeOtherLocalesAsync(write, documents, document, cancellationToken); + + EntryStore.ApplySystem(working, document); + await hooks.RunAsync(HookKind.AfterPublish, write.Type, write.Operation, working, CopyOf(previous), cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.Published, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return new PublishResult(await RespondAsync(write, row), warnings); + } + + public async Task UnpublishAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Unpublish, cancellationToken, locale); + var row = await InTransactionAsync( + async () => + { + var (documents, document, target) = await ReachLocaleAsync(write, ContentOperation.Unpublish, id, cancellationToken); + ThrowIfNotPublished(write, document); + + // The whole entry goes out of sight, so a grant limited to some fields does not reach far enough. + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Unpublish, id, target with { EntryLocales = [document.Locale] }, cancellationToken); + if (write.Type.Fields.Any(field => !field.Hidden && !field.ReadOnly && !writable.Includes(field.Name))) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Unpublish)); + } + + var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); + var working = ContentCopy.Of(published); + await hooks.RunAsync(HookKind.BeforeUnpublish, write.Type, write.Operation, working, ContentCopy.Of(published), cancellationToken); + + var isLast = !documents.Any(other => other.Id != document.Id && other.PublishedVersion is not null); + await store.DeletePublishedAsync(write.Type, document, isLast, cancellationToken); + await store.MarkUnpublishedAsync(document, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken); + + await hooks.RunAsync(HookKind.AfterUnpublish, write.Type, write.Operation, working, ContentCopy.Of(published), cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.Unpublished, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return await RespondAsync(write, row); + } + + public async Task DiscardDraftAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Update, cancellationToken, locale); + var row = await InTransactionAsync( + async () => + { + var (documents, document, target) = await ReachLocaleAsync(write, ContentOperation.Update, id, cancellationToken); + ThrowIfNotPublished(write, document); + await ThrowIfNotExpectedAsync(write, id, document, expectedVersion, cancellationToken); + + var stored = await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken); + var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); + if (SameFields(write.Type, stored, published)) + { + return document; + } + + // The published values go back whole for a principal that may write all of them. For one that may not, it is + // as an update: only what it may write changes, and the hidden and read-only values stay with what it left. + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); + ContentDocument incoming = []; + foreach (var field in write.Type.Fields.Where(field => writable.Allowed && (writable.Fields is null || (!field.Hidden && !field.ReadOnly && writable.Includes(field.Name))))) + { + incoming[field.Name] = published.GetValueOrDefault(field.Name); + } + + var working = Prepare(write, EntryComposer.Replace(write.Type, stored, incoming, static _ => true), document, stored); + working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, [], writable, document), cancellationToken); + + await WriteOverDraftAsync(write, id, document, working, SameFields(write.Type, working, published) ? EntryStatus.Published : EntryStatus.Modified, "Discarded the draft.", cancellationToken); + await RecomputeOtherLocalesAsync(write, documents, document, cancellationToken); + await ThrowIfOutOfReachAsync(write, ContentOperation.Update, id, cancellationToken); + + EntryStore.ApplySystem(working, document); + await hooks.RunAsync(HookKind.AfterUpdate, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.DraftDiscarded, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return await RespondAsync(write, row); + } + + public async Task> GetVersionsAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var (_, document) = await BeginVersionReadAsync(type, id, locale, cancellationToken); + var versions = await store.VersionsAsync(id, document.Locale, cancellationToken); + return [.. versions.Select(version => new VersionInfo(version.Number, version.Locale, version.StatusAfter, version.AuthorId, version.Origin, version.ClientId, version.Message, version.CreatedAt))]; + } + + public async Task GetVersionAsync(string type, Guid id, int number, string? locale = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var (write, document) = await BeginVersionReadAsync(type, id, locale, cancellationToken); + if (await store.VersionAsync(id, document.Locale, number, cancellationToken) is not { } version) + { + return null; + } + + var fields = await ReadableFieldsOfAsync(write, version, cancellationToken); + ContentDocument entry = new() + { + ["id"] = id, + ["status"] = version.StatusAfter, + ["locale"] = version.Locale, + ["version"] = version.Number, + ["updatedAt"] = version.CreatedAt, + }; + + foreach (var (name, value) in fields) + { + if (write.Type.FindField(name) is { } field && canonicalizer.TryRead(field, value, out var read)) + { + entry[name] = AsRead(field, read); + } + } + + write.Access.OmitUnreadable(entry, write.Type, write.Target); + return entry; + } + + public async Task RestoreVersionAsync(string type, Guid id, int number, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Update, cancellationToken, locale); + var row = await InTransactionAsync( + async () => + { + var (documents, document, target) = await ReachLocaleAsync(write, ContentOperation.Update, id, cancellationToken); + await ThrowIfNotExpectedAsync(write, id, document, expectedVersion, cancellationToken); + var version = await store.VersionAsync(id, document.Locale, number, cancellationToken) + ?? throw new EntryNotFoundException(write.Type.Name, id); + + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); + var stored = await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken); + var restored = await RestorableFieldsAsync(write, version, writable, cancellationToken); + + var isSystem = write.Permissions.IsSystem; + var composed = EntryComposer.Replace(write.Type, stored, restored, field => !isSystem && Keeps(write, field, writable, addsLocale: false)); + var working = Prepare(write, composed, document, stored); + if (!isSystem) + { + composer.CarryProtected(write.Type, stored, working, includingGroups: true); + var shown = await ReadShownAsync(write, id, documents, addsLocale: false, cancellationToken); + await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch: false, cancellationToken); + } + + working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, Client(restored), writable, document), cancellationToken); + + var status = document.Status; + if (document.PublishedVersion is not null) + { + var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); + status = SameFields(write.Type, working, published) ? EntryStatus.Published : EntryStatus.Modified; + } + + var message = string.Create(CultureInfo.InvariantCulture, $"Restored version {number}."); + await WriteOverDraftAsync(write, id, document, working, status, message, cancellationToken); + await RecomputeOtherLocalesAsync(write, documents, document, cancellationToken); + await ThrowIfOutOfReachAsync(write, ContentOperation.Update, id, cancellationToken); + + EntryStore.ApplySystem(working, document); + await hooks.RunAsync(HookKind.AfterUpdate, write.Type, write.Operation, working, ContentCopy.Of(stored), cancellationToken); + _pending.Add(new EntryChange(EntryChangeKind.VersionRestored, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); + return document; + }, + cancellationToken); + + return await RespondAsync(write, row); + } + + public async Task> DiffAsync(string type, Guid id, int fromVersion, int toVersion, string? locale = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var (write, document) = await BeginVersionReadAsync(type, id, locale, cancellationToken); + var before = await store.VersionAsync(id, document.Locale, fromVersion, cancellationToken); + var after = await store.VersionAsync(id, document.Locale, toVersion, cancellationToken); + if (before is null || after is null) + { + throw new EntryNotFoundException(write.Type.Name, id); + } + + return VersionDiff.Between(await ReadableFieldsOfAsync(write, before, cancellationToken), await ReadableFieldsOfAsync(write, after, cancellationToken)); + } + + // Versions are a draft's history, so they need what a draft read needs, and they are for those who manage content: a + // preview key shows the draft as it is, not everything it has been. + private async Task<(WriteScope Write, Document Document)> BeginVersionReadAsync(string type, Guid id, string? locale, CancellationToken cancellationToken) + { + var write = await BeginAsync(type, ContentOperation.Read, cancellationToken, locale); + if (!write.Permissions.CanManage) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Read)); + } + + var (_, document, _) = await ReachLocaleAsync(write, ContentOperation.Read, id, cancellationToken); + return (write, document); + } + + // No row in the locale, in another site or of another type, or refused by the row rule all read as no entry. + private async Task<(List Documents, Document Document, AccessTarget Target)> ReachLocaleAsync(WriteScope write, ContentOperation operation, Guid id, CancellationToken cancellationToken) + { + var documents = await store.DocumentsAsync(write.Type, id, write.Site, trashed: false, cancellationToken); + var document = documents.Find(item => item.Locale == write.DocumentLocale) + ?? throw new EntryNotFoundException(write.Type.Name, id); + + var target = write.Target with { EntryLocales = [.. documents.Select(item => item.Locale)] }; + if (!await evaluator.CanAccessEntryAsync(write.Operation, write.Type, operation, id, target, QueryStatus.Draft, cancellationToken)) + { + throw new EntryNotFoundException(write.Type.Name, id); + } + + return (documents, document, target); + } + + private async Task ThrowIfNotExpectedAsync(WriteScope write, Guid id, Document document, int? expected, CancellationToken cancellationToken) + { + if (expected is { } version && version != document.DraftVersion) + { + throw await ConflictAsync(write, id, version, document.DraftVersion, cancellationToken); + } + } + + private static void ThrowIfNotPublished(WriteScope write, Document document) + { + if (document.PublishedVersion is null) + { + throw new ValidationException([new ValidationError("", ContentErrorCodes.NotPublished, $"This {write.Type.Name} is not published in this locale.")]); + } + } + + // A grant limited to some fields publishes only what stays within them, and it does not say which it was not, since it + // may not be able to read that field. A hidden or read-only field has no grant to ask; a hook is what fills it. + private static void ThrowIfMovesWhatItMayNotWrite(WriteScope write, ContentDocument draft, ContentDocument published, WritableFields writable, ContentOperation operation) + { + foreach (var field in write.Type.Fields.Where(field => !field.Hidden && !field.ReadOnly && !writable.Includes(field.Name))) + { + var from = published.GetValueOrDefault(field.Name); + var to = draft.GetValueOrDefault(field.Name); + if (!(EntryJson.Same(from, to) || (LocaleFallbackReader.IsMissing(from) && LocaleFallbackReader.IsMissing(to)))) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(operation)); + } + } + } + + // A publish makes no slug: the draft's values are what was reviewed. A hook may set one, and answers to the same limits as on a save. + private ContentDocument Settle(WriteScope write, ContentDocument working, JsonObject baseline, ContentDocument stored, WritableFields writable) + { + var canonical = canonicalizer.Canonicalize(write.Type, working, static (_, _) => false, stored); + var fields = EntryJson.Fields(write.Type, canonical); + write.Access.RejectHookChanges(write.Type.Fields.Select(field => field.Name).Where(name => !JsonNode.DeepEquals(baseline[name], fields[name])), write.Type, writable); + return canonical; + } + + private async Task ValidateForPublishAsync(WriteScope write, ContentDocument working, Document document, List warnings, CancellationToken cancellationToken) + { + warnings.Clear(); + var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, working) : null; + var result = await validation.ValidateAsync( + write.Type, + mapper.ValidationValues(write.Type, working), + entry, + write.Operation, + ValidationMode.Publish, + cancellationToken, + async (context, token) => + { + await store.CheckUniqueAsync(write.Type, working, document.DocumentId, write.Site, write.Locale, context, token, publishing: true); + await CheckReferencesAsync(write, working, context, warnings, token); + }); + + var errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).ForPublish(result.Errors, EntryJson.Fields(write.Type, working)); + if (errors.Count > 0) + { + throw new ValidationException(errors); + } + } + + // Reference integrity, on what is about to be published. The answer must not be how a principal learns of what it may + // not read: a write takes any id without looking, so a warning or a refusal that told a missing target from a published + // one would be a way to ask about any entry. So a target of a type it may not read is never confirmed, and is no warning + // and, where the type blocks, always a refusal; and one its row rule refuses reads the same as one that is not there. + // A reference in a field it may not read is decided on what is true, and says nothing of where. + private async Task CheckReferencesAsync(WriteScope write, ContentDocument working, ValidationContext context, List warnings, CancellationToken cancellationToken) + { + var blocks = write.Type.UnpublishedReferences == UnpublishedReferencePolicy.Block; + var locales = (await fallback.ChainAsync(write.Site, write.Locale, LocaleFallback.Chain, cancellationToken)).Locales; + var isSystem = write.Permissions.IsSystem; + HashSet<(string Path, string Code, Guid? Id)> told = []; + + foreach (var reference in store.TargetsOf(write.Type, working)) + { + var visible = isSystem || (reference.IsShown && write.Permissions.CanReadField(write.Type, reference.Field, write.Target)); + var target = schema.FindContentType(reference.TargetType); + if (target is not null && !write.Permissions.Allows(target, ContentOperation.Read, write.Target)) + { + if (blocks) + { + context.AddError("", PublishWarningCodes.UnavailableReference, "The entry refers to an entry the principal cannot confirm."); + } + + continue; + } + + var state = target is null + ? ReferenceTargetState.Unavailable + : await store.TargetStateAsync(target, reference.Id, write.Site, locales, cancellationToken); + + if (visible && state != ReferenceTargetState.Unavailable + && !await evaluator.CanAccessEntryAsync(write.Operation, target!, ContentOperation.Read, reference.Id, write.Target, QueryStatus.Draft, cancellationToken)) + { + state = ReferenceTargetState.Unavailable; + } + + if (state == ReferenceTargetState.Published) + { + continue; + } + + if (!visible) + { + if (blocks) + { + context.AddError("", PublishWarningCodes.UnavailableReference, "The entry refers to an entry the principal cannot confirm."); + } + + continue; + } + + var unpublished = state == ReferenceTargetState.Unpublished; + var code = unpublished ? PublishWarningCodes.UnpublishedReference : PublishWarningCodes.UnavailableReference; + var message = unpublished ? $"'{reference.Path}' refers to an entry that is not published." : $"'{reference.Path}' refers to an entry that cannot be reached."; + if (blocks) + { + context.AddError(reference.Field, code, message); + } + else + { + var named = unpublished || isSystem ? reference.Id : (Guid?)null; + if (told.Add((reference.Path, code, named))) + { + warnings.Add(new PublishWarning(reference.Path, code, message, named is null ? null : reference.TargetType, named)); + } + } + } + } + + // The save at the end of a transition: the version moves as on any save, under the same swap. + private async Task WriteOverDraftAsync(WriteScope write, Guid id, Document document, ContentDocument working, EntryStatus status, string? message, CancellationToken cancellationToken) + { + var expected = document.DraftVersion; + if (!await store.AdvanceVersionAsync(id, document.Locale, expected, status, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken)) + { + throw await ConflictAsync(write, id, expected, await store.DraftVersionAsync(id, document.Locale, cancellationToken), cancellationToken); + } + + document.DraftVersion = expected + 1; + document.Status = status; + document.UpdatedAt = write.Operation.Timestamp; + document.UpdatedBy = write.Operation.AuditPrincipal.Id; + await store.WriteDraftAsync(write.Type, document, isNewDocument: false, working, write.Operation.Origin, write.Operation.ClientId, cancellationToken, message); + } + + private async Task ThrowIfOutOfReachAsync(WriteScope write, ContentOperation operation, Guid id, CancellationToken cancellationToken) + { + if (!await evaluator.CanAccessEntryAsync(write.Operation, write.Type, operation, id, write.Target, QueryStatus.Draft, cancellationToken)) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(operation)); + } + } + + // The shared row moved under the other locales, and each is Published only while its draft equals what is published. + private async Task RecomputeOtherLocalesAsync(WriteScope write, List documents, Document document, CancellationToken cancellationToken) + { + if (!write.Type.Localized) + { + return; + } + + foreach (var other in documents.Where(other => other.Id != document.Id && other.PublishedVersion is not null)) + { + var draft = await store.ReadAsync(write.Type, other, ContentState.Draft, cancellationToken); + var published = await store.ReadAsync(write.Type, other, ContentState.Published, cancellationToken); + var status = SameFields(write.Type, draft, published) ? EntryStatus.Published : EntryStatus.Modified; + if (status != other.Status) + { + await store.SetStatusAsync(other.DocumentId, other.Locale, status, cancellationToken); + } + } + } + + private static bool SameFields(SchemaType type, ContentDocument left, ContentDocument right) => + JsonNode.DeepEquals(EntryJson.Fields(type, left), EntryJson.Fields(type, right)); + + // A version as this principal may read it today: no hidden value at any depth, no field its grants leave out, no key + // the schema has since lost, and no relation target it may not read. Hidden fields show to no one, the system principal included. + private async Task ReadableFieldsOfAsync(WriteScope write, ContentVersion version, CancellationToken cancellationToken) + { + var snapshot = ParseSnapshot(version); + JsonObject fields = []; + foreach (var field in write.Type.Fields) + { + if (!field.Hidden && write.Permissions.CanReadField(write.Type, field.Name, write.Target) && snapshot[field.Name] is { } value) + { + var visible = value.DeepClone(); + HiddenFields.Remove(schema, visible, field, dropUnknown: true); + fields[field.Name] = visible; + } + } + + await DropUnreadableTargetsAsync(write, fields, cancellationToken); + return fields; + } + + // What a restore may take from a version. The system principal takes all of it; anyone else only what it could send + // itself, so that an old version is no way past the limits of a write. + private async Task RestorableFieldsAsync(WriteScope write, ContentVersion version, WritableFields writable, CancellationToken cancellationToken) + { + var isSystem = write.Permissions.IsSystem; + var snapshot = ParseSnapshot(version); + JsonObject fields = []; + foreach (var field in write.Type.Fields) + { + if ((isSystem || !Keeps(write, field, writable, addsLocale: false)) && snapshot[field.Name] is { } value) + { + var kept = value.DeepClone(); + HiddenFields.RemoveWhere(schema, kept, field, inner => !isSystem && (inner.Hidden || inner.ReadOnly)); + fields[field.Name] = kept; + } + } + + await DropUnreadableTargetsAsync(write, fields, cancellationToken); + ContentDocument restored = []; + foreach (var field in write.Type.Fields) + { + if (isSystem || !Keeps(write, field, writable, addsLocale: false)) + { + restored[field.Name] = fields[field.Name]?.DeepClone(); + } + } + + return restored; + } + + private static JsonObject ParseSnapshot(ContentVersion version) => + JsonNode.Parse(version.Snapshot) as JsonObject ?? []; + + // A read does not name these targets, and a version does not either. Fails closed on one since trashed, which only a + // draft read of the entry itself would show. References inside blocks and rich text stay as a draft read shows them. + private async Task DropUnreadableTargetsAsync(WriteScope write, JsonObject fields, CancellationToken cancellationToken) + { + if (write.Permissions.IsSystem) + { + return; + } + + foreach (var item in FieldStorage.Of(schema, write.Type).Where(item => item.Kind == FieldStorageKind.Reference)) + { + if (schema.FindContentType(EntryPaths.ReferenceTarget(item.Field)) is not { } target) + { + continue; + } + + var names = QueryPaths.ApiPath(write.Type, item).Split('.'); + var holder = fields; + foreach (var name in names[..^1]) + { + holder = holder?[name] as JsonObject; + } + + if (holder is null) + { + continue; + } + + switch (holder[names[^1]]) + { + case JsonArray ids: + JsonArray kept = []; + foreach (var value in ids) + { + if (await CanReadTargetAsync(write, target, value, cancellationToken)) + { + kept.Add(value?.DeepClone()); + } + } + + holder[names[^1]] = kept; + break; + case { } single when !await CanReadTargetAsync(write, target, single, cancellationToken): + holder[names[^1]] = null; + break; + } + } + } + + private async Task CanReadTargetAsync(WriteScope write, SchemaType target, JsonNode? value, CancellationToken cancellationToken) => + EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) + && await evaluator.CanAccessEntryAsync(write.Operation, target, ContentOperation.Read, id, write.Target, QueryStatus.Draft, cancellationToken); + + // A relation in the shape a read gives it, so that a version reads like the entry it was. + private object? AsRead(SchemaField field, object? value) + { + var name = EntryPaths.ReferenceTarget(field); + return value switch + { + Guid id when field.Relation is not null => Stub(id, name), + Guid[] ids when field.Relation is not null => ids.Select(id => Stub(id, name)).ToList(), + ContentDocument group when schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component => GroupAsRead(component, group), + _ => value, + }; + } + + private ContentDocument GroupAsRead(SchemaType component, ContentDocument group) + { + ContentDocument result = []; + foreach (var (key, value) in group) + { + result[key] = component.FindField(key) is { } inner ? AsRead(inner, value) : value; + } + + return result; + } + + private static ContentDocument Stub(Guid id, string type) => new() { ["id"] = id, ["type"] = type }; +} diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index dc4528609..ca0b01e18 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -22,7 +22,7 @@ namespace FluentCMS.Core.Content; // 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( +internal sealed partial class ContentService( CmsDbContext context, SchemaModel schema, IOperationContextAccessor accessor, @@ -377,26 +377,26 @@ private async Task> ReadEveryLocaleAsync(SchemaType type, return (field => permissions.CanReadField(type, field.Name, target), permissions.IsSystem); } - private async Task BeginAsync(string typeName, ContentOperation operationKind, CancellationToken cancellationToken) + private async Task BeginAsync(string typeName, ContentOperation operationKind, CancellationToken cancellationToken, string? locale = null) { 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) + var resolved = await ResolveLocaleAsync(operation.Site, locale ?? operation.Locale, cancellationToken); + if (resolved == ILocaleResolver.All) { throw new ValidationException([new ValidationError("locale", QueryErrorCodes.InvalidParameter, "A write needs one locale.")]); } - var target = new AccessTarget(operation.Site, locale); + var target = new AccessTarget(operation.Site, resolved); 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)); + return new WriteScope(operation, type, operation.Site, resolved, target, permissions, new FieldAccess(permissions, schema)); } // Runs writes as one transaction and tells the sink what committed. A write inside another transaction takes a diff --git a/src/FluentCMS.Core/Content/EntryChange.cs b/src/FluentCMS.Core/Content/EntryChange.cs index 8146948d4..80e10567d 100644 --- a/src/FluentCMS.Core/Content/EntryChange.cs +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -7,6 +7,10 @@ internal enum EntryChangeKind DraftSaved, Trashed, Restored, + Published, + Unpublished, + DraftDiscarded, + VersionRestored, } internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation); diff --git a/src/FluentCMS.Core/Content/EntryErrors.cs b/src/FluentCMS.Core/Content/EntryErrors.cs index 5c28b3dad..aa77739cd 100644 --- a/src/FluentCMS.Core/Content/EntryErrors.cs +++ b/src/FluentCMS.Core/Content/EntryErrors.cs @@ -18,6 +18,8 @@ internal sealed class EntryErrors(SchemaModel schema, SchemaType type, Effective public static readonly ValidationError ProducedValue = new("", ValidationErrorCodes.InvalidValue, "A value set while saving the entry is not valid."); + public static readonly ValidationError UnreadableValue = new("", ValidationErrorCodes.InvalidValue, "The entry cannot be published: a value the principal may not read 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) @@ -51,6 +53,32 @@ internal sealed class EntryErrors(SchemaModel schema, SchemaType type, Effective return (reportable, toCompare, produced); } + // On publish nothing was sent and every error blocks. One on a field the principal cannot read says nothing of that + // field, not even which one, since a rule's message can quote the value. + public List ForPublish(IReadOnlyList errors, JsonObject now) + { + List reportable = []; + var unreadable = false; + foreach (var error in errors) + { + if (permissions.IsSystem || Describe(error.Path, now) is not { } path || path.Readable) + { + reportable.Add(error); + } + else + { + unreadable = true; + } + } + + if (unreadable) + { + reportable.Add(UnreadableValue); + } + + return reportable; + } + // 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) { diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index 6b2fb5b21..65965e109 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -16,7 +16,7 @@ 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. +// permissions; ContentService decides, and calls this inside its transaction. internal sealed class EntryStore(CmsDbContext context, SchemaModel schema) { private const string MediaTarget = "media"; @@ -174,43 +174,245 @@ public Task SetTrashedAsync(Guid id, DateTimeOffset? at, Guid? by, CancellationT 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) + // version, in one save, and prunes the versions over the cap of the type. + public Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocument, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken, string? message = null) => + SaveOwnRowsAsync( + async () => + { + 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, + Message = message, + CreatedAt = row.UpdatedAt, + }); + }, + () => PruneVersionsAsync(type, row, cancellationToken), + cancellationToken); + + // Copies the draft rows of one locale, and the shared row they sit on, over the published ones. + public Task CopyDraftToPublishedAsync(SchemaType type, Document row, CancellationToken cancellationToken) => + SaveOwnRowsAsync( + async () => + { + await CopyRowAsync(StorageNames.Table(type), row.DocumentId, null, cancellationToken); + if (type.Localized) + { + await CopyRowAsync(StorageNames.LocaleTable(type), row.DocumentId, row.Locale, cancellationToken); + } + + await DeleteReferencesAsync(type, row, ContentState.Published, includingShared: true, cancellationToken); + var drafts = await ReferencesOf(type, row, ContentState.Draft, includingShared: true).AsNoTracking().ToListAsync(cancellationToken); + context.References.AddRange(drafts.Select(reference => new Reference + { + SourceType = reference.SourceType, + SourceDocumentId = reference.SourceDocumentId, + State = ContentState.Published, + Locale = reference.Locale, + Field = reference.Field, + Position = reference.Position, + TargetType = reference.TargetType, + TargetId = reference.TargetId, + })); + }, + null, + cancellationToken); + + // Removes the published rows of one locale. The shared row and its references serve the other published locales, so they go only with the last. + public async Task DeletePublishedAsync(SchemaType type, Document row, bool isLastPublished, 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 (type.Localized) + { + await PublishedRows(StorageNames.LocaleTable(type), row.DocumentId, row.Locale).ExecuteDeleteAsync(cancellationToken); + } + + if (!type.Localized || isLastPublished) + { + await PublishedRows(StorageNames.Table(type), row.DocumentId, null).ExecuteDeleteAsync(cancellationToken); + } + + await DeleteReferencesAsync(type, row, ContentState.Published, includingShared: !type.Localized || isLastPublished, cancellationToken); + } + + // A draft version the row must still hold: one that lost the swap has changed nothing. + public async Task MarkPublishedAsync(Document row, DateTimeOffset at, Guid by, CancellationToken cancellationToken) + { + var id = row.DocumentId; + var locale = row.Locale; + var version = row.DraftVersion; + var first = row.FirstPublishedAt ?? at; + var affected = await context.Documents + .Where(document => document.DocumentId == id && document.Locale == locale && document.DraftVersion == version) + .ExecuteUpdateAsync( + set => set + .SetProperty(document => document.Status, EntryStatus.Published) + .SetProperty(document => document.PublishedVersion, version) + .SetProperty(document => document.PublishedAt, at) + .SetProperty(document => document.FirstPublishedAt, first) + .SetProperty(document => document.UpdatedAt, at) + .SetProperty(document => document.UpdatedBy, by), + cancellationToken); + + if (affected != 1) + { + return false; + } + + // Pruning goes by this: a version that was published stays. + await context.Versions + .Where(item => item.DocumentId == id && item.Locale == locale && item.Number == version) + .ExecuteUpdateAsync(set => set.SetProperty(item => item.StatusAfter, EntryStatus.Published), cancellationToken); + + row.Status = EntryStatus.Published; + row.PublishedVersion = version; + row.PublishedAt = at; + row.FirstPublishedAt = first; + row.UpdatedAt = at; + row.UpdatedBy = by; + return true; + } + + public async Task MarkUnpublishedAsync(Document row, DateTimeOffset at, Guid by, CancellationToken cancellationToken) + { + var id = row.DocumentId; + var locale = row.Locale; + await context.Documents + .Where(document => document.DocumentId == id && document.Locale == locale) + .ExecuteUpdateAsync( + set => set + .SetProperty(document => document.Status, EntryStatus.Unpublished) + .SetProperty(document => document.PublishedVersion, (int?)null) + .SetProperty(document => document.PublishedAt, (DateTimeOffset?)null) + .SetProperty(document => document.UpdatedAt, at) + .SetProperty(document => document.UpdatedBy, by), + cancellationToken); + + row.Status = EntryStatus.Unpublished; + row.PublishedVersion = null; + row.PublishedAt = null; + row.UpdatedAt = at; + row.UpdatedBy = by; + } + + public Task SetStatusAsync(Guid id, string locale, EntryStatus status, CancellationToken cancellationToken) => + context.Documents + .Where(document => document.DocumentId == id && document.Locale == locale) + .ExecuteUpdateAsync(set => set.SetProperty(document => document.Status, status), cancellationToken); + + public async Task> VersionsAsync(Guid id, string locale, CancellationToken cancellationToken) + { + var versions = await context.Versions.AsNoTracking() + .Where(version => version.DocumentId == id && version.Locale == locale) + .ToListAsync(cancellationToken); + + return [.. versions.OrderByDescending(version => version.Number)]; + } + + public Task VersionAsync(Guid id, string locale, int number, CancellationToken cancellationToken) => + context.Versions.AsNoTracking() + .FirstOrDefaultAsync(version => version.DocumentId == id && version.Locale == locale && version.Number == number, cancellationToken); + + // Whether a target is there and published in one of these locales. The query filter on Document leaves out the trash, + // and a target of another type or another site is not there either. + public async Task TargetStateAsync(SchemaType target, Guid id, SiteId site, IReadOnlyList locales, CancellationToken cancellationToken) + { + var typeName = target.Name; + var siteId = (Guid?)site.Value; + var rows = context.Documents.AsNoTracking().Where(document => document.DocumentId == id && document.Type == typeName); + if (target.Scope == ContentScope.Site) + { + rows = rows.Where(document => document.SiteId == siteId); + } + + var found = await rows.Select(document => new { document.Locale, document.PublishedVersion }).ToListAsync(cancellationToken); + if (found.Count == 0) { - if (isNewDocument) + return ReferenceTargetState.Unavailable; + } + + return found.Any(row => row.PublishedVersion is not null && (!target.Localized || locales.Contains(row.Locale, StringComparer.OrdinalIgnoreCase))) + ? ReferenceTargetState.Published + : ReferenceTargetState.Unpublished; + } + + // What an entry refers to, with whether the reference is outside what hidden fields hide. Media has no row here to check. + public List TargetsOf(SchemaType type, ContentDocument entry) + { + List targets = []; + foreach (var item in FieldStorage.Of(schema, type)) + { + var path = QueryPaths.ApiPath(type, item); + var value = EntryPaths.ValueAt(entry, item, type); + List<(string Target, Guid Id)> all = []; + Collect(item.Field, value, all); + if (all.Count == 0) { - context.Documents.Add(row); + continue; } - 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) + var top = path.Split('.')[0]; + List<(string Target, Guid Id)> shown = []; + if (!item.Field.Hidden && type.FindField(top) is { Hidden: false }) { - await WriteRowAsync(StorageNames.LocaleTable(type), type, row, row.Locale, stored.Where(item => item.Localized), entry, cancellationToken); + Collect(item.Field, WithoutHidden(item.Field, value), shown); } - await ReplaceReferencesAsync(type, row, stored, entry, cancellationToken); - - context.Versions.Add(new ContentVersion + foreach (var (target, id) in all.Where(found => found.Target != MediaTarget)) { - 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, - }); + targets.Add(new EntryTarget(path, top, target, id, shown.Remove((target, id)))); + } + } + + return targets; + } + + private object? WithoutHidden(SchemaField field, object? value) + { + if (value is not JsonNode node) + { + return value; + } + + var visible = node.DeepClone(); + HiddenFields.Remove(schema, visible, field); + return visible; + } + // 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. + private async Task SaveOwnRowsAsync(Func add, Func? afterSave, 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 + { + await add(); await context.SaveChangesAsync(cancellationToken); + if (afterSave is not null) + { + await afterSave(); + } } finally { @@ -226,6 +428,63 @@ public async Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocum } } + // The oldest go first and the cap counts every version, so an entry with more published versions than the cap keeps + // those and no other. The one just written stays whatever its status. + private async Task PruneVersionsAsync(SchemaType type, Document row, CancellationToken cancellationToken) + { + var id = row.DocumentId; + var locale = row.Locale; + var number = row.DraftVersion; + var versions = context.Versions.Where(version => version.DocumentId == id && version.Locale == locale); + var excess = await versions.CountAsync(cancellationToken) - type.MaxVersions; + if (excess <= 0) + { + return; + } + + var prunable = await versions + .Where(version => version.StatusAfter != EntryStatus.Published && version.Number != number) + .Select(version => new { version.Id, version.Number }) + .ToListAsync(cancellationToken); + var oldest = prunable.OrderBy(version => version.Number).Take(excess).Select(version => version.Id).ToList(); + await context.Versions.Where(version => oldest.Contains(version.Id)).ExecuteDeleteAsync(cancellationToken); + } + + private async Task CopyRowAsync(string table, Guid id, string? locale, CancellationToken cancellationToken) + { + var draft = await RowAsync(table, id, ContentState.Draft, locale, tracked: false, cancellationToken); + await PublishedRows(table, id, locale).ExecuteDeleteAsync(cancellationToken); + if (draft is null) + { + return; + } + + Dictionary published = new(draft); + published.Remove(StorageNames.Id); + published[StorageNames.State] = ContentState.Published; + context.Rows(table).Add(published); + } + + private IQueryable> PublishedRows(string table, Guid id, string? locale) + { + var rows = context.Rows(table).Where(row => EF.Property(row, StorageNames.DocumentId) == id && EF.Property(row, StorageNames.State) == ContentState.Published); + return locale is null ? rows : rows.Where(row => EF.Property(row, StorageNames.Locale) == locale); + } + + private IQueryable ReferencesOf(SchemaType type, Document row, short state, bool includingShared) + { + var typeName = type.Name; + var id = row.DocumentId; + var locale = row.Locale; + return context.References.Where(reference => reference.SourceType == typeName + && reference.SourceDocumentId == id + && reference.State == state + && ((includingShared && reference.Locale == "") || reference.Locale == locale)); + } + + private Task DeleteReferencesAsync(SchemaType type, Document row, short state, bool includingShared, CancellationToken cancellationToken) => + ReferencesOf(type, row, state, includingShared).ExecuteDeleteAsync(cancellationToken); + private static HeldEntry Hold(EntityEntry entry) { var original = entry.State == EntityState.Modified ? entry.OriginalValues.Clone() : null; @@ -352,12 +611,7 @@ private async Task ReplaceReferencesAsync(SchemaType type, Document row, List reference.SourceType == typeName - && reference.SourceDocumentId == id - && reference.State == ContentState.Draft - && (reference.Locale == "" || reference.Locale == locale)) - .ExecuteDeleteAsync(cancellationToken); + await DeleteReferencesAsync(type, row, ContentState.Draft, includingShared: true, cancellationToken); foreach (var item in stored) { @@ -465,13 +719,14 @@ private void CollectInside(SchemaField field, JsonNode node, List<(string Target // 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) + // An empty value is what an unfinished draft holds, and is not a value to clash on, until it is published: the unique + // index of that state has room for one ''. + public async Task CheckUniqueAsync(SchemaType type, ContentDocument entry, Guid id, SiteId site, string locale, ValidationContext result, CancellationToken cancellationToken, bool publishing = false) { 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 "") + if (value is null || (value is "" && !publishing)) { continue; } @@ -482,6 +737,11 @@ public async Task CheckUniqueAsync(SchemaType type, ContentDocument entry, Guid 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 (value is "") + { + taken = Expression.AndAlso(taken, Expression.Equal(QueryExpressions.Column(row, StorageNames.State, typeof(short)), QueryExpressions.Bind(ContentState.Published))); + } + if (type.Scope == ContentScope.Site) { taken = Expression.AndAlso(taken, Expression.Equal(QueryExpressions.Column(row, StorageNames.SiteId, typeof(Guid)), QueryExpressions.Bind(site.Value))); @@ -501,3 +761,13 @@ public async Task CheckUniqueAsync(SchemaType type, ContentDocument entry, Guid } } } + +internal enum ReferenceTargetState +{ + Unavailable, + Unpublished, + Published, +} + +// One reference of an entry. Path is the stored field, Field the top-level field that holds it. +internal sealed record EntryTarget(string Path, string Field, string TargetType, Guid Id, bool IsShown); diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs index 17021dc31..9ac43521c 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs @@ -364,7 +364,8 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List?> ReadableTargetsAsync(QueryScope query, ReferenceOutput output, List references, CancellationToken cancellationToken) { var target = output.Stored.Field.Relation is { } relation ? schema.FindContentType(relation.Target) : null; @@ -380,7 +381,8 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List reference.Field == output.ApiPath).Select(reference => reference.TargetId).Distinct().ToList(); if (!hasRule) { - // Trashed targets stay references, so this looks past the query filter. - var site = query.Site!.Value.Value; - var inSite = await context.Documents.IgnoreQueryFilters().AsNoTracking() - .Where(document => ids.Contains(document.DocumentId) && document.SiteId == site) - .Select(document => document.DocumentId) - .ToListAsync(cancellationToken); + // In a draft a trashed target stays a reference, so this looks past the query filter. + var documents = isPublished + ? context.Documents.AsNoTracking().Where(document => document.PublishedVersion != null) + : context.Documents.IgnoreQueryFilters().AsNoTracking(); + documents = documents.Where(document => ids.Contains(document.DocumentId)); + if (inOneSite) + { + var site = query.Site!.Value.Value; + documents = documents.Where(document => document.SiteId == site); + } - return [.. inSite]; + return [.. await documents.Select(document => document.DocumentId).ToListAsync(cancellationToken)]; } var row = Expression.Parameter(typeof(QueryRow), "row"); @@ -411,7 +417,7 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List wanted, CancellationToken cancellationToken) { var ids = wanted.Select(item => item.Target).Distinct().ToList(); diff --git a/src/FluentCMS.Core/Content/Query/HiddenFields.cs b/src/FluentCMS.Core/Content/Query/HiddenFields.cs index 728e70ca2..bdbcff27e 100644 --- a/src/FluentCMS.Core/Content/Query/HiddenFields.cs +++ b/src/FluentCMS.Core/Content/Query/HiddenFields.cs @@ -10,7 +10,14 @@ internal static class HiddenFields { private const string Discriminator = "type"; - public static void Remove(SchemaModel schema, JsonNode? value, SchemaField field, bool dropUnknown = false) + public static void Remove(SchemaModel schema, JsonNode? value, SchemaField field, bool dropUnknown = false) => + Remove(schema, value, field, dropUnknown, static inner => inner.Hidden); + + // What is restored from a version: what the system principal may hold, or what no client may write. + public static void RemoveWhere(SchemaModel schema, JsonNode? value, SchemaField field, Func remove) => + Remove(schema, value, field, dropUnknown: true, remove); + + private static void Remove(SchemaModel schema, JsonNode? value, SchemaField field, bool dropUnknown, Func remove) { switch (field.FieldType) { @@ -19,7 +26,7 @@ public static void Remove(SchemaModel schema, JsonNode? value, SchemaField field { if (block[Discriminator] is JsonValue name && name.TryGetValue(out var blockName) && schema.FindBlock(blockName) is { } type) { - Remove(schema, block, type, dropUnknown); + Remove(schema, block, type, dropUnknown, remove); } else if (dropUnknown) { @@ -31,24 +38,24 @@ public static void Remove(SchemaModel schema, JsonNode? value, SchemaField field case FieldTypeNames.Component when schema.FindComponent((string)field.Settings[FieldSettings.Component]) is { } component: foreach (var item in value is JsonArray items ? items.OfType() : value is JsonObject single ? [single] : []) { - Remove(schema, item, component, dropUnknown); + Remove(schema, item, component, dropUnknown, remove); } break; } } - private static void Remove(SchemaModel schema, JsonObject entry, SchemaType type, bool dropUnknown) + private static void Remove(SchemaModel schema, JsonObject entry, SchemaType type, bool dropUnknown, Func remove) { foreach (var field in type.Fields) { - if (field.Hidden) + if (remove(field)) { entry.Remove(field.Name); } else if (entry[field.Name] is { } inner) { - Remove(schema, inner, field, dropUnknown); + Remove(schema, inner, field, dropUnknown, remove); } } diff --git a/src/FluentCMS.Core/Content/TypedContentService.cs b/src/FluentCMS.Core/Content/TypedContentService.cs index e65cae1bb..ff563eb82 100644 --- a/src/FluentCMS.Core/Content/TypedContentService.cs +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -1,6 +1,7 @@ using FluentCMS.Content; using FluentCMS.Core.Content.Query; using FluentCMS.Localization; +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Content; @@ -55,6 +56,30 @@ public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) public async Task RestoreAsync(Guid id, CancellationToken cancellationToken = default) => await ReadBackAsync(await documents.RestoreAsync(_type, id, cancellationToken)); + public async Task> PublishAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) + { + var published = await documents.PublishAsync(_type, id, locale, expectedVersion, cancellationToken); + return new PublishResult(await ReadBackAsync(published.Entry, locale), published.Warnings); + } + + public async Task UnpublishAsync(Guid id, string? locale = null, CancellationToken cancellationToken = default) => + await ReadBackAsync(await documents.UnpublishAsync(_type, id, locale, cancellationToken), locale); + + public async Task DiscardDraftAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) => + await ReadBackAsync(await documents.DiscardDraftAsync(_type, id, locale, expectedVersion, cancellationToken), locale); + + public Task> GetVersionsAsync(Guid id, string? locale = null, CancellationToken cancellationToken = default) => + documents.GetVersionsAsync(_type, id, locale, cancellationToken); + + public async Task GetVersionAsync(Guid id, int number, string? locale = null, CancellationToken cancellationToken = default) => + await documents.GetVersionAsync(_type, id, number, locale, cancellationToken) is { } entry ? Map(entry) : null; + + public async Task RestoreVersionAsync(Guid id, int number, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) => + await ReadBackAsync(await documents.RestoreVersionAsync(_type, id, number, locale, expectedVersion, cancellationToken), locale); + + public Task> DiffAsync(Guid id, int fromVersion, int toVersion, string? locale = null, CancellationToken cancellationToken = default) => + documents.DiffAsync(_type, id, fromVersion, toVersion, locale, cancellationToken); + public Task CountAsync(ContentFilter? filter = null, QueryStatus status = QueryStatus.Published, CancellationToken cancellationToken = default) => documents.CountAsync(_type, filter, status, cancellationToken); @@ -62,9 +87,9 @@ public Task CountAsync(ContentFilter? filter = null, QueryStatus status = // 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) + private async Task ReadBackAsync(ContentDocument written, string? locale = null) { - var options = new GetOptions { Status = QueryStatus.Draft, Fallback = LocaleFallback.None }; + var options = new GetOptions { Status = QueryStatus.Draft, Fallback = LocaleFallback.None, Locale = locale }; return Map(written.ContainsKey("status") && await documents.GetAsync(_type, (Guid)written["id"]!, options, everySystemField: true, CancellationToken.None) is { } entry ? entry : written); } diff --git a/src/FluentCMS.Core/Content/Validation/FieldValue.cs b/src/FluentCMS.Core/Content/Validation/FieldValue.cs index fc28792ff..6b9f58d23 100644 --- a/src/FluentCMS.Core/Content/Validation/FieldValue.cs +++ b/src/FluentCMS.Core/Content/Validation/FieldValue.cs @@ -1,6 +1,9 @@ using System.Collections; using System.Collections.Concurrent; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.ProseMirror; using FluentCMS.Media; using FluentCMS.Schema; @@ -18,6 +21,7 @@ internal static class FieldValue string text => string.IsNullOrWhiteSpace(text), Guid id => id == Guid.Empty, MediaRef media => media.IsEmpty, + RichText document => IsEmptyDocument(document), IEnumerable items => !items.GetEnumerator().MoveNext(), _ => IsEmptyRef(value), }; @@ -70,6 +74,41 @@ internal static class FieldValue return values; } + // What a draft holds for no rich text, and what an editor leaves behind: blocks with no text, and nothing a reader + // would see without any, such as an image or a rule. A document that cannot be read is for its own rule to refuse. + private static bool IsEmptyDocument(RichText document) + { + try + { + return HasNoContent(JsonNode.Parse(document.Json)); + } + catch (JsonException) + { + return false; + } + } + + private static bool HasNoContent(JsonNode? node) + { + if (node is not JsonObject item || item["type"] is not JsonValue kind || !kind.TryGetValue(out var type)) + { + return false; + } + + if (type == RichTextNodeTypes.Text) + { + return item["text"] is not JsonValue value || !value.TryGetValue(out var text) || string.IsNullOrWhiteSpace(text); + } + + if (type is not (RichTextNodeTypes.Doc or RichTextNodeTypes.Paragraph or RichTextNodeTypes.Heading or RichTextNodeTypes.BulletList + or RichTextNodeTypes.OrderedList or RichTextNodeTypes.ListItem or RichTextNodeTypes.Blockquote or RichTextNodeTypes.CodeBlock or RichTextNodeTypes.HardBreak)) + { + return false; + } + + return item["content"] is not JsonArray content || content.All(HasNoContent); + } + private static PropertyInfo[] ReadableProperties(Type type) => [.. type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(property => property.GetIndexParameters().Length == 0 && property.CanRead)]; diff --git a/src/FluentCMS.Core/Content/VersionDiff.cs b/src/FluentCMS.Core/Content/VersionDiff.cs new file mode 100644 index 000000000..97ab23ed5 --- /dev/null +++ b/src/FluentCMS.Core/Content/VersionDiff.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Publishing; + +namespace FluentCMS.Core.Content; + +// The structural diff of spec 4.5, over two sets of fields. A list has no ids, so items compare by position. An item of +// another kind (a block or a rich text node with another "type") is one change, not one per field. No value and null are the same. +internal static class VersionDiff +{ + private const string Discriminator = "type"; + + public static List Between(JsonObject before, JsonObject after) + { + List changes = []; + Compare("", before, after, changes); + return changes; + } + + private static void Compare(string path, JsonNode? before, JsonNode? after, List changes) + { + if (JsonNode.DeepEquals(before, after)) + { + return; + } + + switch (before, after) + { + case (null, _): + changes.Add(new VersionChange(path, VersionChangeKind.Added, null, after!.DeepClone())); + break; + case (_, null): + changes.Add(new VersionChange(path, VersionChangeKind.Removed, before.DeepClone(), null)); + break; + case (JsonObject left, JsonObject right) when JsonNode.DeepEquals(left[Discriminator], right[Discriminator]): + CompareObjects(path, left, right, changes); + break; + case (JsonArray left, JsonArray right): + for (var index = 0; index < Math.Max(left.Count, right.Count); index++) + { + Compare( + string.Create(CultureInfo.InvariantCulture, $"{path}[{index}]"), + index < left.Count ? left[index] : null, + index < right.Count ? right[index] : null, + changes); + } + + break; + default: + changes.Add(new VersionChange(path, VersionChangeKind.Changed, before.DeepClone(), after.DeepClone())); + break; + } + } + + private static void CompareObjects(string path, JsonObject before, JsonObject after, List changes) + { + var prefix = path.Length == 0 ? "" : path + "."; + foreach (var (key, value) in before) + { + Compare(prefix + key, value, after[key], changes); + } + + foreach (var (key, value) in after) + { + if (!before.ContainsKey(key)) + { + Compare(prefix + key, null, value, changes); + } + } + } +} diff --git a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs index b7dade83c..19d37a8e4 100644 --- a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs +++ b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs @@ -1,5 +1,6 @@ using System.Reflection; using FluentCMS.Media; +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -60,6 +61,21 @@ public void Read(Type type) { t.Localized(localized); t.Scope(scope); + if (type.GetCustomAttribute(inherit: false) is { } policy) + { + if (policy.MaxVersions < 1) + { + Report(SchemaErrorCodes.InvalidType, $"{type.Name} must keep at least one version"); + } + else + { + t.PublishPolicy(policy.UnpublishedReferences, policy.MaxVersions); + } + } + } + else if (type.GetCustomAttribute(inherit: false) is not null) + { + Report(SchemaErrorCodes.InvalidType, $"{kind.ToString().ToLowerInvariant()} {type.Name} is not published, so it cannot have a publish policy"); } foreach (var property in Properties(type)) diff --git a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs index d8c510f72..ea989d4ac 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -96,6 +97,8 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) string? name = null; var localized = false; var scope = ContentScope.Site; + var unpublishedReferences = UnpublishedReferencePolicy.Warn; + var maxVersions = PublishPolicyAttribute.DefaultMaxVersions; JsonElement? fields = null; var failed = false; @@ -113,6 +116,19 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) case "scope": failed |= !TryScope(property.Value, kind, propertyPath, out scope); break; + case "unpublishedReferences" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton && property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() == "block": + unpublishedReferences = UnpublishedReferencePolicy.Block; + break; + case "unpublishedReferences" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton && property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() == "warn": + break; + case "maxVersions" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton && property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out var kept) && kept >= 1: + maxVersions = kept; + break; + case "unpublishedReferences": + case "maxVersions": + Fail(propertyPath, "is not a valid publishing setting for this type"); + failed = true; + break; case "fields": fields = property.Value; break; @@ -137,6 +153,11 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) { type.Localized(localized); type.Scope(scope); + if (kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton) + { + type.PublishPolicy(unpublishedReferences, maxVersions); + } + if (fields is { } list) { ReadFields(type, list, $"{path}.fields"); diff --git a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs index 18049cc04..d6b8c0b1f 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -55,6 +56,16 @@ private static void WriteTypes(Utf8JsonWriter writer, string group, IEnumerable< writer.WriteString("scope", "global"); } + if (type.UnpublishedReferences == UnpublishedReferencePolicy.Block) + { + writer.WriteString("unpublishedReferences", "block"); + } + + if (type.MaxVersions != PublishPolicyAttribute.DefaultMaxVersions) + { + writer.WriteNumber("maxVersions", type.MaxVersions); + } + writer.WriteStartArray("fields"); foreach (var field in type.Fields) { diff --git a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs index 33cce517c..7bba10afc 100644 --- a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs +++ b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs @@ -21,6 +21,8 @@ public static SchemaModel Create(IReadOnlyList drafts, IFieldTypeRegi Kind = draft.Kind, Localized = draft.Localized, Scope = draft.Scope, + UnpublishedReferences = draft.UnpublishedReferences, + MaxVersions = draft.MaxVersions, ClrType = draft.ClrType, Fields = draft.Fields.Select(Create).ToList().AsReadOnly(), ReverseRelations = (draft.IsContentType ? reverseRelations[draft.Name] : []).OrderBy(relation => relation.Name, StringComparer.Ordinal).ToList().AsReadOnly(), diff --git a/src/FluentCMS.Core/Schema/TypeBuilder.cs b/src/FluentCMS.Core/Schema/TypeBuilder.cs index a0790eb5d..b551219c4 100644 --- a/src/FluentCMS.Core/Schema/TypeBuilder.cs +++ b/src/FluentCMS.Core/Schema/TypeBuilder.cs @@ -1,3 +1,4 @@ +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -21,6 +22,20 @@ public ITypeBuilder Scope(ContentScope scope) return this; } + public ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions) + { + if (!draft.IsContentType) + { + throw new ArgumentException($"{draft.Kind.ToString().ToLowerInvariant()} '{draft.Name}' is not published; only collections and singletons are.", nameof(unpublishedReferences)); + } + + ArgumentOutOfRangeException.ThrowIfLessThan(maxVersions, 1); + + draft.UnpublishedReferences = unpublishedReferences; + draft.MaxVersions = maxVersions; + return this; + } + public ITypeBuilder Field(string name, string fieldType, Action? configure = null) { ArgumentException.ThrowIfNullOrEmpty(name); diff --git a/src/FluentCMS.Core/Schema/TypeDraft.cs b/src/FluentCMS.Core/Schema/TypeDraft.cs index 7f625d6c0..eaebd2cb4 100644 --- a/src/FluentCMS.Core/Schema/TypeDraft.cs +++ b/src/FluentCMS.Core/Schema/TypeDraft.cs @@ -1,3 +1,4 @@ +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -14,6 +15,10 @@ internal sealed class TypeDraft(string name, SchemaTypeKind kind, Type? clrType) public ContentScope Scope { get; set; } + public UnpublishedReferencePolicy UnpublishedReferences { get; set; } + + public int MaxVersions { get; set; } = PublishPolicyAttribute.DefaultMaxVersions; + public List Fields { get; } = []; public bool IsContentType => Kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton; diff --git a/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs b/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs index 3e7ee3d32..ddb398368 100644 --- a/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs +++ b/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs @@ -17,6 +17,7 @@ public sealed class ContentVersion // JSON text. public string Snapshot { get; set; } = ""; + // The one thing that changes: Published once this version has been published, which is what keeps it from pruning. public EntryStatus StatusAfter { get; set; } public Guid AuthorId { get; set; } diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs index 8561cf16b..ac2512089 100644 --- a/src/FluentCMS.Sdk/Content/IContentService.cs +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -1,3 +1,4 @@ +using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Content; @@ -83,6 +84,96 @@ public interface IContentService /// The entry is not in the trash, or the principal may not reach it. Task RestoreAsync(string type, Guid id, CancellationToken cancellationToken = default); + /// + /// Publishes the draft of one locale, under every rule a draft skips and with the references checked. Fields a localized + /// type shares go with it, so a grant that does not cover the other published locales may only publish when those fields have not changed. + /// + /// The name of the content type. + /// The id of the entry. + /// The locale, or for that of the operation. + /// The version that was reviewed, or for whatever the draft holds. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read, and the warnings. + /// The principal may not publish entries of the type in the locale, or the draft changes a field its grant does not cover. + /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + /// The stored version is not the expected one. + /// The draft is not valid to publish, or a reference is refused by the type's policy. + Task> PublishAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// Withdraws the published state of one locale. The draft stays. + /// The name of the content type. + /// The id of the entry. + /// The locale, or for that of the operation. + /// A token to cancel the work. + /// The entry with its system fields, without what the principal may not read. + /// The principal may not unpublish the whole entry in the locale. + /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + /// The entry is not published in the locale. + Task UnpublishAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default); + + /// Puts the published values back in the draft of one locale, as a new version. A field the principal may not write stays as the draft holds it, so the entry then stays modified. + /// The name of the content type. + /// The id of the entry. + /// The locale, or for that of the operation. + /// The version being discarded, or for whatever the draft holds. + /// 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 in the locale. + /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + /// The stored version is not the expected one. + /// The entry is not published in the locale, or a hook left a value that is not valid. + Task DiscardDraftAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// Lists the versions of an entry in one locale, newest first. They are for those who manage content: a Delivery principal, even with a preview key, may not read them. + /// The name of the content type. + /// The id of the entry. + /// The locale, or for that of the operation. + /// A token to cancel the work. + /// The versions, without their values. + /// The principal may not read versions of the type in the locale. + /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + Task> GetVersionsAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default); + + /// Reads the values of one version, without what the principal may not read today, whatever it could when the version was saved. + /// The name of the content type. + /// The id of the entry. + /// The number of the version. + /// The locale, or for that of the operation. + /// A token to cancel the work. + /// The version, or when there is no such version. + /// The principal may not read versions of the type in the locale. + /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + Task GetVersionAsync(string type, Guid id, int number, string? locale = null, CancellationToken cancellationToken = default); + + /// + /// Makes a version the draft, as a new version and under the draft rules. It is a write of the principal's: a field it may + /// not write or read keeps the value the draft holds, and so does a hidden or read-only one, except for the system principal. + /// + /// The name of the content type. + /// The id of the entry. + /// The number of the version. + /// The locale, or for that of the operation. + /// The version being replaced, or for whatever the draft holds. + /// 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 in the locale. + /// The entry or the version does not exist in the locale, or the principal may not reach it. + /// The stored version is not the expected one. + /// The values are not valid for a draft today. + Task RestoreVersionAsync(string type, Guid id, int number, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// Compares two versions field by field, with blocks and rich text compared as trees, over what the principal may read of them. + /// The name of the content type. + /// The id of the entry. + /// The number of the earlier version. + /// The number of the later version. + /// The locale, or for that of the operation. + /// A token to cancel the work. + /// The differences. + /// The principal may not read versions of the type in the locale. + /// The entry or a version does not exist in the locale, or the principal may not reach it. + Task> DiffAsync(string type, Guid id, int fromVersion, int toVersion, string? locale = null, 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. @@ -164,6 +255,27 @@ public interface IContentService /// The entry is not in the trash, or the principal may not reach it. Task RestoreAsync(Guid id, CancellationToken cancellationToken = default); + /// + Task> PublishAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// + Task UnpublishAsync(Guid id, string? locale = null, CancellationToken cancellationToken = default); + + /// + Task DiscardDraftAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// + Task> GetVersionsAsync(Guid id, string? locale = null, CancellationToken cancellationToken = default); + + /// + Task GetVersionAsync(Guid id, int number, string? locale = null, CancellationToken cancellationToken = default); + + /// + Task RestoreVersionAsync(Guid id, int number, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); + + /// + Task> DiffAsync(Guid id, int fromVersion, int toVersion, string? locale = null, 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. diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index 16ec49387..b10df9900 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -112,19 +112,33 @@ 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.DiffAsync(string! type, System.Guid id, int fromVersion, int toVersion, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.DiscardDraftAsync(string! type, System.Guid id, string? locale = null, int? expectedVersion = null, 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.GetVersionAsync(string! type, System.Guid id, int number, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.GetVersionsAsync(string! type, System.Guid id, string? locale = 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.PublishAsync(string! type, System.Guid id, string? locale = null, int? expectedVersion = null, 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.RestoreVersionAsync(string! type, System.Guid id, int number, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.UnpublishAsync(string! type, System.Guid id, string? locale = null, 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.DiffAsync(System.Guid id, int fromVersion, int toVersion, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.DiscardDraftAsync(System.Guid id, string? locale = null, int? expectedVersion = null, 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.GetVersionAsync(System.Guid id, int number, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.GetVersionsAsync(System.Guid id, string? locale = 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.PublishAsync(System.Guid id, string? locale = null, int? expectedVersion = null, 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.RestoreVersionAsync(System.Guid id, int number, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Content.IContentService.UnpublishAsync(System.Guid id, string? locale = null, 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 @@ -410,6 +424,78 @@ FluentCMS.Publishing.EntryStatus.Draft = 0 -> FluentCMS.Publishing.EntryStatus FluentCMS.Publishing.EntryStatus.Modified = 2 -> FluentCMS.Publishing.EntryStatus FluentCMS.Publishing.EntryStatus.Published = 1 -> FluentCMS.Publishing.EntryStatus FluentCMS.Publishing.EntryStatus.Unpublished = 3 -> FluentCMS.Publishing.EntryStatus +FluentCMS.Publishing.PublishPolicyAttribute +FluentCMS.Publishing.PublishPolicyAttribute.MaxVersions.get -> int +FluentCMS.Publishing.PublishPolicyAttribute.MaxVersions.set -> void +FluentCMS.Publishing.PublishPolicyAttribute.PublishPolicyAttribute() -> void +FluentCMS.Publishing.PublishPolicyAttribute.UnpublishedReferences.get -> FluentCMS.Publishing.UnpublishedReferencePolicy +FluentCMS.Publishing.PublishPolicyAttribute.UnpublishedReferences.set -> void +FluentCMS.Publishing.PublishResult +FluentCMS.Publishing.PublishResult.$() -> FluentCMS.Publishing.PublishResult! +FluentCMS.Publishing.PublishResult.Deconstruct(out T Entry, out System.Collections.Generic.IReadOnlyList! Warnings) -> void +FluentCMS.Publishing.PublishResult.Entry.get -> T +FluentCMS.Publishing.PublishResult.Entry.init -> void +FluentCMS.Publishing.PublishResult.Equals(FluentCMS.Publishing.PublishResult? other) -> bool +FluentCMS.Publishing.PublishResult.PublishResult(T Entry, System.Collections.Generic.IReadOnlyList! Warnings) -> void +FluentCMS.Publishing.PublishResult.Warnings.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Publishing.PublishResult.Warnings.init -> void +FluentCMS.Publishing.PublishWarning +FluentCMS.Publishing.PublishWarning.$() -> FluentCMS.Publishing.PublishWarning! +FluentCMS.Publishing.PublishWarning.Code.get -> string! +FluentCMS.Publishing.PublishWarning.Code.init -> void +FluentCMS.Publishing.PublishWarning.Deconstruct(out string! Path, out string! Code, out string! Message, out string? TargetType, out System.Guid? TargetId) -> void +FluentCMS.Publishing.PublishWarning.Equals(FluentCMS.Publishing.PublishWarning? other) -> bool +FluentCMS.Publishing.PublishWarning.Message.get -> string! +FluentCMS.Publishing.PublishWarning.Message.init -> void +FluentCMS.Publishing.PublishWarning.Path.get -> string! +FluentCMS.Publishing.PublishWarning.Path.init -> void +FluentCMS.Publishing.PublishWarning.PublishWarning(string! Path, string! Code, string! Message, string? TargetType = null, System.Guid? TargetId = null) -> void +FluentCMS.Publishing.PublishWarning.TargetId.get -> System.Guid? +FluentCMS.Publishing.PublishWarning.TargetId.init -> void +FluentCMS.Publishing.PublishWarning.TargetType.get -> string? +FluentCMS.Publishing.PublishWarning.TargetType.init -> void +FluentCMS.Publishing.PublishWarningCodes +FluentCMS.Publishing.UnpublishedReferencePolicy +FluentCMS.Publishing.UnpublishedReferencePolicy.Block = 1 -> FluentCMS.Publishing.UnpublishedReferencePolicy +FluentCMS.Publishing.UnpublishedReferencePolicy.Warn = 0 -> FluentCMS.Publishing.UnpublishedReferencePolicy +FluentCMS.Publishing.VersionChange +FluentCMS.Publishing.VersionChange.$() -> FluentCMS.Publishing.VersionChange! +FluentCMS.Publishing.VersionChange.After.get -> System.Text.Json.Nodes.JsonNode? +FluentCMS.Publishing.VersionChange.After.init -> void +FluentCMS.Publishing.VersionChange.Before.get -> System.Text.Json.Nodes.JsonNode? +FluentCMS.Publishing.VersionChange.Before.init -> void +FluentCMS.Publishing.VersionChange.Deconstruct(out string! Path, out FluentCMS.Publishing.VersionChangeKind Kind, out System.Text.Json.Nodes.JsonNode? Before, out System.Text.Json.Nodes.JsonNode? After) -> void +FluentCMS.Publishing.VersionChange.Equals(FluentCMS.Publishing.VersionChange? other) -> bool +FluentCMS.Publishing.VersionChange.Kind.get -> FluentCMS.Publishing.VersionChangeKind +FluentCMS.Publishing.VersionChange.Kind.init -> void +FluentCMS.Publishing.VersionChange.Path.get -> string! +FluentCMS.Publishing.VersionChange.Path.init -> void +FluentCMS.Publishing.VersionChange.VersionChange(string! Path, FluentCMS.Publishing.VersionChangeKind Kind, System.Text.Json.Nodes.JsonNode? Before, System.Text.Json.Nodes.JsonNode? After) -> void +FluentCMS.Publishing.VersionChangeKind +FluentCMS.Publishing.VersionChangeKind.Added = 0 -> FluentCMS.Publishing.VersionChangeKind +FluentCMS.Publishing.VersionChangeKind.Changed = 2 -> FluentCMS.Publishing.VersionChangeKind +FluentCMS.Publishing.VersionChangeKind.Removed = 1 -> FluentCMS.Publishing.VersionChangeKind +FluentCMS.Publishing.VersionInfo +FluentCMS.Publishing.VersionInfo.$() -> FluentCMS.Publishing.VersionInfo! +FluentCMS.Publishing.VersionInfo.AuthorId.get -> System.Guid +FluentCMS.Publishing.VersionInfo.AuthorId.init -> void +FluentCMS.Publishing.VersionInfo.ClientId.get -> string? +FluentCMS.Publishing.VersionInfo.ClientId.init -> void +FluentCMS.Publishing.VersionInfo.CreatedAt.get -> System.DateTimeOffset +FluentCMS.Publishing.VersionInfo.CreatedAt.init -> void +FluentCMS.Publishing.VersionInfo.Deconstruct(out int Number, out string! Locale, out FluentCMS.Publishing.EntryStatus Status, out System.Guid AuthorId, out FluentCMS.Content.Origin Origin, out string? ClientId, out string? Message, out System.DateTimeOffset CreatedAt) -> void +FluentCMS.Publishing.VersionInfo.Equals(FluentCMS.Publishing.VersionInfo? other) -> bool +FluentCMS.Publishing.VersionInfo.Locale.get -> string! +FluentCMS.Publishing.VersionInfo.Locale.init -> void +FluentCMS.Publishing.VersionInfo.Message.get -> string? +FluentCMS.Publishing.VersionInfo.Message.init -> void +FluentCMS.Publishing.VersionInfo.Number.get -> int +FluentCMS.Publishing.VersionInfo.Number.init -> void +FluentCMS.Publishing.VersionInfo.Origin.get -> FluentCMS.Content.Origin +FluentCMS.Publishing.VersionInfo.Origin.init -> void +FluentCMS.Publishing.VersionInfo.Status.get -> FluentCMS.Publishing.EntryStatus +FluentCMS.Publishing.VersionInfo.Status.init -> void +FluentCMS.Publishing.VersionInfo.VersionInfo(int Number, string! Locale, FluentCMS.Publishing.EntryStatus Status, System.Guid AuthorId, FluentCMS.Content.Origin Origin, string? ClientId, string? Message, System.DateTimeOffset CreatedAt) -> void FluentCMS.Schema.BlockAttribute FluentCMS.Schema.BlockAttribute.BlockAttribute(string? name = null) -> void FluentCMS.Schema.BlockAttribute.Name.get -> string? @@ -607,6 +693,7 @@ FluentCMS.Schema.ISchemaBuilder.LoadSchemaFile(string! path) -> FluentCMS.Schema FluentCMS.Schema.ITypeBuilder FluentCMS.Schema.ITypeBuilder.Field(string! name, string! fieldType, System.Action? configure = null) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.ITypeBuilder.Localized(bool localized = true) -> FluentCMS.Schema.ITypeBuilder! +FluentCMS.Schema.ITypeBuilder.PublishPolicy(FluentCMS.Publishing.UnpublishedReferencePolicy unpublishedReferences = FluentCMS.Publishing.UnpublishedReferencePolicy.Warn, int maxVersions = 50) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.ITypeBuilder.Scope(FluentCMS.Schema.ContentScope scope) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.IndexedAttribute FluentCMS.Schema.IndexedAttribute.IndexedAttribute() -> void @@ -716,9 +803,11 @@ FluentCMS.Schema.SchemaType.Fields.get -> System.Collections.Generic.IReadOnlyLi FluentCMS.Schema.SchemaType.FindField(string! name) -> FluentCMS.Schema.SchemaField? FluentCMS.Schema.SchemaType.Kind.get -> FluentCMS.Schema.SchemaTypeKind FluentCMS.Schema.SchemaType.Localized.get -> bool +FluentCMS.Schema.SchemaType.MaxVersions.get -> int FluentCMS.Schema.SchemaType.Name.get -> string! FluentCMS.Schema.SchemaType.ReverseRelations.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Schema.SchemaType.Scope.get -> FluentCMS.Schema.ContentScope +FluentCMS.Schema.SchemaType.UnpublishedReferences.get -> FluentCMS.Publishing.UnpublishedReferencePolicy FluentCMS.Schema.SchemaTypeKind FluentCMS.Schema.SchemaTypeKind.Block = 4 -> FluentCMS.Schema.SchemaTypeKind FluentCMS.Schema.SchemaTypeKind.Collection = 1 -> FluentCMS.Schema.SchemaTypeKind @@ -789,6 +878,9 @@ const FluentCMS.Content.ValidationErrorCodes.PatternMismatch = "FCMS2106" -> str const FluentCMS.Content.ValidationErrorCodes.Required = "FCMS2101" -> string! const FluentCMS.Content.ValidationErrorCodes.RequiredByCondition = "FCMS2110" -> string! const FluentCMS.Localization.ILocaleResolver.All = "all" -> string! +const FluentCMS.Publishing.PublishPolicyAttribute.DefaultMaxVersions = 50 -> int +const FluentCMS.Publishing.PublishWarningCodes.UnavailableReference = "FCMS2404" -> string! +const FluentCMS.Publishing.PublishWarningCodes.UnpublishedReference = "FCMS2403" -> string! const FluentCMS.Schema.FieldSettings.Blocks = "blocks" -> string! const FluentCMS.Schema.FieldSettings.Component = "component" -> string! const FluentCMS.Schema.FieldSettings.Options = "options" -> string! @@ -855,6 +947,18 @@ override FluentCMS.Media.MediaRef.ToString() -> string! override FluentCMS.Media.MediaRefList.Equals(object? obj) -> bool override FluentCMS.Media.MediaRefList.GetHashCode() -> int override FluentCMS.Media.MediaRefList.ToString() -> string! +override FluentCMS.Publishing.PublishResult.Equals(object? obj) -> bool +override FluentCMS.Publishing.PublishResult.GetHashCode() -> int +override FluentCMS.Publishing.PublishResult.ToString() -> string! +override FluentCMS.Publishing.PublishWarning.Equals(object? obj) -> bool +override FluentCMS.Publishing.PublishWarning.GetHashCode() -> int +override FluentCMS.Publishing.PublishWarning.ToString() -> string! +override FluentCMS.Publishing.VersionChange.Equals(object? obj) -> bool +override FluentCMS.Publishing.VersionChange.GetHashCode() -> int +override FluentCMS.Publishing.VersionChange.ToString() -> string! +override FluentCMS.Publishing.VersionInfo.Equals(object? obj) -> bool +override FluentCMS.Publishing.VersionInfo.GetHashCode() -> int +override FluentCMS.Publishing.VersionInfo.ToString() -> string! override FluentCMS.Schema.BlockList.Equals(object? obj) -> bool override FluentCMS.Schema.BlockList.GetHashCode() -> int override FluentCMS.Schema.BlockList.ToString() -> string! @@ -921,6 +1025,14 @@ static FluentCMS.Media.MediaRef.operator ==(FluentCMS.Media.MediaRef left, Fluen static FluentCMS.Media.MediaRefList.Create(System.ReadOnlySpan items) -> FluentCMS.Media.MediaRefList! static FluentCMS.Media.MediaRefList.operator !=(FluentCMS.Media.MediaRefList? left, FluentCMS.Media.MediaRefList? right) -> bool static FluentCMS.Media.MediaRefList.operator ==(FluentCMS.Media.MediaRefList? left, FluentCMS.Media.MediaRefList? right) -> bool +static FluentCMS.Publishing.PublishResult.operator !=(FluentCMS.Publishing.PublishResult? left, FluentCMS.Publishing.PublishResult? right) -> bool +static FluentCMS.Publishing.PublishResult.operator ==(FluentCMS.Publishing.PublishResult? left, FluentCMS.Publishing.PublishResult? right) -> bool +static FluentCMS.Publishing.PublishWarning.operator !=(FluentCMS.Publishing.PublishWarning? left, FluentCMS.Publishing.PublishWarning? right) -> bool +static FluentCMS.Publishing.PublishWarning.operator ==(FluentCMS.Publishing.PublishWarning? left, FluentCMS.Publishing.PublishWarning? right) -> bool +static FluentCMS.Publishing.VersionChange.operator !=(FluentCMS.Publishing.VersionChange? left, FluentCMS.Publishing.VersionChange? right) -> bool +static FluentCMS.Publishing.VersionChange.operator ==(FluentCMS.Publishing.VersionChange? left, FluentCMS.Publishing.VersionChange? right) -> bool +static FluentCMS.Publishing.VersionInfo.operator !=(FluentCMS.Publishing.VersionInfo? left, FluentCMS.Publishing.VersionInfo? right) -> bool +static FluentCMS.Publishing.VersionInfo.operator ==(FluentCMS.Publishing.VersionInfo? left, FluentCMS.Publishing.VersionInfo? right) -> bool static FluentCMS.Schema.BlockList.Create(System.ReadOnlySpan items) -> FluentCMS.Schema.BlockList! static FluentCMS.Schema.BlockList.operator !=(FluentCMS.Schema.BlockList? left, FluentCMS.Schema.BlockList? right) -> bool static FluentCMS.Schema.BlockList.operator ==(FluentCMS.Schema.BlockList? left, FluentCMS.Schema.BlockList? right) -> bool diff --git a/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs b/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs new file mode 100644 index 000000000..28a8da09a --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs @@ -0,0 +1,15 @@ +namespace FluentCMS.Publishing; + +/// Sets how entries of a content type are published and how many of their versions are kept. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class PublishPolicyAttribute : Attribute +{ + /// The number of versions kept per entry and locale when a type does not say. + public const int DefaultMaxVersions = 50; + + /// What publishing does about a reference to an entry that is not published or is in the trash. + public UnpublishedReferencePolicy UnpublishedReferences { get; set; } + + /// How many versions to keep per entry and locale. The oldest go first, and a version that was published is never pruned. + public int MaxVersions { get; set; } = DefaultMaxVersions; +} diff --git a/src/FluentCMS.Sdk/Publishing/PublishResult.cs b/src/FluentCMS.Sdk/Publishing/PublishResult.cs new file mode 100644 index 000000000..04d2c6485 --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/PublishResult.cs @@ -0,0 +1,7 @@ +namespace FluentCMS.Publishing; + +/// What publishing an entry returns. +/// The type of an entry. +/// The entry with its system fields, without what the principal may not read. +/// What a reader of the entry will miss. It says nothing about a target of a type the principal may not read, or about a field it may not read. +public sealed record PublishResult(T Entry, IReadOnlyList Warnings); diff --git a/src/FluentCMS.Sdk/Publishing/PublishWarning.cs b/src/FluentCMS.Sdk/Publishing/PublishWarning.cs new file mode 100644 index 000000000..28d786b5a --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/PublishWarning.cs @@ -0,0 +1,9 @@ +namespace FluentCMS.Publishing; + +/// Something a reader of a published entry will miss. +/// The field that holds the reference. +/// One of . +/// A description for a person. +/// The type of the target, when the principal may know it. +/// The id of the target, when the principal may know it. +public sealed record PublishWarning(string Path, string Code, string Message, string? TargetType = null, Guid? TargetId = null); diff --git a/src/FluentCMS.Sdk/Publishing/PublishWarningCodes.cs b/src/FluentCMS.Sdk/Publishing/PublishWarningCodes.cs new file mode 100644 index 000000000..f580f843a --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/PublishWarningCodes.cs @@ -0,0 +1,11 @@ +namespace FluentCMS.Publishing; + +/// The codes of what publishing reports about references, as warnings or, under , as validation errors. +public static class PublishWarningCodes +{ + /// The target exists and the principal may read it, but it has no published row in the locale or its fallbacks. + public const string UnpublishedReference = "FCMS2403"; + + /// The target cannot be reached: it does not exist, is in the trash or in another site, or the principal may not read it. These are not told apart. + public const string UnavailableReference = "FCMS2404"; +} diff --git a/src/FluentCMS.Sdk/Publishing/UnpublishedReferencePolicy.cs b/src/FluentCMS.Sdk/Publishing/UnpublishedReferencePolicy.cs new file mode 100644 index 000000000..bb19e7895 --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/UnpublishedReferencePolicy.cs @@ -0,0 +1,11 @@ +namespace FluentCMS.Publishing; + +/// What publishing an entry does about what it refers to that readers cannot reach. +public enum UnpublishedReferencePolicy +{ + /// The entry is published, and the response says so in its warnings. + Warn = 0, + + /// The entry is not published: a validation error. + Block = 1, +} diff --git a/src/FluentCMS.Sdk/Publishing/VersionChange.cs b/src/FluentCMS.Sdk/Publishing/VersionChange.cs new file mode 100644 index 000000000..1cdd2979b --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/VersionChange.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Publishing; + +/// One difference between two versions of an entry. +/// Where the value is, such as sections[1].heading or body.content[0].content[0].text. +/// How it differs. +/// The value in the earlier version. +/// The value in the later version. +public sealed record VersionChange(string Path, VersionChangeKind Kind, JsonNode? Before, JsonNode? After); diff --git a/src/FluentCMS.Sdk/Publishing/VersionChangeKind.cs b/src/FluentCMS.Sdk/Publishing/VersionChangeKind.cs new file mode 100644 index 000000000..5eb7ff8cd --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/VersionChangeKind.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Publishing; + +/// How a value differs between two versions. +public enum VersionChangeKind +{ + /// The later version has a value where the earlier one had none. + Added = 0, + + /// The earlier version had a value where the later one has none. + Removed = 1, + + /// The value is different. + Changed = 2, +} diff --git a/src/FluentCMS.Sdk/Publishing/VersionInfo.cs b/src/FluentCMS.Sdk/Publishing/VersionInfo.cs new file mode 100644 index 000000000..afca0e567 --- /dev/null +++ b/src/FluentCMS.Sdk/Publishing/VersionInfo.cs @@ -0,0 +1,14 @@ +using FluentCMS.Content; + +namespace FluentCMS.Publishing; + +/// One save of an entry in one locale. The values are read with . +/// The number of the version, which is the draft version it was saved as. +/// The locale of the version; empty for a type that is not localized. +/// The status the save left the entry in, or once this version has been published. +/// The id of who the save was made for. +/// Where the save came from. +/// The client that made the save, if there was one. +/// A note on the save, if there is one. +/// When the save was made. +public sealed record VersionInfo(int Number, string Locale, EntryStatus Status, Guid AuthorId, Origin Origin, string? ClientId, string? Message, DateTimeOffset CreatedAt); diff --git a/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs b/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs index 41b928fcc..e9606f9c5 100644 --- a/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs +++ b/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs @@ -1,3 +1,5 @@ +using FluentCMS.Publishing; + namespace FluentCMS.Schema; /// Declares the fields of a type defined without a class. @@ -14,6 +16,14 @@ public interface ITypeBuilder /// The type is a component or a block and is not . ITypeBuilder Scope(ContentScope scope); + /// Sets how entries are published and how many versions are kept. Only collections and singletons are published. + /// What publishing does about a reference to an entry that is not published or is in the trash. + /// How many versions to keep per entry and locale. A version that was published is never pruned. + /// This builder. + /// The type is a component or a block. + /// is less than 1. + ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions); + /// Declares a field. /// The field name, in camelCase. /// The name of the field type, such as text or richText. diff --git a/src/FluentCMS.Sdk/Schema/SchemaType.cs b/src/FluentCMS.Sdk/Schema/SchemaType.cs index 60a94dbc8..d0a121850 100644 --- a/src/FluentCMS.Sdk/Schema/SchemaType.cs +++ b/src/FluentCMS.Sdk/Schema/SchemaType.cs @@ -1,3 +1,5 @@ +using FluentCMS.Publishing; + namespace FluentCMS.Schema; /// A collection, singleton, component or block of a . @@ -19,6 +21,12 @@ internal SchemaType() /// Whether entries belong to one site or are shared by all sites. Always for components and blocks. public ContentScope Scope { get; internal init; } + /// What publishing an entry does about a reference to an entry that is not published or is in the trash. + public UnpublishedReferencePolicy UnpublishedReferences { get; internal init; } + + /// How many versions to keep per entry and locale. A version that was published is never pruned. + public int MaxVersions { get; internal init; } = PublishPolicyAttribute.DefaultMaxVersions; + /// The class the type was read from, or if it was defined without one. It does not take part in comparing models. public Type? ClrType { get; internal init; } diff --git a/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs b/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs new file mode 100644 index 000000000..57113d201 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs @@ -0,0 +1,67 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema; + +public sealed class PublishPolicyTests +{ + [Fact] + public void JsonRoundTrips() + { + var model = new SchemaBuilder().AddSchemaJson("""{ "collections": [ { "name": "article", "unpublishedReferences": "block", "maxVersions": 7 } ] }""").Build(); + + var type = model.FindContentType("article")!; + type.UnpublishedReferences.ShouldBe(UnpublishedReferencePolicy.Block); + type.MaxVersions.ShouldBe(7); + + var again = new SchemaBuilder().AddSchemaJson(SchemaJsonWriter.Write(model)).Build().FindContentType("article")!; + again.UnpublishedReferences.ShouldBe(UnpublishedReferencePolicy.Block); + again.MaxVersions.ShouldBe(7); + } + + [Fact] + public void TheDefaultsAreNotWrittenSoNoExistingModelChangesItsKey() + { + var json = SchemaJsonWriter.Write(new SchemaBuilder().AddCollection("article", type => type.PublishPolicy()).Build()); + + json.ShouldNotContain("unpublishedReferences"); + json.ShouldNotContain("maxVersions"); + } + + [Theory] + [InlineData("""{ "collections": [ { "name": "article", "unpublishedReferences": "ignore" } ] }""")] + [InlineData("""{ "collections": [ { "name": "article", "maxVersions": 0 } ] }""")] + [InlineData("""{ "components": [ { "name": "seo", "maxVersions": 5 } ] }""")] + [InlineData("""{ "blocks": [ { "name": "hero", "unpublishedReferences": "block" } ] }""")] + public void JsonRefusesWhatIsNotAPolicy(string json) => + Should.Throw(() => new SchemaBuilder().AddSchemaJson(json).Build()); + + [Fact] + public void TheBuilderRefusesAPolicyOnAComponentAndACapBelowOne() + { + Should.Throw(() => new SchemaBuilder().AddComponent("seo", type => type.PublishPolicy()).Build()); + Should.Throw(() => new SchemaBuilder().AddCollection("article", type => type.PublishPolicy(maxVersions: 0)).Build()); + } + + [FluentCMS.Schema.Collection("capless")] + [PublishPolicy(MaxVersions = 0)] + private sealed class Capless : ContentItem + { + } + + [Component("policed")] + [PublishPolicy] + private sealed class Policed + { + } + + [Fact] + public void AttributesReportWhatTheBuilderRefuses() + { + Should.Throw(() => new SchemaBuilder().AddContentType().Build()); + Should.Throw(() => new SchemaBuilder().AddContentType().Build()); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs new file mode 100644 index 000000000..b4a6c2352 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs @@ -0,0 +1,715 @@ +using System.Text.Json; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Authorization; +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; + +// Publishing and versions as a principal with little: what it may not read stays out of every answer, and what it may +// not write stays as it was, on every transition. +public sealed class PublishingAccessTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static string Json(object? value) => JsonSerializer.Serialize(value); + + private Principal Limited(params (string Permission, PermissionConstraints? Constraints)[] grants) => + WithRole(_database.Database.AddRoleForTest(Guid.NewGuid().ToString("N"), grants)); + + private static ContentDocument Secretive(string title, string secret, int views = 1, string heading = "Hi", string inner = "hidden-in-block") => new() + { + ["title"] = title, + ["secret"] = secret, + ["views"] = views, + ["seo"] = new ContentDocument { ["metaTitle"] = "Meta", ["internal"] = "hidden-in-group" }, + ["sections"] = new object[] { new ContentDocument { ["type"] = "hero", ["heading"] = heading, ["internal"] = inner } }, + }; + + private async Task SecretiveEntryAsync() + { + var id = IdOf(await _database.AsAsync(Principal.System, services => services.GetRequiredService().CreateAsync("article", Secretive("One", "s1"), Token), origin: Origin.Seed)); + await _database.AsAsync(Principal.System, services => services.GetRequiredService().UpdateAsync("article", id, Secretive("Two", "s2", 2, "Hi2", "hidden-2"), 1, Token), origin: Origin.Seed); + return id; + } + + [Fact] + public async Task HiddenValuesAreInTheSnapshotAndInNoVersionReadOrDiffForAnyone() + { + var id = await SecretiveEntryAsync(); + _database.Versions(id)[0].Snapshot.ShouldContain("hidden-in-block"); + + foreach (var principal in new[] { Editor, Principal.System }) + { + var origin = principal.IsSystem ? Origin.Seed : Origin.Api; + var version = await _database.AsAsync(principal, services => services.GetRequiredService().GetVersionAsync("article", id, 1, null, Token), origin: origin); + var changes = await _database.AsAsync(principal, services => services.GetRequiredService().DiffAsync("article", id, 1, 2, null, Token), origin: origin); + + version!["title"].ShouldBe("One"); + version.ContainsKey("secret").ShouldBeFalse(); + var all = Json(version) + Json(changes.Select(change => (change.Path, Before: change.Before?.ToJsonString(), After: change.After?.ToJsonString())).ToList()); + all.ShouldNotContain("hidden-in-block"); + all.ShouldNotContain("hidden-2"); + all.ShouldNotContain("hidden-in-group"); + all.ShouldNotContain("s1"); + changes.Select(change => change.Path).ShouldContain("sections[0].heading"); + changes.Select(change => change.Path).ShouldNotContain("secret"); + changes.Select(change => change.Path).ShouldNotContain("sections[0].internal"); + } + } + + [Fact] + public async Task AFieldItMayNotReadIsInNoVersionReadOrDiff() + { + var id = await SecretiveEntryAsync(); + var limited = Limited(("content.article.read", new PermissionConstraints { Fields = ["title"] })); + + var version = (await _database.VersionAsync(limited, id, 1))!; + version["title"].ShouldBe("One"); + version.ContainsKey("views").ShouldBeFalse(); + version.ContainsKey("sections").ShouldBeFalse(); + version.ContainsKey("seo").ShouldBeFalse(); + + (await _database.DiffAsync(limited, id, 1, 2)).ShouldHaveSingleItem().Path.ShouldBe("title"); + } + + [Fact] + public async Task AChangeOnlyInWhatItMayNotReadIsNoDifference() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 1); + var limited = Limited(("content.article.read", new PermissionConstraints { Fields = ["title"] })); + + (await _database.DiffAsync(limited, id, 1, 2)).ShouldBeEmpty(); + } + + [Fact] + public async Task ARelationTargetItMayNotReadIsNotNamedByAVersionOrADiff() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "net" }, "tag")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["author"] = author, ["tags"] = new[] { tag } })); + await _database.PatchAsync(Editor, id, new() { ["author"] = null, ["tags"] = Array.Empty() }, 1); + + var limited = Limited(("content.article.read", null), ("content.tag.read", null)); + var version = (await _database.VersionAsync(limited, id, 1))!; + version["author"].ShouldBeNull(); + var listed = version["tags"].ShouldBeOfType>().ShouldHaveSingleItem(); + listed["id"].ShouldBe(tag); + + var changes = await _database.DiffAsync(limited, id, 1, 2); + changes.ShouldHaveSingleItem().Path.ShouldBe("tags[0]"); + Json(changes[0].Before?.ToJsonString()).ShouldNotContain(author.ToString()); + } + + [Fact] + public async Task ATokenThatOnlyReadsCannotReadVersionsEvenWithAPreviewKey() + { + var id = await SecretiveEntryAsync(); + var role = _database.Database.AddRoleForTest("Reader", ("content.article.read", null)); + + foreach (var scopes in new[] { Array.Empty(), [TokenScopes.Delivery], [TokenScopes.Draft], [TokenScopes.Preview] }) + { + var token = AuthorizationDatabase.User([role], scopes); + await Should.ThrowAsync(() => _database.VersionsAsync(token, id)); + await Should.ThrowAsync(() => _database.VersionAsync(token, id, 1)); + await Should.ThrowAsync(() => _database.DiffAsync(token, id, 1, 2)); + } + + await Should.ThrowAsync(() => _database.VersionsAsync(Principal.Anonymous, id)); + } + + [Fact] + public async Task NoTransitionWorksForATokenWithoutTheManagementScope() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + var role = _database.Database.AddRoleForTest("Everything", ("content.article.*", null)); + var token = AuthorizationDatabase.User([role], [TokenScopes.Draft]); + + await Should.ThrowAsync(() => _database.PublishAsync(token, id)); + await Should.ThrowAsync(() => _database.UnpublishAsync(token, id)); + await Should.ThrowAsync(() => _database.DiscardAsync(token, id)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(token, id, 1)); + } + + [Fact] + public async Task EachTransitionNeedsItsOwnPermission() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + var before = _database.Trace(id); + + var updater = Limited(("content.article.update", null), ("content.article.read", null)); + await Should.ThrowAsync(() => _database.PublishAsync(updater, id)); + await Should.ThrowAsync(() => _database.UnpublishAsync(updater, id)); + + var publisher = Limited(("content.article.publish", null), ("content.article.unpublish", null), ("content.article.restore", null)); + await Should.ThrowAsync(() => _database.DiscardAsync(publisher, id)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(publisher, id, 1)); + await Should.ThrowAsync(() => _database.VersionsAsync(publisher, id)); + + _database.Trace(id).ShouldBe(before); + + (await _database.PublishAsync(publisher, id)).Entry.Keys.ShouldBe(["id", "version"]); + (await _database.UnpublishAsync(publisher, id)).Keys.ShouldBe(["id", "version"]); + } + + [Fact] + public async Task ARowRuleHoldsOnEveryTransitionAndReadsAsNoEntry() + { + var ann = Guid.CreateVersion7(); + var own = new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }; + var role = _database.Database.AddRoleForTest( + "Own", + ("content.article.create", null), + ("content.article.read", own), + ("content.article.update", own), + ("content.article.publish", own), + ("content.article.unpublish", own)); + var mine = WithRole(role, ann); + var id = IdOf(await _database.CreateAsync(Editor, Titled("Theirs"))); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, Titled("Theirs 2"), 1); + var before = _database.Trace(id); + + await Should.ThrowAsync(() => _database.PublishAsync(mine, id)); + await Should.ThrowAsync(() => _database.UnpublishAsync(mine, id)); + await Should.ThrowAsync(() => _database.DiscardAsync(mine, id)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(mine, id, 1)); + await Should.ThrowAsync(() => _database.VersionsAsync(mine, id)); + await Should.ThrowAsync(() => _database.VersionAsync(mine, id, 1)); + await Should.ThrowAsync(() => _database.DiffAsync(mine, id, 1, 2)); + _database.Trace(id).ShouldBe(before); + + var nowhere = Guid.CreateVersion7(); + await Should.ThrowAsync(() => _database.PublishAsync(mine, nowhere)); + + var own2 = IdOf(await _database.CreateAsync(mine, Titled("Mine"))); + await Should.NotThrowAsync(() => _database.PublishAsync(mine, own2)); + await Should.NotThrowAsync(() => _database.VersionsAsync(mine, own2)); + } + + [FluentCMS.Schema.Collection("article")] + private sealed class Regional : ContentItem + { + public string? Region { get; set; } + } + + [Fact] + public async Task ARestoreOrDiscardThatTakesTheEntryOutOfTheRowRuleIsRolledBack() + { + using var database = new ServiceDatabase(cms => cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region"))); + var inRegion = new PermissionConstraints { RowRule = "region" }; + var role = database.Database.AddRoleForTest("Regional", ("content.article.read", inRegion), ("content.article.update", inRegion)); + var eu = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "One", ["region"] = "us" })); + await database.PublishAsync(Editor, id); + await database.PatchAsync(Editor, id, new() { ["region"] = "eu" }, 1); + var before = database.Trace(id); + + await Should.ThrowAsync(() => database.RestoreVersionAsync(eu, id, 1)); + database.Trace(id).ShouldBe(before); + + await Should.ThrowAsync(() => database.DiscardAsync(eu, id)); + database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task ALocaleLimitHoldsOnEveryTransitionWhicheverWayTheLocaleIsNamed() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, Titled("Hello 2"), 1); + var before = _database.Trace(id); + var french = new PermissionConstraints { Locales = ["fr"] }; + var translator = Limited( + ("content.article.read", french), + ("content.article.update", french), + ("content.article.publish", french), + ("content.article.unpublish", french)); + + foreach (var (locale, operation) in new[] { ((string?)"en", "fr"), (null, "en") }) + { + await Should.ThrowAsync(() => _database.PublishAsync(translator, id, locale, operationLocale: operation)); + await Should.ThrowAsync(() => _database.UnpublishAsync(translator, id, locale, operationLocale: operation)); + await Should.ThrowAsync(() => _database.DiscardAsync(translator, id, locale, operationLocale: operation)); + } + + await Should.ThrowAsync(() => _database.RestoreVersionAsync(translator, id, 1, "en")); + await Should.ThrowAsync(() => _database.VersionsAsync(translator, id, "en")); + await Should.ThrowAsync(() => _database.VersionAsync(translator, id, 1, "en")); + await Should.ThrowAsync(() => _database.DiffAsync(translator, id, 1, 2, "en")); + await Should.ThrowAsync(() => _database.PublishAsync(translator, id, "all")); + + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AGrantLimitedToALocaleCannotPublishASharedChangeOverTheLocalesItDoesNotCover() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + var translator = Limited(("content.article.publish", new PermissionConstraints { Locales = ["fr"] })); + + // Nothing shared has moved, so its own locale is its to publish. + await _database.PublishAsync(translator, id, "fr"); + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + + await _database.PatchAsync(Editor, id, new() { ["views"] = 99 }, 1); + var before = _database.Trace(id); + + await Should.ThrowAsync(() => _database.PublishAsync(translator, id, "fr")); + _database.Trace(id).ShouldBe(before); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }))!["views"].ShouldBe(1L); + } + + [Fact] + public async Task AGrantLimitedToALocaleMayFirstPublishWhenNoOtherLocaleIsPublished() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + var translator = Limited(("content.article.publish", new PermissionConstraints { Locales = ["fr"] })); + + await _database.PublishAsync(translator, id, "fr"); + + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + _database.Document(id).Status.ShouldBe(EntryStatus.Draft); + } + + [Fact] + public async Task AGrantLimitedToSomeFieldsPublishesOnlyWhatStaysWithinThem() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 1 })); + await _database.PublishAsync(Editor, id); + var titles = Limited(("content.article.publish", new PermissionConstraints { Fields = ["title"] })); + + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + await _database.PublishAsync(titles, id); + _database.Document(id).PublishedVersion.ShouldBe(2); + + await _database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 2); + var before = _database.Trace(id); + await Should.ThrowAsync(() => _database.PublishAsync(titles, id)); + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AGrantLimitedToSomeFieldsCannotWithdrawTheWholeEntry() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + await _database.PublishAsync(Editor, id); + var before = _database.Trace(id); + var titles = Limited(("content.article.unpublish", new PermissionConstraints { Fields = ["title"] })); + + await Should.ThrowAsync(() => _database.UnpublishAsync(titles, id)); + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task DiscardChangesOnlyWhatThePrincipalMayWrite() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 1, ["region"] = "eu" })); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Two", ["views"] = 2, ["region"] = "us" }, 1); + var titles = Limited(("content.article.read", null), ("content.article.update", new PermissionConstraints { Fields = ["title"] })); + + var entry = await _database.DiscardAsync(titles, id); + + entry["title"].ShouldBe("One"); + entry["views"].ShouldBe(2L); + entry["status"].ShouldBe(EntryStatus.Modified); + _database.Row("cms_article", id)["region"].ShouldBe("us"); + _database.Document(id).Status.ShouldBe(EntryStatus.Modified); + } + + [Fact] + public async Task DiscardByAGrantLimitedToALocaleLeavesTheSharedFields() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + await _database.PatchAsync(Editor, id, new() { ["views"] = 2, ["title"] = "Salut" }, 1, locale: "fr"); + var french = new PermissionConstraints { Locales = ["fr"] }; + var translator = Limited(("content.article.read", french), ("content.article.update", french)); + + var entry = await _database.DiscardAsync(translator, id, "fr", operationLocale: "fr"); + + entry["title"].ShouldBe("Bonjour"); + entry["views"].ShouldBe(2L); + _database.Row("cms_article", id)["views"].ShouldBe(2L); + } + + [Fact] + public async Task RestoreIsAWriteOfThePrincipalsAndAnOldVersionIsNoWayPastItsLimits() + { + var id = await SecretiveEntryAsync(); + var limited = Limited(("content.article.read", new PermissionConstraints { Fields = ["title", "sections"] }), ("content.article.update", new PermissionConstraints { Fields = ["title", "sections"] })); + + var entry = await _database.RestoreVersionAsync(limited, id, 1); + + entry["title"].ShouldBe("One"); + entry.ContainsKey("views").ShouldBeFalse(); + entry.ContainsKey("secret").ShouldBeFalse(); + Json(entry).ShouldNotContain("hidden"); + + // Its own fields came back; views, the hidden field and the hidden value inside the block did not. + var row = _database.Row("cms_article", id); + row["views"].ShouldBe(2L); + row["secret"].ShouldBe("s2"); + var sections = row["sections"].ShouldBeOfType(); + sections.ShouldContain("\"heading\":\"Hi\""); + sections.ShouldContain("hidden-2"); + sections.ShouldNotContain("hidden-in-block"); + } + + [Fact] + public async Task AHiddenValueInAGroupStaysOnARestore() + { + var system = Principal.System; + var id = IdOf(await _database.AsAsync(system, services => services.GetRequiredService().CreateAsync("article", new() { ["title"] = "One", ["seo"] = new ContentDocument { ["metaTitle"] = "Old", ["internal"] = "first" } }, Token), origin: Origin.Seed)); + await _database.AsAsync(system, services => services.GetRequiredService().PatchAsync("article", id, new() { ["seo"] = new ContentDocument { ["metaTitle"] = "New", ["internal"] = "second" } }, 1, Token), origin: Origin.Seed); + + var entry = await _database.RestoreVersionAsync(Editor, id, 1); + + ((ContentDocument)entry["seo"]!)["metaTitle"].ShouldBe("Old"); + ((ContentDocument)entry["seo"]!).ContainsKey("internal").ShouldBeFalse(); + var snapshot = _database.Versions(id)[^1].Snapshot; + snapshot.ShouldContain("second"); + snapshot.ShouldNotContain("first"); + } + + [Fact] + public async Task ARestoreByAGrantLimitedToALocaleLeavesTheSharedFields() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Un" }, 0, locale: "fr"); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Deux", ["views"] = 2 }, 1, locale: "fr"); + var french = new PermissionConstraints { Locales = ["fr"] }; + var translator = Limited(("content.article.read", french), ("content.article.update", french)); + + var entry = await _database.RestoreVersionAsync(translator, id, 1, "fr"); + + entry.ShouldNotBeNull(); + _database.Row("cms_article", id)["views"].ShouldBe(2L); + _database.Row("cms_article_i18n", id, "fr")["title"].ShouldBe("Un"); + _database.Row("cms_article_i18n", id, "en")["title"].ShouldBe("Hello"); + } + + [Fact] + public async Task ARestoreOfASharedFieldMovesThePublishedOtherLocaleToModified() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Un" }, 0, locale: "fr"); + await _database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 1); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + + await _database.RestoreVersionAsync(Editor, id, 1); + + _database.Document(id).Status.ShouldBe(EntryStatus.Modified); + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Modified); + } + + [Fact] + public async Task TheSystemPrincipalRestoresTheHiddenValuesToo() + { + var id = await SecretiveEntryAsync(); + + await _database.AsAsync(Principal.System, services => services.GetRequiredService().RestoreVersionAsync("article", id, 1, null, null, Token), origin: Origin.Seed); + + var row = _database.Row("cms_article", id); + row["secret"].ShouldBe("s1"); + row["sections"].ShouldBeOfType().ShouldContain("hidden-in-block"); + } + + [Fact] + public async Task ReadOnlyValuesStayOnARestore() + { + var id = IdOf(await _database.AsAsync(Principal.System, services => services.GetRequiredService().CreateAsync("article", new() { ["title"] = "One", ["stamp"] = "first" }, Token), origin: Origin.Seed)); + await _database.AsAsync(Principal.System, services => services.GetRequiredService().PatchAsync("article", id, new() { ["stamp"] = "second" }, 1, Token), origin: Origin.Seed); + + await _database.RestoreVersionAsync(Editor, id, 1); + + _database.Row("cms_article", id)["stamp"].ShouldBe("second"); + } + + [Fact] + public async Task AWriterWhoCannotReadGetsOnlyTheIdAndVersionFromARestoreAndADiscard() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + var writer = Limited(("content.article.update", null)); + + (await _database.RestoreVersionAsync(writer, id, 1)).Keys.ShouldBe(["id", "version"]); + await _database.PatchAsync(Editor, id, Titled("Three"), 3); + (await _database.DiscardAsync(writer, id)).Keys.ShouldBe(["id", "version"]); + await Should.ThrowAsync(() => _database.VersionAsync(writer, id, 1)); + } + + [Fact] + public async Task ARestoreKeepsARelationTargetItMayNotReadAndDoesNotBringOneBack() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var other = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Bob" }, "author")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["author"] = author })); + await _database.PatchAsync(Editor, id, new() { ["author"] = other, ["title"] = "Two" }, 1); + var limited = Limited(("content.article.read", null), ("content.article.update", null)); + + await _database.RestoreVersionAsync(limited, id, 1); + + _database.References(id, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(other); + _database.Row("cms_article_i18n", id, "en")["title"].ShouldBe("One"); + } + + [Fact] + public async Task ThePublishErrorOfAFieldItMayNotReadNamesNeitherTheFieldNorTheValue() + { + var schema = SampleSchema.NewBuilder() + .AddCollection("page", type => type + .Field("name", "text", field => field.Required()) + .Field("sensitive", "text", field => field.Required()) + .Field("internal", "text", field => field.Hidden().Required())) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var id = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "One" }, "page")); + var blind = WithRole(database.Database.AddRoleForTest( + "Blind", + ("content.page.publish", null), + ("content.page.read", new PermissionConstraints { Fields = ["name"] }))); + + var error = (await Should.ThrowAsync(() => database.PublishAsync(blind, id, type: "page"))).Errors.ShouldHaveSingleItem(); + error.Path.ShouldBe(""); + error.Message.ShouldNotContain("sensitive"); + error.Message.ShouldNotContain("internal"); + + var errors = (await Should.ThrowAsync(() => database.PublishAsync(Editor, id, type: "page"))).Errors; + errors.Select(item => item.Path).ShouldBe(["sensitive", ""], ignoreOrder: true); + } + + private sealed class ThrowAt(HookKind kind) : IBeforePublishHook, IAfterPublishHook, IBeforeUnpublishHook, IAfterUnpublishHook, IAfterUpdateHook + { + public HookKind Kind { get; set; } = kind; + + public bool Armed { get; set; } + + private ValueTask At(HookKind kind) => + Armed && kind == Kind ? throw new InvalidOperationException("hook failed") : ValueTask.CompletedTask; + + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + if (Armed && Kind == HookKind.BeforePublish) + { + context.Cancel("no", "Refused."); + } + + return ValueTask.CompletedTask; + } + + public ValueTask AfterPublishAsync(HookContext context, CancellationToken cancellationToken) => At(HookKind.AfterPublish); + + public ValueTask BeforeUnpublishAsync(HookContext context, CancellationToken cancellationToken) + { + if (Armed && Kind == HookKind.BeforeUnpublish) + { + context.Cancel("no", "Refused."); + } + + return ValueTask.CompletedTask; + } + + public ValueTask AfterUnpublishAsync(HookContext context, CancellationToken cancellationToken) => At(HookKind.AfterUnpublish); + + public ValueTask AfterUpdateAsync(HookContext context, CancellationToken cancellationToken) => At(HookKind.AfterUpdate); + } + + private sealed class Sink : IEntryChangeSink + { + public List Changes { get; } = []; + + public ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) + { + Changes.Add(change); + return ValueTask.CompletedTask; + } + } + + [Theory] + [InlineData(HookKind.BeforePublish)] + [InlineData(HookKind.AfterPublish)] + [InlineData(HookKind.BeforeUnpublish)] + [InlineData(HookKind.AfterUnpublish)] + [InlineData(HookKind.AfterUpdate)] + public async Task AHookThatThrowsLeavesNothingBehindAndTellsNoOne(HookKind kind) + { + var hook = new ThrowAt(kind); + var sink = new Sink(); + using var database = new ServiceDatabase(cms => + { + cms.Services.AddSingleton>(hook); + cms.Services.AddSingleton>(hook); + cms.Services.AddSingleton>(hook); + cms.Services.AddSingleton>(hook); + cms.Services.AddSingleton>(hook); + cms.Services.AddSingleton(sink); + }); + + var author = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "One", ["author"] = author })); + await database.PublishAsync(Editor, id); + await database.PatchAsync(Editor, id, new() { ["title"] = "Two", ["author"] = null, ["views"] = 2 }, 1); + + var before = database.Trace(id); + sink.Changes.Clear(); + hook.Armed = true; + + Func[] calls = kind switch + { + HookKind.BeforePublish or HookKind.AfterPublish => [() => database.PublishAsync(Editor, id)], + HookKind.BeforeUnpublish or HookKind.AfterUnpublish => [() => database.UnpublishAsync(Editor, id)], + _ => [() => database.DiscardAsync(Editor, id), () => database.RestoreVersionAsync(Editor, id, 1)], + }; + + foreach (var call in calls) + { + await Should.ThrowAsync(call); + database.Trace(id).ShouldBe(before); + } + + sink.Changes.ShouldBeEmpty(); + + hook.Armed = false; + await calls[0](); + database.Trace(id).ShouldNotBe(before); + } + + private sealed class SetRegion : IBeforePublishHook + { + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["region"] = "from-hook"; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task APublishHookIsHeldToTheLimitsOfThePrincipal() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, SetRegion>()); + var id = IdOf(await database.CreateAsync(Editor, Titled("One"))); + var titles = WithRole(database.Database.AddRoleForTest("Titles", ("content.article.publish", new PermissionConstraints { Fields = ["title", "slug", "category"] }))); + var before = database.Trace(id); + + var error = (await Should.ThrowAsync(() => database.PublishAsync(titles, id))).Errors.ShouldHaveSingleItem(); + error.Code.ShouldBe(AuthorizationErrorCodes.FieldNotWritable); + database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AnAuditColumnNamesTheActorOfAJobAndAuthorizationGoesByThePrincipal() + { + var scheduler = new Principal(PrincipalKind.AdminUser, Guid.CreateVersion7(), "Scheduler"); + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + + await InJob(scheduler, service => service.PublishAsync("article", id, null, null, Token)); + _database.Document(id).UpdatedBy.ShouldBe(scheduler.Id); + + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + await InJob(scheduler, service => service.RestoreVersionAsync("article", id, 1, null, null, Token)); + _database.Document(id).UpdatedBy.ShouldBe(scheduler.Id); + _database.Versions(id)[^1].AuthorId.ShouldBe(scheduler.Id); + + await _database.PatchAsync(Editor, id, Titled("Three"), 3); + await InJob(scheduler, service => service.DiscardDraftAsync("article", id, null, null, Token)); + _database.Versions(id)[^1].AuthorId.ShouldBe(scheduler.Id); + + await InJob(scheduler, service => service.UnpublishAsync("article", id, null, Token)); + _database.Document(id).UpdatedBy.ShouldBe(scheduler.Id); + + // An actor with no grants takes nothing from the job, and a client's actor gives it nothing. + var nobody = Limited(); + await Should.ThrowAsync(() => _database.AsAsync(nobody, services => + { + var accessor = services.GetRequiredService(); + accessor.Current = accessor.Current! with { Actor = Editor }; + return services.GetRequiredService().PublishAsync("article", id, null, null, Token); + })); + } + + private async Task InJob(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 + { + await work(scope.ServiceProvider.GetRequiredService()); + } + finally + { + accessor.Current = null; + } + } + + [Fact] + public async Task AnEntryOfAnotherSiteOrTypeTakesNoTransitionAndAGlobalOneNeedsAnInstanceWideGrant() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + var before = _database.Trace(id); + await Should.ThrowAsync(() => _database.AsAsync(Editor, services => services.GetRequiredService().PublishAsync("article", id, null, null, Token), site: SiteB)); + await Should.ThrowAsync(() => _database.AsAsync(Editor, services => services.GetRequiredService().GetVersionsAsync("article", id, null, Token), site: SiteB)); + await Should.ThrowAsync(() => _database.PublishAsync(Editor, id, type: "author")); + _database.Trace(id).ShouldBe(before); + + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "net" }, "tag")); + var inA = WithRole(_database.Database.AddRoleForTest("Site A", SiteA.Value, ("content.tag.publish", null), ("content.tag.unpublish", null), ("content.tag.update", null))); + await Should.ThrowAsync(() => _database.PublishAsync(inA, tag, type: "tag")); + } + + [Fact] + public async Task APublishThatLostTheSwapChangesNothing() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + var before = _database.Trace(id); + + var stale = _database.Document(id); + stale.DraftVersion = 1; + await _database.AsAsync(Editor, async services => + { + (await services.GetRequiredService().MarkPublishedAsync(stale, DateTimeOffset.UtcNow, Editor.Id, Token)).ShouldBeFalse(); + return true; + }); + + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AStaleExpectedVersionTellsNothingThePrincipalMayNotRead() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + var limited = Limited(("content.article.read", new PermissionConstraints { Fields = ["title"] }), ("content.article.publish", null)); + + var conflict = await Should.ThrowAsync(() => _database.PublishAsync(limited, id, expected: 1)); + + conflict.Current!.ContainsKey("views").ShouldBeFalse(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingDatabase.cs b/tests/FluentCMS.Data.Tests/Content/PublishingDatabase.cs new file mode 100644 index 000000000..a8e9414b7 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/PublishingDatabase.cs @@ -0,0 +1,67 @@ +using FluentCMS.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Publishing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +// The publishing calls, each a request of its own like those of ServiceDatabase, and what they leave in the tables. +internal static class PublishingDatabase +{ + private static readonly string[] ArticleTables = ["cms_article", "cms_article_i18n"]; + + public static Task> PublishAsync(this ServiceDatabase database, Principal principal, Guid id, string? locale = null, int? expected = null, string type = "article", string operationLocale = "en") => + database.AsAsync(principal, services => services.GetRequiredService().PublishAsync(type, id, locale, expected, Token), operationLocale); + + public static Task UnpublishAsync(this ServiceDatabase database, Principal principal, Guid id, string? locale = null, string type = "article", string operationLocale = "en") => + database.AsAsync(principal, services => services.GetRequiredService().UnpublishAsync(type, id, locale, Token), operationLocale); + + public static Task DiscardAsync(this ServiceDatabase database, Principal principal, Guid id, string? locale = null, int? expected = null, string type = "article", string operationLocale = "en") => + database.AsAsync(principal, services => services.GetRequiredService().DiscardDraftAsync(type, id, locale, expected, Token), operationLocale); + + public static Task> VersionsAsync(this ServiceDatabase database, Principal principal, Guid id, string? locale = null, string type = "article") => + database.AsAsync(principal, services => services.GetRequiredService().GetVersionsAsync(type, id, locale, Token)); + + public static Task VersionAsync(this ServiceDatabase database, Principal principal, Guid id, int number, string? locale = null, string type = "article") => + database.AsAsync(principal, services => services.GetRequiredService().GetVersionAsync(type, id, number, locale, Token)); + + public static Task RestoreVersionAsync(this ServiceDatabase database, Principal principal, Guid id, int number, string? locale = null, int? expected = null, string type = "article") => + database.AsAsync(principal, services => services.GetRequiredService().RestoreVersionAsync(type, id, number, locale, expected, Token)); + + public static Task> DiffAsync(this ServiceDatabase database, Principal principal, Guid id, int from, int to, string? locale = null, string type = "article") => + database.AsAsync(principal, services => services.GetRequiredService().DiffAsync(type, id, from, to, locale, Token)); + + public static Document Document(this ServiceDatabase database, Guid id, string locale = "en") => + database.Read(context => context.Documents.IgnoreQueryFilters().AsNoTracking().Single(document => document.DocumentId == id && document.Locale == locale)); + + public static Dictionary? PublishedRow(this ServiceDatabase database, string table, Guid id, string? locale = null) => + database.Read(context => context.Rows(table).AsNoTracking().SingleOrDefault(row => + EF.Property(row, StorageNames.DocumentId) == id + && EF.Property(row, StorageNames.State) == ContentState.Published + && (locale == null || EF.Property(row, StorageNames.Locale) == locale))); + + public static List References(this ServiceDatabase database, Guid id, short state) => + database.References(id).Where(reference => reference.State == state).ToList(); + + // Everything one entry holds, so a test can say that a failed transition changed none of it. + public static string Trace(this ServiceDatabase database, Guid id) => database.Read(context => + { + var documents = context.Documents.IgnoreQueryFilters().AsNoTracking().Where(document => document.DocumentId == id).ToList() + .OrderBy(document => document.Locale) + .Select(document => $"{document.Locale}:{document.Status}:{document.DraftVersion}:{document.PublishedVersion}:{document.PublishedAt}:{document.UpdatedAt}:{document.UpdatedBy}"); + var versions = context.Versions.AsNoTracking().Where(version => version.DocumentId == id).ToList() + .OrderBy(version => version.Locale).ThenBy(version => version.Number) + .Select(version => $"{version.Locale}:{version.Number}:{version.StatusAfter}:{version.Snapshot}"); + var rows = ArticleTables.SelectMany(table => context.Rows(table).AsNoTracking() + .Where(row => EF.Property(row, StorageNames.DocumentId) == id).ToList() + .Select(row => string.Join(',', row.Where(column => column.Key != StorageNames.Id).OrderBy(column => column.Key, StringComparer.Ordinal).Select(column => $"{column.Key}={column.Value}"))) + .OrderBy(row => row, StringComparer.Ordinal)); + var references = context.References.AsNoTracking().Where(reference => reference.SourceDocumentId == id).ToList() + .Select(reference => $"{reference.State}:{reference.Locale}:{reference.Field}:{reference.Position}:{reference.TargetId}") + .OrderBy(row => row, StringComparer.Ordinal); + return string.Join('\n', documents.Concat(versions).Concat(rows).Concat(references)); + }); +} diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs b/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs new file mode 100644 index 000000000..82d014301 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs @@ -0,0 +1,616 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Authorization; +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 PublishingTransitionTests : IDisposable +{ + private static readonly string[] Labels = ["net"]; + private static readonly string[] Notes = ["note"]; + + private readonly Recorder _sink = new(); + private readonly ServiceDatabase _database; + + public PublishingTransitionTests() + { + _database = new ServiceDatabase(cms => cms.Services.AddSingleton(_sink)); + } + + public void Dispose() => _database.Dispose(); + + 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 async Task PublishedAsync(string title = "Hello") + { + var id = IdOf(await _database.CreateAsync(Editor, Titled(title))); + await _database.PublishAsync(Editor, id); + return id; + } + + [Fact] + public async Task PublishCopiesTheDraftRowsAndRecordsTheVersion() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + await _database.PublishAsync(Editor, author, type: "author"); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 5, ["author"] = author })); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published })).ShouldBeNull(); + + var result = await _database.PublishAsync(Editor, id); + + result.Warnings.ShouldBeEmpty(); + result.Entry["status"].ShouldBe(EntryStatus.Published); + result.Entry["version"].ShouldBe(1); + + var document = _database.Document(id); + document.Status.ShouldBe(EntryStatus.Published); + document.DraftVersion.ShouldBe(1); + document.PublishedVersion.ShouldBe(1); + document.PublishedAt.ShouldNotBeNull(); + document.FirstPublishedAt.ShouldBe(document.PublishedAt); + + _database.PublishedRow("cms_article", id)!["views"].ShouldBe(5L); + _database.PublishedRow("cms_article_i18n", id, "en")!["title"].ShouldBe("Hello"); + _database.Row("cms_article", id).ShouldNotBeNull(); + _database.References(id, ContentState.Published).ShouldHaveSingleItem().TargetId.ShouldBe(author); + _database.Versions(id).ShouldHaveSingleItem().StatusAfter.ShouldBe(EntryStatus.Published); + + var read = await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }); + read!["title"].ShouldBe("Hello"); + read["status"].ShouldBe(EntryStatus.Published); + + var change = _sink.Changes[^1]; + change.Kind.ShouldBe(EntryChangeKind.Published); + change.Id.ShouldBe(id); + change.Locale.ShouldBe("en"); + change.Version.ShouldBe(1); + } + + [Fact] + public async Task ASaveAfterPublishingLeavesThePublishedRowsAndTheNextPublishMovesThem() + { + var id = await PublishedAsync(); + var first = _database.Document(id).PublishedAt; + + await _database.PatchAsync(Editor, id, Titled("Changed"), 1); + _database.Document(id).Status.ShouldBe(EntryStatus.Modified); + _database.Document(id).PublishedVersion.ShouldBe(1); + _database.PublishedRow("cms_article_i18n", id, "en")!["title"].ShouldBe("Hello"); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }))!["title"].ShouldBe("Hello"); + + await _database.PublishAsync(Editor, id); + var document = _database.Document(id); + document.Status.ShouldBe(EntryStatus.Published); + document.PublishedVersion.ShouldBe(2); + document.FirstPublishedAt.ShouldBe(first); + _database.PublishedRow("cms_article_i18n", id, "en")!["title"].ShouldBe("Changed"); + } + + [Fact] + public async Task UnpublishRemovesThePublishedRowsAndKeepsTheDraft() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["author"] = author })); + await _database.PublishAsync(Editor, id); + var first = _database.Document(id).FirstPublishedAt; + _sink.Changes.Clear(); + + var entry = await _database.UnpublishAsync(Editor, id); + + entry["status"].ShouldBe(EntryStatus.Unpublished); + var document = _database.Document(id); + document.Status.ShouldBe(EntryStatus.Unpublished); + document.PublishedVersion.ShouldBeNull(); + document.PublishedAt.ShouldBeNull(); + document.FirstPublishedAt.ShouldBe(first); + document.DraftVersion.ShouldBe(1); + _database.PublishedRow("cms_article", id).ShouldBeNull(); + _database.PublishedRow("cms_article_i18n", id, "en").ShouldBeNull(); + _database.References(id, ContentState.Published).ShouldBeEmpty(); + _database.References(id, ContentState.Draft).ShouldHaveSingleItem(); + (await _database.GetAsync(Editor, id))!["title"].ShouldBe("Hello"); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published })).ShouldBeNull(); + + var change = _sink.Changes.ShouldHaveSingleItem(); + change.Kind.ShouldBe(EntryChangeKind.Unpublished); + change.Version.ShouldBe(1); + + await _database.PublishAsync(Editor, id); + _database.Document(id).Status.ShouldBe(EntryStatus.Published); + } + + [Fact] + public async Task ADraftThatWasNeverPublishedCannotBeUnpublishedOrDiscarded() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + + (await Should.ThrowAsync(() => _database.UnpublishAsync(Editor, id))).Errors.ShouldHaveSingleItem().Code.ShouldBe(ContentErrorCodes.NotPublished); + (await Should.ThrowAsync(() => _database.DiscardAsync(Editor, id))).Errors.ShouldHaveSingleItem().Code.ShouldBe(ContentErrorCodes.NotPublished); + } + + [Fact] + public async Task DiscardPutsThePublishedRowsBackAsANewVersion() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var other = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Bob" }, "author")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1, ["author"] = author })); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Changed", ["views"] = 2, ["author"] = other }, 1); + _sink.Changes.Clear(); + + var entry = await _database.DiscardAsync(Editor, id); + + entry["title"].ShouldBe("Hello"); + entry["version"].ShouldBe(3); + entry["status"].ShouldBe(EntryStatus.Published); + var document = _database.Document(id); + document.Status.ShouldBe(EntryStatus.Published); + document.DraftVersion.ShouldBe(3); + document.PublishedVersion.ShouldBe(1); + _database.Row("cms_article", id)["views"].ShouldBe(1L); + _database.References(id, ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(author); + + var version = _database.Versions(id)[^1]; + version.Number.ShouldBe(3); + version.Message.ShouldNotBeNullOrEmpty(); + var change = _sink.Changes.ShouldHaveSingleItem(); + change.Kind.ShouldBe(EntryChangeKind.DraftDiscarded); + change.Version.ShouldBe(3); + } + + [Fact] + public async Task DiscardingADraftThatEqualsThePublishedStateWritesNothing() + { + var id = await PublishedAsync(); + var before = _database.Trace(id); + _sink.Changes.Clear(); + + (await _database.DiscardAsync(Editor, id))["version"].ShouldBe(1); + + _database.Trace(id).ShouldBe(before); + _sink.Changes.ShouldBeEmpty(); + } + + [Fact] + public async Task RestoringAVersionMakesANewDraftAndLeavesThePublishedRows() + { + var id = await PublishedAsync("One"); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + await _database.PatchAsync(Editor, id, Titled("Three"), 2); + await _database.PublishAsync(Editor, id); + _sink.Changes.Clear(); + + var entry = await _database.RestoreVersionAsync(Editor, id, 1); + + entry["title"].ShouldBe("One"); + entry["version"].ShouldBe(4); + entry["status"].ShouldBe(EntryStatus.Modified); + _database.PublishedRow("cms_article_i18n", id, "en")!["title"].ShouldBe("Three"); + _database.Versions(id)[^1].Message.ShouldBe("Restored version 1."); + + var change = _sink.Changes.ShouldHaveSingleItem(); + change.Kind.ShouldBe(EntryChangeKind.VersionRestored); + change.Version.ShouldBe(4); + + await _database.RestoreVersionAsync(Editor, id, 3); + _database.Document(id).Status.ShouldBe(EntryStatus.Published); + } + + [Fact] + public async Task RestoringAVersionBringsBackEveryKindOfValue() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "net" }, "tag")); + var body = new RichText("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Body"}]}]}"""); + var at = new DateTimeOffset(2026, 11, 2, 10, 30, 0, TimeSpan.Zero); + var id = IdOf(await _database.CreateAsync(Editor, new() + { + ["title"] = "One", + ["body"] = body, + ["views"] = 7, + ["price"] = 1.5m, + ["publishedOn"] = at, + ["issued"] = new DateOnly(2026, 11, 2), + ["author"] = author, + ["tags"] = new[] { tag }, + ["labels"] = Labels, + ["seo"] = new ContentDocument { ["metaTitle"] = "Meta" }, + ["sections"] = new object[] { new ContentDocument { ["type"] = "hero", ["heading"] = "Hi", ["on"] = "2026-11-02" } }, + ["metadata"] = new System.Text.Json.Nodes.JsonObject { ["a"] = 1 }, + })); + var original = _database.Versions(id)[0].Snapshot; + + await _database.UpdateAsync(Editor, id, Titled("Two"), 1); + var restored = await _database.RestoreVersionAsync(Editor, id, 1); + + restored["version"].ShouldBe(3); + _database.Versions(id)[^1].Snapshot.ShouldBe(original); + _database.References(id, ContentState.Draft).Count.ShouldBe(2); + } + + [Fact] + public async Task AVersionOrTheEntryThatIsNotThereCannotBeRestored() + { + var id = await PublishedAsync(); + + await Should.ThrowAsync(() => _database.RestoreVersionAsync(Editor, id, 9)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(Editor, Guid.CreateVersion7(), 1)); + await Should.ThrowAsync(() => _database.DiffAsync(Editor, id, 1, 9)); + (await _database.VersionAsync(Editor, id, 9)).ShouldBeNull(); + } + + [Fact] + public async Task ATrashedEntryTakesNoTransition() + { + var id = await PublishedAsync(); + await _database.DeleteAsync(Editor, id); + + await Should.ThrowAsync(() => _database.PublishAsync(Editor, id)); + await Should.ThrowAsync(() => _database.UnpublishAsync(Editor, id)); + await Should.ThrowAsync(() => _database.DiscardAsync(Editor, id)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(Editor, id, 1)); + await Should.ThrowAsync(() => _database.VersionsAsync(Editor, id)); + + await _database.RestoreAsync(Editor, id); + _database.Document(id).Status.ShouldBe(EntryStatus.Published); + _database.PublishedRow("cms_article_i18n", id, "en").ShouldNotBeNull(); + } + + [Fact] + public async Task AStaleExpectedVersionStopsAPublishADiscardAndARestore() + { + var id = await PublishedAsync(); + await _database.PatchAsync(Editor, id, Titled("Changed"), 1); + var before = _database.Trace(id); + + (await Should.ThrowAsync(() => _database.PublishAsync(Editor, id, expected: 1))).CurrentVersion.ShouldBe(2); + await Should.ThrowAsync(() => _database.DiscardAsync(Editor, id, expected: 1)); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(Editor, id, 1, expected: 1)); + + _database.Trace(id).ShouldBe(before); + await Should.NotThrowAsync(() => _database.PublishAsync(Editor, id, expected: 2)); + } + + [Fact] + public async Task PublishingOneLocaleLeavesTheOtherAlone() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + + await _database.PublishAsync(Editor, id, "fr"); + + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + _database.Document(id).Status.ShouldBe(EntryStatus.Draft); + _database.PublishedRow("cms_article_i18n", id, "fr").ShouldNotBeNull(); + _database.PublishedRow("cms_article_i18n", id, "en").ShouldBeNull(); + _sink.Changes[^1].Locale.ShouldBe("fr"); + } + + [Fact] + public async Task TheLastLocaleTakesTheSharedRowWithItWhenItIsUnpublished() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["author"] = author })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + + await _database.UnpublishAsync(Editor, id); + _database.PublishedRow("cms_article", id).ShouldNotBeNull(); + _database.References(id, ContentState.Published).ShouldHaveSingleItem(); + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }, locale: "fr"))!["title"].ShouldBe("Bonjour"); + + await _database.UnpublishAsync(Editor, id, "fr"); + _database.PublishedRow("cms_article", id).ShouldBeNull(); + _database.References(id, ContentState.Published).ShouldBeEmpty(); + } + + [Fact] + public async Task PublishingASharedChangeResettlesTheOtherLocales() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + + await _database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 1); + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Modified); + + await _database.PublishAsync(Editor, id); + + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + (await _database.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }, locale: "fr"))!["views"].ShouldBe(2L); + } + + [Fact] + public async Task DiscardingASharedChangeResettlesTheOtherLocales() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["views"] = 1 })); + await _database.PatchAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + await _database.PatchAsync(Editor, id, new() { ["views"] = 2 }, 1); + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Modified); + + await _database.DiscardAsync(Editor, id); + + _database.Document(id, "fr").Status.ShouldBe(EntryStatus.Published); + _database.Row("cms_article", id)["views"].ShouldBe(1L); + } + + [Fact] + public async Task ASingletonAndAGlobalTypePublishToo() + { + var settings = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Site" }, "settings")); + (await _database.PublishAsync(Editor, settings, type: "settings")).Entry["status"].ShouldBe(EntryStatus.Published); + _database.PublishedRow("cms_settings", settings).ShouldNotBeNull(); + + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "net" }, "tag")); + (await _database.PublishAsync(Editor, tag, type: "tag")).Entry["status"].ShouldBe(EntryStatus.Published); + _database.PublishedRow("cms_tag", tag).ShouldNotBeNull(); + (await _database.UnpublishAsync(Editor, tag, type: "tag"))["status"].ShouldBe(EntryStatus.Unpublished); + } + + [Fact] + public async Task PublishRunsEveryRuleADraftSkips() + { + var schema = SampleSchema.NewBuilder() + .AddBlock("note", block => block.Field("text", "richText", field => field.Required())) + .AddCollection("page", type => type + .Field("name", "text", field => field.Required()) + .Field("body", "richText", field => field.Required()) + .Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, Notes)) + .Field("flag", "boolean") + .Field("reason", "text", field => field.RequiredWhen(Condition.Field("flag").Equals(true)))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var id = IdOf(await database.CreateAsync(Editor, new() { ["flag"] = true }, "page")); + + var errors = (await Should.ThrowAsync(() => database.PublishAsync(Editor, id, type: "page"))).Errors; + errors.Select(error => (error.Path, error.Code)).ShouldBe( + [("name", ValidationErrorCodes.Required), ("body", ValidationErrorCodes.Required), ("reason", ValidationErrorCodes.RequiredByCondition)], + ignoreOrder: true); + + database.Document(id, "").Status.ShouldBe(EntryStatus.Draft); + database.PublishedRow("cms_page", id).ShouldBeNull(); + } + + [Theory] + [InlineData("""{"type":"doc","content":[{"type":"paragraph"}]}""")] + [InlineData("""{"type":"doc","content":[]}""")] + [InlineData("""{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}""")] + [InlineData("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}""")] + public async Task AnEmptyDocumentIsMissingOnPublish(string json) + { + var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("body", "richText", field => field.Required())).Build(); + using var database = new ServiceDatabase(schema: schema); + var id = IdOf(await database.CreateAsync(Editor, new() { ["body"] = new RichText(json) }, "page")); + + (await Should.ThrowAsync(() => database.PublishAsync(Editor, id, type: "page"))).Errors.ShouldHaveSingleItem().Path.ShouldBe("body"); + } + + [Fact] + public async Task AnEmptyDocumentInsideABlockIsMissingAndADocumentWithAnImageIsNot() + { + var schema = SampleSchema.NewBuilder() + .AddBlock("note", block => block.Field("text", "richText", field => field.Required())) + .AddCollection("page", type => type.Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, Notes))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var empty = new ContentDocument { ["type"] = "note", ["text"] = RichText.Empty }; + var id = IdOf(await database.CreateAsync(Editor, new() { ["sections"] = new object[] { empty } }, "page")); + + (await Should.ThrowAsync(() => database.PublishAsync(Editor, id, type: "page"))).Errors.ShouldHaveSingleItem().Path.ShouldBe("sections[0].text"); + + var rule = new ContentDocument { ["type"] = "note", ["text"] = new RichText("""{"type":"doc","content":[{"type":"horizontalRule"}]}""") }; + await database.PatchAsync(Editor, id, new() { ["sections"] = new object[] { rule } }, 1, "page"); + (await database.PublishAsync(Editor, id, type: "page")).Entry["status"].ShouldBe(EntryStatus.Published); + } + + [Fact] + public async Task ATitleOfWhitespaceAndAnEmptyStringAreMissingOnPublish() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["views"] = 1 })); + _database.Row("cms_article_i18n", id, "en")["title"].ShouldBe(""); + (await Should.ThrowAsync(() => _database.PublishAsync(Editor, id))).Errors.ShouldHaveSingleItem().Code.ShouldBe(ValidationErrorCodes.Required); + + await _database.PatchAsync(Editor, id, Titled(" "), 1); + (await Should.ThrowAsync(() => _database.PublishAsync(Editor, id))).Errors.ShouldHaveSingleItem().Code.ShouldBe(ValidationErrorCodes.Required); + } + + [Fact] + public async Task TwoEntriesWithAnEmptySlugClashOnPublishAsAnErrorAndNotAtTheIndex() + { + var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("slug", "slug", field => field.Unique())).Build(); + using var database = new ServiceDatabase(schema: schema); + var first = IdOf(await database.CreateAsync(Editor, [], "page")); + var second = IdOf(await database.CreateAsync(Editor, [], "page")); + await database.PublishAsync(Editor, first, type: "page"); + + var error = (await Should.ThrowAsync(() => database.PublishAsync(Editor, second, type: "page"))).Errors.ShouldHaveSingleItem(); + error.Code.ShouldBe(ValidationErrorCodes.NotUnique); + } + + private sealed class Stamp : IBeforePublishHook + { + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["stamp"] = "from-hook"; + context.Entry["secret"] = "from-hook"; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task WhatABeforePublishHookChangesIsANewVersionAndIsWhatIsPublished() + { + using var database = new ServiceDatabase(cms => cms.Services.AddSingleton, Stamp>()); + var id = IdOf(await database.CreateAsync(Editor, Titled("Hello"))); + + var result = await database.PublishAsync(Editor, id); + + result.Entry["version"].ShouldBe(2); + result.Entry["stamp"].ShouldBe("from-hook"); + result.Entry.ContainsKey("secret").ShouldBeFalse(); + var document = database.Document(id); + document.DraftVersion.ShouldBe(2); + document.PublishedVersion.ShouldBe(2); + document.Status.ShouldBe(EntryStatus.Published); + database.PublishedRow("cms_article", id)!["secret"].ShouldBe("from-hook"); + database.Row("cms_article", id)["secret"].ShouldBe("from-hook"); + database.Versions(id).Select(version => version.StatusAfter).ShouldBe([EntryStatus.Draft, EntryStatus.Published]); + } + + [Fact] + public async Task PruningDropsTheOldestDraftsAndKeepsEveryVersionThatWasPublished() + { + var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.PublishPolicy(maxVersions: 3).Field("name", "text")).Build(); + using var database = new ServiceDatabase(schema: schema); + var id = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "v1" }, "page")); + await database.PublishAsync(Editor, id, type: "page"); + + for (var number = 2; number <= 6; number++) + { + await database.PatchAsync(Editor, id, new() { ["name"] = $"v{number}" }, number - 1, "page"); + if (number == 3) + { + await database.PublishAsync(Editor, id, type: "page"); + } + } + + database.Versions(id).Select(version => version.Number).ShouldBe([1, 3, 6]); + + await database.PublishAsync(Editor, id, type: "page"); + for (var number = 7; number <= 9; number++) + { + await database.PatchAsync(Editor, id, new() { ["name"] = $"v{number}" }, number - 1, "page"); + } + + database.Versions(id).Select(version => version.Number).ShouldBe([1, 3, 6, 9]); + (await database.RestoreVersionAsync(Editor, id, 1, type: "page"))["name"].ShouldBe("v1"); + await Should.ThrowAsync(() => database.RestoreVersionAsync(Editor, id, 2, type: "page")); + } + + [Fact] + public async Task TheDefaultCapIsFiftyPerLocale() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("v1"))); + await _database.PatchAsync(Editor, id, Titled("fr"), 0, locale: "fr"); + for (var number = 2; number <= 52; number++) + { + await _database.PatchAsync(Editor, id, Titled($"v{number}"), number - 1); + } + + var versions = _database.Versions(id); + versions.Count(version => version.Locale == "en").ShouldBe(50); + versions.Count(version => version.Locale == "fr").ShouldBe(1); + versions.Where(version => version.Locale == "en").Min(version => version.Number).ShouldBe(3); + } + + [Fact] + public async Task VersionsListNewestFirstWithWhoMadeThem() + { + var id = await PublishedAsync(); + await _database.PatchAsync(Editor, id, Titled("Changed"), 1); + + var versions = await _database.VersionsAsync(Editor, id); + + versions.Select(version => version.Number).ShouldBe([2, 1]); + versions[0].Status.ShouldBe(EntryStatus.Modified); + versions[1].Status.ShouldBe(EntryStatus.Published); + versions[0].AuthorId.ShouldBe(Editor.Id); + versions[0].Origin.ShouldBe(Origin.Api); + versions[0].ClientId.ShouldBe("client"); + versions[0].Locale.ShouldBe("en"); + } + + [Fact] + public async Task AVersionReadsLikeTheEntryItWas() + { + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 3, ["author"] = author, ["seo"] = new ContentDocument { ["metaTitle"] = "Meta" } })); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + + var version = (await _database.VersionAsync(Editor, id, 1))!; + + version["id"].ShouldBe(id); + version["version"].ShouldBe(1); + version["locale"].ShouldBe("en"); + version["title"].ShouldBe("One"); + version["views"].ShouldBe(3L); + ((ContentDocument)version["author"]!)["id"].ShouldBe(author); + ((ContentDocument)version["seo"]!)["metaTitle"].ShouldBe("Meta"); + } + + [Fact] + public async Task TheTypedServiceGoesThroughTheSameTransitions() + { + using var database = new ServiceDatabase(schema: SampleSchema.FromClasses().Build()); + await database.AsAsync(Editor, async services => + { + var service = services.GetRequiredService>(); + var created = await service.CreateAsync(new FluentCMS.Core.Tests.Schema.Samples.Author { Name = "Ann" }, Token); + + var published = await service.PublishAsync(created.Id, cancellationToken: Token); + published.Entry.Status.ShouldBe(EntryStatus.Published); + published.Warnings.ShouldBeEmpty(); + + var changed = await service.PatchAsync(created.Id, new() { ["name"] = "Bob" }, 1, Token); + changed.Status.ShouldBe(EntryStatus.Modified); + (await service.DiffAsync(created.Id, 1, 2, cancellationToken: Token)).ShouldHaveSingleItem().Path.ShouldBe("name"); + (await service.GetVersionsAsync(created.Id, cancellationToken: Token)).Count.ShouldBe(2); + (await service.GetVersionAsync(created.Id, 1, cancellationToken: Token))!.Name.ShouldBe("Ann"); + + (await service.DiscardDraftAsync(created.Id, cancellationToken: Token)).Name.ShouldBe("Ann"); + (await service.RestoreVersionAsync(created.Id, 2, cancellationToken: Token)).Name.ShouldBe("Bob"); + (await service.UnpublishAsync(created.Id, cancellationToken: Token)).Status.ShouldBe(EntryStatus.Unpublished); + return true; + }); + } + + [Fact] + public void ThePolicyIsReadFromAnAttributeAndRoundTripsThroughJson() + { + var model = SampleSchema.NewBuilder().AddContentType().Build(); + var type = model.FindContentType("blocked")!; + type.UnpublishedReferences.ShouldBe(UnpublishedReferencePolicy.Block); + type.MaxVersions.ShouldBe(5); + + var json = FluentCMS.Core.Schema.SchemaJsonWriter.Write(model); + json.ShouldContain("\"unpublishedReferences\": \"block\""); + json.ShouldContain("\"maxVersions\": 5"); + + SampleSchema.NewBuilder().AddCollection("plain", plain => plain.Field("name", "text")).Build().FindContentType("plain")!.MaxVersions.ShouldBe(50); + FluentCMS.Core.Schema.SchemaJsonWriter.Write(SampleSchema.NewBuilder().AddCollection("plain", plain => plain.Field("name", "text")).Build()).ShouldNotContain("maxVersions"); + } + + [FluentCMS.Schema.Collection("blocked")] + [PublishPolicy(UnpublishedReferences = UnpublishedReferencePolicy.Block, MaxVersions = 5)] + private sealed class Blocked : ContentItem + { + [Text] + public string? Name { get; set; } + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs new file mode 100644 index 000000000..0c7ce2065 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs @@ -0,0 +1,312 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class ReferenceIntegrityTests : IDisposable +{ + private static readonly string[] Cards = ["card"]; + + // The article refers to an author that may or may not be readable, and blocks when asked to. + private static SchemaModel Model(UnpublishedReferencePolicy policy) => SampleSchema.NewBuilder() + .AddBlock("card", block => block + .Field("who", "relation", field => field.Relation("author")) + .Field("internal", "relation", field => field.Hidden().Relation("author"))) + .AddCollection("author", type => type.Localized().Field("name", "text", field => field.Localizable())) + .AddCollection("article", type => type + .Localized() + .PublishPolicy(policy) + .Field("title", "text", field => field.Localizable()) + .Field("author", "relation", field => field.Relation("author")) + .Field("secretAuthor", "relation", field => field.Hidden().Relation("author")) + .Field("cards", "blocks", field => field.Setting(FieldSettings.Blocks, Cards)) + .Field("body", "richText")) + .Build(); + + private readonly ServiceDatabase _warning = new(schema: Model(UnpublishedReferencePolicy.Warn)); + private readonly ServiceDatabase _blocking = new(schema: Model(UnpublishedReferencePolicy.Block)); + + public void Dispose() + { + _warning.Dispose(); + _blocking.Dispose(); + } + + private static Task AuthorAsync(ServiceDatabase database, string locale = "en") => + database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author", locale).ContinueWith(task => IdOf(task.Result), TaskScheduler.Default); + + private static async Task PublishedAuthorAsync(ServiceDatabase database) + { + var id = await AuthorAsync(database); + await database.PublishAsync(Editor, id, type: "author"); + return id; + } + + private static async Task TrashedAuthorAsync(ServiceDatabase database) + { + var id = await PublishedAuthorAsync(database); + await database.DeleteAsync(Editor, id, "author"); + return id; + } + + private static Task ArticleAsync(ServiceDatabase database, ContentDocument values) => + database.CreateAsync(Editor, values).ContinueWith(task => IdOf(task.Result), TaskScheduler.Default); + + private static Principal Publisher(ServiceDatabase database, params string[] extra) + { + List<(string, PermissionConstraints?)> grants = [("content.article.read", null), ("content.article.publish", null), .. extra.Select(permission => (permission, (PermissionConstraints?)null))]; + return WithRole(database.Database.AddRoleForTest(Guid.NewGuid().ToString("N"), [.. grants])); + } + + [Fact] + public async Task AReferenceToAPublishedEntryIsNoWarning() + { + var author = await PublishedAuthorAsync(_warning); + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = author }); + + (await _warning.PublishAsync(Editor, id)).Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task AReferenceToAnUnpublishedEntryWarnsAndPublishes() + { + var author = await AuthorAsync(_warning); + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = author }); + + var result = await _warning.PublishAsync(Editor, id); + + var warning = result.Warnings.ShouldHaveSingleItem(); + warning.Path.ShouldBe("author"); + warning.Code.ShouldBe(PublishWarningCodes.UnpublishedReference); + warning.TargetId.ShouldBe(author); + warning.TargetType.ShouldBe("author"); + result.Entry["status"].ShouldBe(EntryStatus.Published); + } + + [Fact] + public async Task AReferenceToAnUnpublishedEntryBlocksWhenTheTypeSaysSo() + { + var author = await AuthorAsync(_blocking); + var id = await ArticleAsync(_blocking, new() { ["title"] = "One", ["author"] = author }); + var before = _blocking.Trace(id); + + var error = (await Should.ThrowAsync(() => _blocking.PublishAsync(Editor, id))).Errors.ShouldHaveSingleItem(); + + error.Path.ShouldBe("author"); + error.Code.ShouldBe(PublishWarningCodes.UnpublishedReference); + _blocking.Trace(id).ShouldBe(before); + + await _blocking.PublishAsync(Editor, author, type: "author"); + (await _blocking.PublishAsync(Editor, id)).Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task ATrashedTargetAndOneThatIsNotThereReadTheSameAndNameNoId() + { + var trashed = await TrashedAuthorAsync(_warning); + var missing = Guid.CreateVersion7(); + var one = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = trashed }); + var two = await ArticleAsync(_warning, new() { ["title"] = "Two", ["author"] = missing }); + + var first = (await _warning.PublishAsync(Editor, one)).Warnings.ShouldHaveSingleItem(); + var second = (await _warning.PublishAsync(Editor, two)).Warnings.ShouldHaveSingleItem(); + + first.ShouldBe(second); + first.Code.ShouldBe(PublishWarningCodes.UnavailableReference); + first.TargetId.ShouldBeNull(); + } + + [Fact] + public async Task ATargetOfAnotherSiteOrTypeIsNotThere() + { + var elsewhere = IdOf(await _warning.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author", site: SiteB)); + var article = await ArticleAsync(_warning, new() { ["title"] = "Other type" }); + var one = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = elsewhere }); + var two = await ArticleAsync(_warning, new() { ["title"] = "Two", ["author"] = article }); + + (await _warning.PublishAsync(Editor, one)).Warnings.ShouldHaveSingleItem().Code.ShouldBe(PublishWarningCodes.UnavailableReference); + (await _warning.PublishAsync(Editor, two)).Warnings.ShouldHaveSingleItem().Code.ShouldBe(PublishWarningCodes.UnavailableReference); + } + + [Fact] + public async Task AReferenceInsideABlockOrRichTextCounts() + { + var author = await AuthorAsync(_warning); + var body = new RichText("{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"x\",\"marks\":[{\"type\":\"entryLink\",\"attrs\":{\"entryType\":\"author\",\"entryId\":\"" + author + "\"}}]}]}]}"); + var id = await ArticleAsync(_warning, new() + { + ["title"] = "One", + ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["who"] = author } }, + ["body"] = body, + }); + + var warnings = (await _warning.PublishAsync(Editor, id)).Warnings; + + warnings.Select(warning => warning.Path).ShouldBe(["body", "cards"], ignoreOrder: true); + warnings.ShouldAllBe(warning => warning.TargetId == author); + } + + [Fact] + public async Task ATargetPublishedInAFallbackLocaleCountsAndOneOnlyElsewhereDoesNot() + { + var author = await PublishedAuthorAsync(_warning); + var fr = IdOf(await _warning.CreateAsync(Editor, new() { ["title"] = "Un", ["author"] = author }, locale: "fr")); + (await _warning.PublishAsync(Editor, fr, "fr")).Warnings.ShouldBeEmpty(); + + var french = await AuthorAsync(_warning, "fr"); + await _warning.PublishAsync(Editor, french, "fr", type: "author"); + var en = IdOf(await _warning.CreateAsync(Editor, new() { ["title"] = "One", ["author"] = french })); + (await _warning.PublishAsync(Editor, en)).Warnings.ShouldHaveSingleItem().Code.ShouldBe(PublishWarningCodes.UnpublishedReference); + } + + // The probe: a write takes any id, so anything publish says about a target it may not read would be an oracle. + [Fact] + public async Task ATargetOfATypeItMayNotReadIsNoWarningWhateverItIs() + { + var published = await PublishedAuthorAsync(_warning); + var draft = await AuthorAsync(_warning); + var trashed = await TrashedAuthorAsync(_warning); + var publisher = Publisher(_warning); + + foreach (var target in new[] { published, draft, trashed, Guid.CreateVersion7() }) + { + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = target }); + (await _warning.PublishAsync(publisher, id)).Warnings.ShouldBeEmpty(); + } + } + + [Fact] + public async Task UnderBlockATargetOfATypeItMayNotReadIsAlwaysTheSameRefusal() + { + var published = await PublishedAuthorAsync(_blocking); + var draft = await AuthorAsync(_blocking); + var trashed = await TrashedAuthorAsync(_blocking); + var publisher = Publisher(_blocking); + List answers = []; + + foreach (var target in new[] { published, draft, trashed, Guid.CreateVersion7() }) + { + var id = await ArticleAsync(_blocking, new() { ["title"] = "One", ["author"] = target }); + var error = (await Should.ThrowAsync(() => _blocking.PublishAsync(publisher, id))).Errors.ShouldHaveSingleItem(); + error.Path.ShouldBe(""); + error.Message.ShouldNotContain(target.ToString()); + answers.Add($"{error.Path}|{error.Code}|{error.Message}"); + } + + answers.Distinct().ShouldHaveSingleItem(); + } + + [Fact] + public async Task ATargetItsRowRuleRefusesReadsTheSameAsOneThatIsNotThere() + { + var theirs = await PublishedAuthorAsync(_warning); + var role = _warning.Database.AddRoleForTest( + "Own authors", + ("content.article.read", null), + ("content.article.publish", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule })); + var publisher = WithRole(role); + var refused = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = theirs }); + var missing = await ArticleAsync(_warning, new() { ["title"] = "Two", ["author"] = Guid.CreateVersion7() }); + + var first = (await _warning.PublishAsync(publisher, refused)).Warnings.ShouldHaveSingleItem(); + var second = (await _warning.PublishAsync(publisher, missing)).Warnings.ShouldHaveSingleItem(); + + first.ShouldBe(second); + first.TargetId.ShouldBeNull(); + } + + [Fact] + public async Task AReferenceInAFieldItMayNotReadIsNoWarningAndUnderBlockSaysNotWhere() + { + var draft = await AuthorAsync(_warning); + var limited = WithRole(_warning.Database.AddRoleForTest( + "Titles", + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.publish", null), + ("content.author.read", null))); + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = draft }); + (await _warning.PublishAsync(limited, id)).Warnings.ShouldBeEmpty(); + + var blocked = await AuthorAsync(_blocking); + var limitedThere = WithRole(_blocking.Database.AddRoleForTest( + "Titles", + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.publish", null), + ("content.author.read", null))); + var other = await ArticleAsync(_blocking, new() { ["title"] = "One", ["author"] = blocked }); + (await Should.ThrowAsync(() => _blocking.PublishAsync(limitedThere, other))).Errors.ShouldHaveSingleItem().Path.ShouldBe(""); + } + + [Fact] + public async Task AHiddenReferenceIsNoWarningAtTheTopOrInsideABlock() + { + var draft = await AuthorAsync(_warning); + var publisher = Publisher(_warning, "content.author.read"); + var id = IdOf(await _warning.AsAsync(Principal.System, services => Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(services).CreateAsync( + "article", + new() + { + ["title"] = "One", + ["secretAuthor"] = draft, + ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["internal"] = draft } }, + }, + Token), origin: Origin.Seed)); + + (await _warning.PublishAsync(publisher, id)).Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task TheSystemPrincipalSeesWhichTargetAndWhatIsWrong() + { + var trashed = await TrashedAuthorAsync(_warning); + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = trashed }); + + var result = await _warning.AsAsync(Principal.System, services => Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(services).PublishAsync("article", id, null, null, Token), origin: Origin.Job); + + result.Warnings.ShouldHaveSingleItem().TargetId.ShouldBe(trashed); + } + + [Fact] + public async Task APublishedReadLeavesOutATargetThatIsNotPublishedOrIsInTheTrash() + { + var published = await PublishedAuthorAsync(_warning); + var draft = await AuthorAsync(_warning); + var trashed = await TrashedAuthorAsync(_warning); + + async Task ReadAuthorAsync(Guid target) + { + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = target }); + await _warning.PublishAsync(Editor, id); + return (await _warning.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published }))!["author"]; + } + + (await ReadAuthorAsync(published)).ShouldBeOfType()["id"].ShouldBe(published); + (await ReadAuthorAsync(draft)).ShouldBeNull(); + (await ReadAuthorAsync(trashed)).ShouldBeNull(); + + // A draft read still shows what its author attached. + var inDraft = await ArticleAsync(_warning, new() { ["title"] = "Two", ["author"] = draft }); + (await _warning.GetAsync(Editor, inDraft))!["author"].ShouldBeOfType()["id"].ShouldBe(draft); + } + + [Fact] + public async Task APublishedReadStillNamesATargetPublishedOnlyInAnotherLocale() + { + var french = await AuthorAsync(_warning, "fr"); + await _warning.PublishAsync(Editor, french, "fr", type: "author"); + var id = await ArticleAsync(_warning, new() { ["title"] = "One", ["author"] = french }); + await _warning.PublishAsync(Editor, id); + + var author = (await _warning.GetAsync(Editor, id, new GetOptions { Status = QueryStatus.Published, Populate = ["author"] }))!["author"].ShouldBeOfType(); + + author["id"].ShouldBe(french); + author.ContainsKey("name").ShouldBeFalse(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/VersionDiffTests.cs b/tests/FluentCMS.Data.Tests/Content/VersionDiffTests.cs new file mode 100644 index 000000000..9b4797046 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/VersionDiffTests.cs @@ -0,0 +1,106 @@ +using FluentCMS.Content; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class VersionDiffTests : IDisposable +{ + private static readonly string[] OneLabel = ["net"]; + private static readonly string[] TwoLabels = ["net", "sql"]; + + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static RichText Paragraphs(params string[] texts) => new( + "{\"type\":\"doc\",\"content\":[" + string.Join(',', texts.Select(text => "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"" + text + "\"}]}")) + "]}"); + + private static ContentDocument Hero(string heading) => new() { ["type"] = "hero", ["heading"] = heading }; + + private static ContentDocument Quote(string text) => new() { ["type"] = "quote", ["text"] = text }; + + private async Task> DiffOfAsync(ContentDocument first, ContentDocument second) + { + first["title"] = Guid.NewGuid().ToString("N"); + var id = IdOf(await _database.CreateAsync(Editor, first)); + await _database.PatchAsync(Editor, id, second, 1); + return await _database.DiffAsync(Editor, id, 1, 2); + } + + [Fact] + public async Task AChangeInsideABlockIsReportedAtItsPath() + { + var change = (await DiffOfAsync( + new() { ["sections"] = new object[] { Hero("One"), Hero("Two") } }, + new() { ["sections"] = new object[] { Hero("One"), Hero("Changed") } })).ShouldHaveSingleItem(); + + change.Path.ShouldBe("sections[1].heading"); + change.Kind.ShouldBe(VersionChangeKind.Changed); + change.Before!.GetValue().ShouldBe("Two"); + change.After!.GetValue().ShouldBe("Changed"); + } + + [Fact] + public async Task ABlockAddedRemovedOrOfAnotherTypeIsOneChange() + { + var added = (await DiffOfAsync( + new() { ["sections"] = new object[] { Hero("One") } }, + new() { ["sections"] = new object[] { Hero("One"), Quote("New") } })).ShouldHaveSingleItem(); + added.Path.ShouldBe("sections[1]"); + added.Kind.ShouldBe(VersionChangeKind.Added); + added.Before.ShouldBeNull(); + + var removed = (await DiffOfAsync( + new() { ["sections"] = new object[] { Hero("One"), Quote("Old") } }, + new() { ["sections"] = new object[] { Hero("One") } })).ShouldHaveSingleItem(); + removed.Path.ShouldBe("sections[1]"); + removed.Kind.ShouldBe(VersionChangeKind.Removed); + removed.After.ShouldBeNull(); + + var other = (await DiffOfAsync( + new() { ["sections"] = new object[] { Hero("One") } }, + new() { ["sections"] = new object[] { Quote("One") } })).ShouldHaveSingleItem(); + other.Path.ShouldBe("sections[0]"); + other.Kind.ShouldBe(VersionChangeKind.Changed); + } + + [Fact] + public async Task RichTextComparesAsATree() + { + var changes = await DiffOfAsync( + new() { ["body"] = Paragraphs("One", "Two") }, + new() { ["body"] = Paragraphs("One", "Changed", "Three") }); + + changes.Select(change => (change.Path, change.Kind)).ShouldBe( + [("body.content[1].content[0].text", VersionChangeKind.Changed), ("body.content[2]", VersionChangeKind.Added)]); + } + + [Fact] + public async Task ScalarsGroupsAndListsReportTheirOwnPaths() + { + var changes = await DiffOfAsync( + new() { ["views"] = 1, ["seo"] = new ContentDocument { ["metaTitle"] = "One" }, ["labels"] = OneLabel }, + new() { ["views"] = 2, ["seo"] = new ContentDocument { ["metaTitle"] = "Two" }, ["labels"] = TwoLabels, ["region"] = "eu" }); + + changes.Select(change => (change.Path, change.Kind)).ShouldBe( + [ + ("views", VersionChangeKind.Changed), + ("region", VersionChangeKind.Added), + ("labels[1]", VersionChangeKind.Added), + ("seo.metaTitle", VersionChangeKind.Changed), + ], + ignoreOrder: true); + } + + [Fact] + public async Task AVersionComparedWithItselfHasNoChanges() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["body"] = Paragraphs("x") })); + + (await _database.DiffAsync(Editor, id, 1, 1)).ShouldBeEmpty(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Query/PopulationTests.cs b/tests/FluentCMS.Data.Tests/Query/PopulationTests.cs index ff530c7a0..a2b3a1c2b 100644 --- a/tests/FluentCMS.Data.Tests/Query/PopulationTests.cs +++ b/tests/FluentCMS.Data.Tests/Query/PopulationTests.cs @@ -216,7 +216,7 @@ public async Task TheSameTargetUnderTwoEntriesIsInlinedForBoth() } [Fact] - public async Task AnUnpublishedOrTrashedTargetStaysAReference() + public async Task AnUnpublishedOrTrashedTargetIsNotReturnedByAPublishedRead() { var draft = _database.AddAuthor("Draft", state: ContentState.Draft); var other = _database.AddArticle("Other"); @@ -227,8 +227,8 @@ public async Task AnUnpublishedOrTrashedTargetStaysAReference() var items = (await _database.RunAsync("locale=en&populate=author&sort=title")).Items; - ShouldBeReference(items.Single(item => item["title"] as string == "Other")["author"], draft, "author"); - ShouldBeReference(items.Single(item => item["title"] as string == "Third")["author"], _author, "author"); + items.Single(item => item["title"] as string == "Other")["author"].ShouldBeNull(); + items.Single(item => item["title"] as string == "Third")["author"].ShouldBeNull(); } [Fact] From ed105dfe621f7bb3e6cffd3fe8c9dae4eaf415ba Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 18:01:40 -0500 Subject: [PATCH 2/8] Filter a restored version by one list of fields --- .../Content/ContentService.Publishing.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/FluentCMS.Core/Content/ContentService.Publishing.cs b/src/FluentCMS.Core/Content/ContentService.Publishing.cs index bd8af255d..6495f2474 100644 --- a/src/FluentCMS.Core/Content/ContentService.Publishing.cs +++ b/src/FluentCMS.Core/Content/ContentService.Publishing.cs @@ -504,10 +504,11 @@ private async Task RestorableFieldsAsync(WriteScope write, Cont { var isSystem = write.Permissions.IsSystem; var snapshot = ParseSnapshot(version); + var taken = write.Type.Fields.Where(field => isSystem || !Keeps(write, field, writable, addsLocale: false)).ToList(); JsonObject fields = []; - foreach (var field in write.Type.Fields) + foreach (var field in taken) { - if ((isSystem || !Keeps(write, field, writable, addsLocale: false)) && snapshot[field.Name] is { } value) + if (snapshot[field.Name] is { } value) { var kept = value.DeepClone(); HiddenFields.RemoveWhere(schema, kept, field, inner => !isSystem && (inner.Hidden || inner.ReadOnly)); @@ -517,12 +518,9 @@ private async Task RestorableFieldsAsync(WriteScope write, Cont await DropUnreadableTargetsAsync(write, fields, cancellationToken); ContentDocument restored = []; - foreach (var field in write.Type.Fields) + foreach (var field in taken) { - if (isSystem || !Keeps(write, field, writable, addsLocale: false)) - { - restored[field.Name] = fields[field.Name]?.DeepClone(); - } + restored[field.Name] = fields[field.Name]?.DeepClone(); } return restored; From 360cb79b4abc5cb0c8336e02c7dd51abc2f05b1d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 18:04:36 -0500 Subject: [PATCH 3/8] Align the spec's method table with publishing --- docs/planning/backend-technical-spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index d2c05a06f..bb5943485 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -465,13 +465,13 @@ Rows 2.3 to 2.8, 2.14, 2.15, 2.19, 1.7, 1.8. | `PatchAsync(id, patch, expectedVersion)` | partial update of the draft with a JSON merge patch; a null removes a field. Its own method, because a class cannot express absence | | `DeleteAsync(id)` | trash, in every locale | | `RestoreAsync(id)` | from trash, in every locale | -| `PublishAsync(id, locale?)`, `UnpublishAsync`, `DiscardDraftAsync` | publishing transitions (4.5) | +| `PublishAsync(id, locale?, expectedVersion?)`, `UnpublishAsync(id, locale?)`, `DiscardDraftAsync(id, locale?, expectedVersion?)` | publishing transitions (4.5); publish returns the entry and `warnings` | | `ScheduleAsync(id, ScheduleRequest)`, `CancelScheduleAsync` | per locale | -| `GetVersionsAsync(id, locale)`, `RestoreVersionAsync(id, number)`, `DiffAsync(id, a, b)` | versions | +| `GetVersionsAsync(id, locale?)`, `GetVersionAsync(id, number, locale?)`, `RestoreVersionAsync(id, number, locale?, expectedVersion?)`, `DiffAsync(id, a, b, locale?)` | versions | | `BulkAsync(BulkRequest)` | create many; update, publish, unpublish, delete by id list or by filter with a required cap; per-item result | | `CountAsync(filter)` | | -**Contract as built.** Every method of `IContentService` takes the type name first. `expectedVersion` is a required `int`, so no caller skips it. +**Contract as built.** Every method of `IContentService` takes the type name first. On a save `expectedVersion` is a required `int`, so no caller skips it; on publish, discard and version restore it is optional, for a caller that wants to act only on what it reviewed. - **Refusals.** A principal that lacks the permission on the type gets `ContentAccessDeniedException` (HTTP 401 or 403). An entry that does not exist, is in another site or of another type, or is refused by its row rule gives `EntryNotFoundException` (404), so a client cannot probe; a read returns null. Delete and restore need the operation on every locale of the entry: none reached is not found, some reached is denied. An unreadable type on a read stays the engine's `ValidationException` on `type`, the same as an unknown type. - **System operations.** An operation with no ambient `OperationContext` throws. `Principal.System` is accepted only from `Origin.Job`, `Origin.Cli` and `Origin.Seed`, entered through `SystemOperation.Begin`; from `Api`, `Admin` or `Mcp` it throws. From cde295cee0c4cacb7ee5e63c4a01e1a07d38e23a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 18:58:30 -0500 Subject: [PATCH 4/8] Close the reference and version leaks found in review --- docs/planning/backend-technical-spec.md | 23 +- .../Authorization/AuthorizationEvaluator.cs | 3 +- .../Content/ContentErrorCodes.cs | 1 + .../Content/ContentService.Publishing.cs | 249 +++++++++----- src/FluentCMS.Core/Content/ContentService.cs | 28 +- src/FluentCMS.Core/Content/EntryComposer.cs | 119 +++++-- src/FluentCMS.Core/Content/EntryStore.cs | 40 ++- .../Content/Query/ContentQueryEngine.cs | 39 ++- .../Content/Query/HiddenFields.cs | 11 +- .../Content/Query/NestedTargets.cs | 194 +++++++++++ .../Storage/Entities/ContentVersion.cs | 2 +- src/FluentCMS.Sdk/Content/IContentService.cs | 10 +- .../Content/NestedTargetTests.cs | 317 ++++++++++++++++++ .../Content/PublishingAccessTests.cs | 157 ++++++++- .../Content/PublishingTransitionTests.cs | 39 +++ .../Content/ReferenceIntegrityTests.cs | 153 +++++++++ 16 files changed, 1228 insertions(+), 157 deletions(-) create mode 100644 src/FluentCMS.Core/Content/Query/NestedTargets.cs create mode 100644 tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index bb5943485..d99c72d04 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -554,13 +554,28 @@ Rows 6.1 to 6.11; decisions 38, 39, 40, 41. | Purge | delete all rows; separate permission `content.{type}.purge` | | `EntryPurgedEvent` | | Schedule | row in `cms_schedule`, job enqueued | | `EntryScheduledEvent` | -**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, which `status_after` records. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. +**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, and only a publish marks one so, in `status_after`. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. Access to versions is decided on the entry's current row: history is part of the entry, so a reader the row rule lets in also sees versions from before the entry came within the rule. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. -**Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. A target counts as published when it has a published row in the entry's locale or its fallback chain. The answer must not be a way to ask about an entry the principal may not read, since a write takes any id: a target of a type it may not read is never confirmed, so it is no warning and, under `Block`, always a refusal; one its row rule refuses, one in the trash or in another site and one that does not exist read the same and name no id; a reference in a field it may not read, or a hidden one, is no warning and, under `Block`, a refusal that does not say where. +**Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. A target counts as published when it has a published row in the entry's locale or its fallback chain. A self-reference is exempt. A cycle is not: under `Block` neither side can go first, and the way out is to publish one side without the reference and set it afterwards. -**Published reads.** A published read shows only what is published. A relation target that is trashed or has no published row in any locale is returned as null, or left out of a multiple relation. A target published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed or unpublished target as a reference, because that is what its author attached. A filter on the relation still matches such a target: it answers about the source entry and a value the caller named. +The answer must not be a way to ask about an entry the principal may not read, since a write takes any id: -**Shared fields.** Non-localizable fields live on one row per state, so publishing one locale publishes them for every published locale. A grant limited to some locales may publish only when those fields have not changed, or no other locale is published. The other locales' status is recomputed after a transition that moves the shared row. +| Target, for a principal other than the system principal | Warn | Block | +| --- | --- | --- | +| Published, and it may read it | nothing | publishes | +| Unpublished, and it may read it | `FCMS2403` with the id | `FCMS2403` on the field | +| Trashed, missing, in another site or of another type; refused by its row rule, asked of the locale that serves the target; or with rows only in locales its grant does not cover | `FCMS2404`, no id, all identical | `FCMS2404` on the field | +| Of a type it may not read, or of no type at all | nothing | always one refusal, no path | +| In a field it can write but not read, which is where it chooses the id | nothing | always one refusal, no path | +| In a field it can neither read nor write, or hidden: it did not choose the id | nothing | a refusal with no path, only if the target really is a problem | + +The system principal is told the id on `FCMS2404`. + +**Relation targets on a read.** The same rules hold for a top-level relation, a relation inside a block or component, and an entry link inside rich text. A target the principal may not read by type or row rule, in another site, or of another type than the field's, is returned as null or left out of a list; a rich text link keeps its text and loses its target, and a link to an unknown type is treated as one to an unreadable type. A published read shows only what is published: a target that is trashed or has no published row in any locale goes too, and one published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed target of a type the principal may read as a reference, because that is what its author attached. A filter on the relation still matches a target left out: it answers about the source entry and a value the caller named. + +A write answers what a read showed, so what the read left out stays: a nested target goes back in its block, and a rich text document keeps its links when it comes back as it was read. Rich text nodes have no ids, so an edited document loses the links the principal could not see. + +**Shared fields.** Non-localizable fields live on one row per state, so publishing one locale publishes them for every published locale. A grant limited to some locales may publish only when those fields have not changed, or no other locale is published. The other locales' status is recomputed after a transition that moves the shared row. So `published_version` names the version a locale last published, and what that locale serves can differ from that snapshot in its shared fields. **Scheduling.** `cms_schedule (id, document_id, locale, action, run_at_utc, time_zone, status, job_id, created_by)`. The job runner executes `ScheduledPublishJob` and `ScheduledUnpublishJob`; `cms_document` mirrors the next scheduled times for list views. Cancel and reschedule update the row and the job. diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index fbf3c3abe..7bea99cf5 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -137,7 +137,8 @@ private async Task IsInSiteAsync(SchemaType type, ContentOperation operati } IQueryable documents = ReachesTrash(operation) ? database.Documents.IgnoreQueryFilters() : database.Documents; - return await documents.AsNoTracking().AnyAsync(document => document.DocumentId == entry && document.SiteId == site.Value, cancellationToken); + var typeName = type.Name; + return await documents.AsNoTracking().AnyAsync(document => document.DocumentId == entry && document.Type == typeName && document.SiteId == site.Value, cancellationToken); } private async Task MatchesAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, QueryStatus status, ContentFilter rule, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Content/ContentErrorCodes.cs b/src/FluentCMS.Core/Content/ContentErrorCodes.cs index 203e14872..1b3687280 100644 --- a/src/FluentCMS.Core/Content/ContentErrorCodes.cs +++ b/src/FluentCMS.Core/Content/ContentErrorCodes.cs @@ -4,4 +4,5 @@ internal static class ContentErrorCodes { public const string SingletonExists = "FCMS2401"; public const string NotPublished = "FCMS2402"; + public const string DraftMovedByHook = "FCMS2405"; } diff --git a/src/FluentCMS.Core/Content/ContentService.Publishing.cs b/src/FluentCMS.Core/Content/ContentService.Publishing.cs index 6495f2474..87593de70 100644 --- a/src/FluentCMS.Core/Content/ContentService.Publishing.cs +++ b/src/FluentCMS.Core/Content/ContentService.Publishing.cs @@ -9,14 +9,14 @@ using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Core.Storage; using FluentCMS.Core.Storage.Entities; +using FluentCMS.Hooks; using FluentCMS.Publishing; using FluentCMS.Schema; namespace FluentCMS.Core.Content; -// The transitions of spec 4.5 and the versions behind them. Each is a write like any other: the permission, the entry and -// its row rule, then in one transaction the field limits, the hooks, validation and the write, and the sink and the -// response only after the commit. What a version holds is read and restored as this principal may, not as it was saved. +// The transitions of spec 4.5 and the versions behind them. Each is a write like any other, and what a version holds is +// read and restored as this principal may today, not as it was saved. internal sealed partial class ContentService { public async Task> PublishAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) @@ -38,13 +38,13 @@ public async Task> PublishAsync(string type, Guid // Publishing moves a shared field for every locale published with it, so those are the ones a grant must cover. var moved = target with { EntryLocales = [.. documents.Where(other => other.PublishedVersion is not null || other.Id == document.Id).Select(other => other.Locale)] }; var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Publish, id, moved, cancellationToken); - ThrowIfMovesWhatItMayNotWrite(write, draft, published, writable, ContentOperation.Publish); + await ThrowIfBeyondThePublishGrantAsync(write, id, target, draft, published, writable, cancellationToken); var original = EntryJson.Fields(write.Type, draft); var working = ContentCopy.Of(draft); await hooks.RunAsync(HookKind.BeforeValidate, write.Type, write.Operation, working, CopyOf(previous), cancellationToken); working = Settle(write, working, original, draft, writable); - await ValidateForPublishAsync(write, working, document, warnings, cancellationToken); + await ValidateForPublishAsync(write, working, document, target, warnings, cancellationToken); var validated = EntryJson.Fields(write.Type, working); await hooks.RunAsync(HookKind.BeforePublish, write.Type, write.Operation, working, CopyOf(previous), cancellationToken); @@ -52,10 +52,15 @@ public async Task> PublishAsync(string type, Guid var settled = EntryJson.Fields(write.Type, working); if (!JsonNode.DeepEquals(validated, settled)) { - await ValidateForPublishAsync(write, working, document, warnings, cancellationToken); + await ValidateForPublishAsync(write, working, document, target, warnings, cancellationToken); + } + + // A hook can call the service. What it saved, in another locale too, would go out with the shared row unvalidated. + if (!SameFields(write.Type, draft, await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken))) + { + throw new HookRejectedException(ContentErrorCodes.DraftMovedByHook, "A hook changed the draft being published."); } - // What a hook changed is a new draft version, so that the published state is a version like any other. if (!JsonNode.DeepEquals(original, settled)) { var status = document.Status == EntryStatus.Published ? EntryStatus.Modified : document.Status; @@ -104,8 +109,12 @@ public async Task UnpublishAsync(string type, Guid id, string? await hooks.RunAsync(HookKind.BeforeUnpublish, write.Type, write.Operation, working, ContentCopy.Of(published), cancellationToken); var isLast = !documents.Any(other => other.Id != document.Id && other.PublishedVersion is not null); + if (!await store.MarkUnpublishedAsync(document, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken)) + { + throw await ConflictAsync(write, id, document.DraftVersion, await store.DraftVersionAsync(id, document.Locale, cancellationToken), cancellationToken); + } + await store.DeletePublishedAsync(write.Type, document, isLast, cancellationToken); - await store.MarkUnpublishedAsync(document, write.Operation.Timestamp, write.Operation.AuditPrincipal.Id, cancellationToken); await hooks.RunAsync(HookKind.AfterUnpublish, write.Type, write.Operation, working, ContentCopy.Of(published), cancellationToken); _pending.Add(new EntryChange(EntryChangeKind.Unpublished, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation)); @@ -132,19 +141,21 @@ public async Task DiscardDraftAsync(string type, Guid id, strin var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); if (SameFields(write.Type, stored, published)) { + if (document.Status != EntryStatus.Published) + { + await store.SetStatusAsync(id, document.Locale, EntryStatus.Published, cancellationToken); + } + return document; } - // The published values go back whole for a principal that may write all of them. For one that may not, it is - // as an update: only what it may write changes, and the hidden and read-only values stay with what it left. var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); - ContentDocument incoming = []; - foreach (var field in write.Type.Fields.Where(field => writable.Allowed && (writable.Fields is null || (!field.Hidden && !field.ReadOnly && writable.Includes(field.Name))))) + var working = Prepare(write, EntryComposer.Replace(write.Type, stored, DiscardedValues(write, published, writable), static _ => true), document, stored); + if (writable.Fields is not null) { - incoming[field.Name] = published.GetValueOrDefault(field.Name); + composer.CarryProtected(write.Type, stored, working, includingGroups: true); } - var working = Prepare(write, EntryComposer.Replace(write.Type, stored, incoming, static _ => true), document, stored); working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, [], writable, document), cancellationToken); await WriteOverDraftAsync(write, id, document, working, SameFields(write.Type, working, published) ? EntryStatus.Published : EntryStatus.Modified, "Discarded the draft.", cancellationToken); @@ -161,6 +172,28 @@ public async Task DiscardDraftAsync(string type, Guid id, strin return await RespondAsync(write, row); } + // Whole for a principal that may write every field. Otherwise as an update: only what it may write, without the hidden + // and read-only values inside, which stay as the draft holds them. + private ContentDocument DiscardedValues(WriteScope write, ContentDocument published, WritableFields writable) + { + ContentDocument values = []; + foreach (var field in write.Type.Fields) + { + if (writable.Allowed && writable.Fields is null) + { + values[field.Name] = published.GetValueOrDefault(field.Name); + } + else if (!field.Hidden && !field.ReadOnly && writable.Includes(field.Name)) + { + var value = EntryJson.ToNode(published.GetValueOrDefault(field.Name)); + HiddenFields.RemoveWhere(schema, value, field, static inner => inner.Hidden || inner.ReadOnly); + values[field.Name] = value; + } + } + + return values; + } + public async Task> GetVersionsAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(type); @@ -224,8 +257,8 @@ public async Task RestoreVersionAsync(string type, Guid id, int var working = Prepare(write, composed, document, stored); if (!isSystem) { - composer.CarryProtected(write.Type, stored, working, includingGroups: true); var shown = await ReadShownAsync(write, id, documents, addsLocale: false, cancellationToken); + composer.CarryProtected(write.Type, stored, working, includingGroups: true, await UnseenNestedTargetsAsync(write, stored, shown, cancellationToken)); await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch: false, cancellationToken); } @@ -268,10 +301,18 @@ public async Task> DiffAsync(string type, Guid id, return VersionDiff.Between(await ReadableFieldsOfAsync(write, before, cancellationToken), await ReadableFieldsOfAsync(write, after, cancellationToken)); } - // Versions are a draft's history, so they need what a draft read needs, and they are for those who manage content: a - // preview key shows the draft as it is, not everything it has been. + // A preview key shows the draft as it is, not everything it has been, so versions also need the Management scope. An + // unknown type and an unreadable one give the engine's one error, so that type names cannot be tried. Access goes by the + // current row (spec 4.5): history is part of the entry. private async Task<(WriteScope Write, Document Document)> BeginVersionReadAsync(string type, Guid id, string? locale, CancellationToken cancellationToken) { + var operation = Current(); + var resolved = await ResolveLocaleAsync(operation.Site, locale ?? operation.Locale, cancellationToken); + if (schema.FindContentType(type) is not { } known || !(await evaluator.ResolveAsync(operation, cancellationToken)).Allows(known, ContentOperation.Read, new AccessTarget(operation.Site, resolved))) + { + throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{type}' is not a content type that can be read.")]); + } + var write = await BeginAsync(type, ContentOperation.Read, cancellationToken, locale); if (!write.Permissions.CanManage) { @@ -282,7 +323,6 @@ public async Task> DiffAsync(string type, Guid id, return (write, document); } - // No row in the locale, in another site or of another type, or refused by the row rule all read as no entry. private async Task<(List Documents, Document Document, AccessTarget Target)> ReachLocaleAsync(WriteScope write, ContentOperation operation, Guid id, CancellationToken cancellationToken) { var documents = await store.DocumentsAsync(write.Type, id, write.Site, trashed: false, cancellationToken); @@ -314,22 +354,29 @@ private static void ThrowIfNotPublished(WriteScope write, Document document) } } - // A grant limited to some fields publishes only what stays within them, and it does not say which it was not, since it - // may not be able to read that field. A hidden or read-only field has no grant to ask; a hook is what fills it. - private static void ThrowIfMovesWhatItMayNotWrite(WriteScope write, ContentDocument draft, ContentDocument published, WritableFields writable, ContentOperation operation) + // A grant limited to some fields publishes only what stays within them. A field it can write but not read is refused + // whatever it holds: otherwise it could save a guess and let this answer say whether it equals the published value. + private async Task ThrowIfBeyondThePublishGrantAsync(WriteScope write, Guid id, AccessTarget target, ContentDocument draft, ContentDocument published, WritableFields writable, CancellationToken cancellationToken) { + if (writable.Allowed && writable.Fields is null) + { + return; + } + + var updatable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); foreach (var field in write.Type.Fields.Where(field => !field.Hidden && !field.ReadOnly && !writable.Includes(field.Name))) { var from = published.GetValueOrDefault(field.Name); var to = draft.GetValueOrDefault(field.Name); - if (!(EntryJson.Same(from, to) || (LocaleFallbackReader.IsMissing(from) && LocaleFallbackReader.IsMissing(to)))) + var isSame = EntryJson.Same(from, to) || (LocaleFallbackReader.IsMissing(from) && LocaleFallbackReader.IsMissing(to)); + if (!isSame || (updatable.Includes(field.Name) && !write.Permissions.CanReadField(write.Type, field.Name, write.Target))) { - throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(operation)); + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Publish)); } } } - // A publish makes no slug: the draft's values are what was reviewed. A hook may set one, and answers to the same limits as on a save. + // A publish makes no slug: the draft's values are what was reviewed. private ContentDocument Settle(WriteScope write, ContentDocument working, JsonObject baseline, ContentDocument stored, WritableFields writable) { var canonical = canonicalizer.Canonicalize(write.Type, working, static (_, _) => false, stored); @@ -338,7 +385,7 @@ private ContentDocument Settle(WriteScope write, ContentDocument working, JsonOb return canonical; } - private async Task ValidateForPublishAsync(WriteScope write, ContentDocument working, Document document, List warnings, CancellationToken cancellationToken) + private async Task ValidateForPublishAsync(WriteScope write, ContentDocument working, Document document, AccessTarget target, List warnings, CancellationToken cancellationToken) { warnings.Clear(); var entry = write.Type.ClrType is { } clrType ? mapper.ToEntry(clrType, working) : null; @@ -352,7 +399,7 @@ private async Task ValidateForPublishAsync(WriteScope write, ContentDocument wor async (context, token) => { await store.CheckUniqueAsync(write.Type, working, document.DocumentId, write.Site, write.Locale, context, token, publishing: true); - await CheckReferencesAsync(write, working, context, warnings, token); + await CheckReferencesAsync(write, working, document.DocumentId, target, context, warnings, token); }); var errors = new EntryErrors(schema, write.Type, write.Permissions, write.Target).ForPublish(result.Errors, EntryJson.Fields(write.Type, working)); @@ -362,54 +409,57 @@ private async Task ValidateForPublishAsync(WriteScope write, ContentDocument wor } } - // Reference integrity, on what is about to be published. The answer must not be how a principal learns of what it may - // not read: a write takes any id without looking, so a warning or a refusal that told a missing target from a published - // one would be a way to ask about any entry. So a target of a type it may not read is never confirmed, and is no warning - // and, where the type blocks, always a refusal; and one its row rule refuses reads the same as one that is not there. - // A reference in a field it may not read is decided on what is true, and says nothing of where. - private async Task CheckReferencesAsync(WriteScope write, ContentDocument working, ValidationContext context, List warnings, CancellationToken cancellationToken) + // A write takes any id without looking, so a warning or a refusal that told a missing target from a published one would + // be a way to ask about any entry. What a principal could have chosen is therefore never confirmed to it (spec 4.5). + // A self-reference is exempt: it is what is being published. + private async Task CheckReferencesAsync(WriteScope write, ContentDocument working, Guid id, AccessTarget entryTarget, ValidationContext context, List warnings, CancellationToken cancellationToken) { var blocks = write.Type.UnpublishedReferences == UnpublishedReferencePolicy.Block; var locales = (await fallback.ChainAsync(write.Site, write.Locale, LocaleFallback.Chain, cancellationToken)).Locales; var isSystem = write.Permissions.IsSystem; HashSet<(string Path, string Code, Guid? Id)> told = []; + WritableFields? updatable = null; + WritableFields? creatable = null; + + void Refuse() => context.AddError("", PublishWarningCodes.UnavailableReference, "The entry refers to an entry the principal cannot confirm."); foreach (var reference in store.TargetsOf(write.Type, working)) { - var visible = isSystem || (reference.IsShown && write.Permissions.CanReadField(write.Type, reference.Field, write.Target)); + if (reference.Id == id && reference.TargetType == write.Type.Name) + { + continue; + } + var target = schema.FindContentType(reference.TargetType); - if (target is not null && !write.Permissions.Allows(target, ContentOperation.Read, write.Target)) + if (!isSystem && !(reference.IsShown && write.Permissions.CanReadField(write.Type, reference.Field, write.Target))) { if (blocks) { - context.AddError("", PublishWarningCodes.UnavailableReference, "The entry refers to an entry the principal cannot confirm."); + updatable ??= await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, entryTarget, cancellationToken); + creatable ??= await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Create, id, entryTarget, cancellationToken); + var chose = reference.IsShown && (updatable.Includes(reference.Field) || creatable.Includes(reference.Field)); + if (chose || !await IsPublishedTargetAsync(write, target, reference.Id, locales, cancellationToken)) + { + Refuse(); + } } continue; } - var state = target is null - ? ReferenceTargetState.Unavailable - : await store.TargetStateAsync(target, reference.Id, write.Site, locales, cancellationToken); - - if (visible && state != ReferenceTargetState.Unavailable - && !await evaluator.CanAccessEntryAsync(write.Operation, target!, ContentOperation.Read, reference.Id, write.Target, QueryStatus.Draft, cancellationToken)) + if (!isSystem && (target is null || !write.Permissions.Allows(target, ContentOperation.Read, write.Target))) { - state = ReferenceTargetState.Unavailable; - } + if (blocks) + { + Refuse(); + } - if (state == ReferenceTargetState.Published) - { continue; } - if (!visible) + var state = await TargetStateAsync(write, target, reference.Id, locales, cancellationToken); + if (state == ReferenceTargetState.Published) { - if (blocks) - { - context.AddError("", PublishWarningCodes.UnavailableReference, "The entry refers to an entry the principal cannot confirm."); - } - continue; } @@ -431,7 +481,47 @@ private async Task CheckReferencesAsync(WriteScope write, ContentDocument workin } } - // The save at the end of a transition: the version moves as on any save, under the same swap. + private async Task IsPublishedTargetAsync(WriteScope write, SchemaType? target, Guid id, IReadOnlyList locales, CancellationToken cancellationToken) => + target is not null && IsPublishedIn(target, await store.TargetRowsAsync(target, id, write.Site, cancellationToken), locales); + + private static bool IsPublishedIn(SchemaType target, List rows, IReadOnlyList locales) => + rows.Any(row => row.IsPublished && (!target.Localized || locales.Contains(row.Locale, StringComparer.OrdinalIgnoreCase))); + + // What a principal that may read the type may know of a target. A row only in locales its grant does not cover, and a + // row its rule refuses, read the same as no row. The rule is asked of the locale that serves the target, not the entry's. + private async Task TargetStateAsync(WriteScope write, SchemaType? target, Guid id, IReadOnlyList locales, CancellationToken cancellationToken) + { + if (target is null) + { + return ReferenceTargetState.Unavailable; + } + + var rows = await store.TargetRowsAsync(target, id, write.Site, cancellationToken); + if (!write.Permissions.IsSystem) + { + rows = [.. rows.Where(row => !target.Localized || write.Permissions.Allows(target, ContentOperation.Read, write.Target with { Locale = row.Locale }))]; + } + + if (rows.Count == 0) + { + return ReferenceTargetState.Unavailable; + } + + if (!write.Permissions.IsSystem) + { + var serving = locales.Select(locale => rows.Find(row => row.IsPublished && string.Equals(row.Locale, locale, StringComparison.OrdinalIgnoreCase))).FirstOrDefault(row => row is not null) + ?? locales.Select(locale => rows.Find(row => string.Equals(row.Locale, locale, StringComparison.OrdinalIgnoreCase))).FirstOrDefault(row => row is not null) + ?? rows[0]; + var asked = target.Localized ? write.Target with { Locale = serving.Locale } : write.Target; + if (!await evaluator.CanAccessEntryAsync(write.Operation, target, ContentOperation.Read, id, asked, QueryStatus.Draft, cancellationToken)) + { + return ReferenceTargetState.Unavailable; + } + } + + return IsPublishedIn(target, rows, locales) ? ReferenceTargetState.Published : ReferenceTargetState.Unpublished; + } + private async Task WriteOverDraftAsync(WriteScope write, Guid id, Document document, ContentDocument working, EntryStatus status, string? message, CancellationToken cancellationToken) { var expected = document.DraftVersion; @@ -478,8 +568,7 @@ private async Task RecomputeOtherLocalesAsync(WriteScope write, List d private static bool SameFields(SchemaType type, ContentDocument left, ContentDocument right) => JsonNode.DeepEquals(EntryJson.Fields(type, left), EntryJson.Fields(type, right)); - // A version as this principal may read it today: no hidden value at any depth, no field its grants leave out, no key - // the schema has since lost, and no relation target it may not read. Hidden fields show to no one, the system principal included. + // Hidden fields show to no one, the system principal included. private async Task ReadableFieldsOfAsync(WriteScope write, ContentVersion version, CancellationToken cancellationToken) { var snapshot = ParseSnapshot(version); @@ -498,8 +587,7 @@ private async Task ReadableFieldsOfAsync(WriteScope write, ContentVe return fields; } - // What a restore may take from a version. The system principal takes all of it; anyone else only what it could send - // itself, so that an old version is no way past the limits of a write. + // Anyone but the system principal takes only what it could send itself, so that an old version is no way past the limits of a write. private async Task RestorableFieldsAsync(WriteScope write, ContentVersion version, WritableFields writable, CancellationToken cancellationToken) { var isSystem = write.Permissions.IsSystem; @@ -529,8 +617,7 @@ private async Task RestorableFieldsAsync(WriteScope write, Cont private static JsonObject ParseSnapshot(ContentVersion version) => JsonNode.Parse(version.Snapshot) as JsonObject ?? []; - // A read does not name these targets, and a version does not either. Fails closed on one since trashed, which only a - // draft read of the entry itself would show. References inside blocks and rich text stay as a draft read shows them. + // Fails closed on a target since trashed, which only a draft read of the entry itself would show. private async Task DropUnreadableTargetsAsync(WriteScope write, JsonObject fields, CancellationToken cancellationToken) { if (write.Permissions.IsSystem) @@ -538,51 +625,27 @@ private async Task DropUnreadableTargetsAsync(WriteScope write, JsonObject field return; } - foreach (var item in FieldStorage.Of(schema, write.Type).Where(item => item.Kind == FieldStorageKind.Reference)) + var nested = new NestedTargets(schema); + HashSet<(string Type, Guid Id)> readable = []; + foreach (var (name, ids) in nested.Collect(write.Type, fields)) { - if (schema.FindContentType(EntryPaths.ReferenceTarget(item.Field)) is not { } target) - { - continue; - } - - var names = QueryPaths.ApiPath(write.Type, item).Split('.'); - var holder = fields; - foreach (var name in names[..^1]) - { - holder = holder?[name] as JsonObject; - } - - if (holder is null) + if (schema.FindContentType(name) is not { } target || !write.Permissions.Allows(target, ContentOperation.Read, write.Target)) { continue; } - switch (holder[names[^1]]) + foreach (var id in ids) { - case JsonArray ids: - JsonArray kept = []; - foreach (var value in ids) - { - if (await CanReadTargetAsync(write, target, value, cancellationToken)) - { - kept.Add(value?.DeepClone()); - } - } - - holder[names[^1]] = kept; - break; - case { } single when !await CanReadTargetAsync(write, target, single, cancellationToken): - holder[names[^1]] = null; - break; + if (await evaluator.CanAccessEntryAsync(write.Operation, target, ContentOperation.Read, id, write.Target, QueryStatus.Draft, cancellationToken)) + { + readable.Add((name, id)); + } } } - } - private async Task CanReadTargetAsync(WriteScope write, SchemaType target, JsonNode? value, CancellationToken cancellationToken) => - EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) - && await evaluator.CanAccessEntryAsync(write.Operation, target, ContentOperation.Read, id, write.Target, QueryStatus.Draft, cancellationToken); + nested.Filter(write.Type, fields, (name, id) => readable.Contains((name, id))); + } - // A relation in the shape a read gives it, so that a version reads like the entry it was. private object? AsRead(SchemaField field, object? value) { var name = EntryPaths.ReferenceTarget(field); diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index ca0b01e18..0a8609bae 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -6,6 +6,7 @@ using FluentCMS.Core.Content.Query; using FluentCMS.Core.Content.Validation; using FluentCMS.Core.Localization; +using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Core.Storage; using FluentCMS.Core.Storage.Entities; using FluentCMS.Localization; @@ -554,7 +555,8 @@ private async Task SaveAsync(string typeName, Guid id, ContentD } // 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 needsRead = NeedsWhatARead(write, sent, isPatch); + var shown = needsRead ? await ReadShownAsync(write, id, documents, addsLocale, cancellationToken) : null; var refused = isPatch ? sent : WithoutUnchanged(write, sent, shown, writable, addsLocale); Reject(write, refused, writable); @@ -562,7 +564,7 @@ private async Task SaveAsync(string typeName, Guid id, ContentD ? 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); + composer.CarryProtected(type, stored, working, includingGroups: !isPatch || !write.Permissions.IsSystem, needsRead ? await UnseenNestedTargetsAsync(write, stored, shown, cancellationToken) : null); await KeepUnreadableTargetsAsync(write, stored, shown, working, isPatch, cancellationToken); working = await RunBeforeHooksAsync(write, HookKind.BeforeUpdate, working, new SaveContext(stored, Client(refused), writable, document), cancellationToken); @@ -637,7 +639,27 @@ private ContentDocument WithoutUnchanged(WriteScope write, ContentDocument chang 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]))); + && (!isPatch + || FieldStorage.Of(schema, write.Type).Any(item => item.Kind == FieldStorageKind.Reference && item.Field.Multiple && sent.ContainsKey(QueryPaths.ApiPath(write.Type, item).Split('.')[0])) + || sent.Keys.Any(key => write.Type.FindField(key)?.FieldType is FieldTypeNames.Blocks or FieldTypeNames.Component or FieldTypeNames.RichText)); + + // A nested target that the read left out and that still exists. One no longer there is nothing to keep. + private async Task> UnseenNestedTargetsAsync(WriteScope write, ContentDocument stored, ContentDocument? shown, CancellationToken cancellationToken) + { + var nested = new NestedTargets(schema); + var ids = nested.Collect(write.Type, stored).Values.SelectMany(found => found).ToHashSet(); + if (ids.Count == 0) + { + return ids; + } + + if (shown is not null) + { + ids.ExceptWith(nested.Collect(write.Type, shown).Values.SelectMany(found => found)); + } + + return ids.Count == 0 ? ids : await store.ExistingEntriesAsync([.. ids], cancellationToken); + } // 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. diff --git a/src/FluentCMS.Core/Content/EntryComposer.cs b/src/FluentCMS.Core/Content/EntryComposer.cs index b3ad58f49..62573d332 100644 --- a/src/FluentCMS.Core/Content/EntryComposer.cs +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -11,6 +11,8 @@ internal sealed class EntryComposer(SchemaModel schema) { private const string Discriminator = "type"; + private static readonly HashSet NoTargets = []; + // 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) @@ -116,8 +118,11 @@ private static JsonObject MergeJson(JsonNode? stored, JsonObject patch) // 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) + // unseen holds the nested targets a read left out: they stay too, and a rich text document keeps its links only when + // it comes back as it was read, since its nodes have no ids to pair an edit by. + public void CarryProtected(SchemaType type, ContentDocument stored, ContentDocument composed, bool includingGroups, IReadOnlySet? unseen = null) { + unseen ??= NoTargets; foreach (var field in type.Fields) { stored.TryGetValue(field.Name, out var before); @@ -126,21 +131,28 @@ public void CarryProtected(SchemaType type, ContentDocument stored, ContentDocum { if (includingGroups && before is ContentDocument oldGroup && ComponentOf(field) is { } component) { - CarryGroup(component, oldGroup, after as ContentDocument, group => composed[field.Name] = group); + CarryGroup(component, oldGroup, after as ContentDocument, group => composed[field.Name] = group, unseen); } } + else if (before is RichText oldText && after is RichText newText) + { + composed[field.Name] = CarryLinks(oldText, newText, unseen); + } else if (before is JsonNode oldNode && after is JsonNode newNode) { - CarryInside(field, oldNode, newNode); + CarryInside(field, oldNode, newNode, unseen); } } } + private static RichText CarryLinks(RichText before, RichText after, IReadOnlySet unseen) => + unseen.Count > 0 && JsonNode.DeepEquals(JsonNode.Parse(NestedTargets.DropLinks(before, (_, id) => !unseen.Contains(id)).Json), JsonNode.Parse(after.Json)) ? before : after; + private SchemaType? ComponentOf(SchemaField field) => field.Settings.TryGetValue(FieldSettings.Component, out var name) && name is string component ? schema.FindComponent(component) : null; // An absent component comes back only if it has something to carry. - private void CarryGroup(SchemaType component, ContentDocument before, ContentDocument? after, Action set) + private void CarryGroup(SchemaType component, ContentDocument before, ContentDocument? after, Action set, IReadOnlySet unseen) { var group = after ?? []; foreach (var field in component.Fields) @@ -151,13 +163,17 @@ private void CarryGroup(SchemaType component, ContentDocument before, ContentDoc { group[field.Name] = old; } + else if (old is RichText oldText && current is RichText newText) + { + group[field.Name] = CarryLinks(oldText, newText, unseen); + } else if (old is JsonNode oldNode && current is JsonNode newNode) { - CarryInside(field, oldNode, newNode); + CarryInside(field, oldNode, newNode, unseen); } 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); + CarryGroup(inner, oldGroup, current as ContentDocument, value => group[field.Name] = value, unseen); } } @@ -167,25 +183,25 @@ private void CarryGroup(SchemaType component, ContentDocument before, ContentDoc } } - private void CarryInside(SchemaField field, JsonNode before, JsonNode after) + private void CarryInside(SchemaField field, JsonNode before, JsonNode after, IReadOnlySet unseen) { if (before is JsonArray oldItems && after is JsonArray newItems) { - foreach (var (oldItem, newItem) in PairUp(field, [.. oldItems.OfType()], [.. newItems.OfType()])) + foreach (var (oldItem, newItem) in PairUp(field, [.. oldItems.OfType()], [.. newItems.OfType()], unseen)) { if (OwnerOf(field, oldItem) is { } owner) { - CarryIntoObject(owner, oldItem, newItem); + CarryIntoObject(owner, oldItem, newItem, unseen); } } } else if (before is JsonObject oldObject && after is JsonObject newObject && OwnerOf(field, oldObject) is { } single && OwnerOf(field, newObject) == single) { - CarryIntoObject(single, oldObject, newObject); + CarryIntoObject(single, oldObject, newObject, unseen); } } - private List<(JsonObject Old, JsonObject New)> PairUp(SchemaField field, List before, List after) + private List<(JsonObject Old, JsonObject New)> PairUp(SchemaField field, List before, List after, IReadOnlySet unseen) { List<(JsonObject Old, JsonObject New)> pairs = []; List oldLeft = [.. before]; @@ -193,7 +209,7 @@ private void CarryInside(SchemaField field, JsonNode before, JsonNode after) 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))) + ? oldLeft.FindIndex(old => OwnerOf(field, old) == owner && JsonNode.DeepEquals(Comparable(owner, old, unseen), Comparable(owner, item, unseen))) : -1; if (same < 0) @@ -222,7 +238,7 @@ private void CarryInside(SchemaField field, JsonNode before, JsonNode after) } // 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) + private JsonObject Comparable(SchemaType owner, JsonObject item, IReadOnlySet unseen) { JsonObject result = []; foreach (var (key, value) in item) @@ -232,8 +248,8 @@ private JsonObject Comparable(SchemaType owner, JsonObject item) continue; } - var inner = owner.FindField(key) is { } field ? ComparableValue(field, value) : value.DeepClone(); - if (inner is not JsonObject { Count: 0 }) + var inner = owner.FindField(key) is { } field ? ComparableValue(field, value, unseen) : value.DeepClone(); + if (inner is not (null or JsonObject { Count: 0 })) { result[key] = inner; } @@ -242,12 +258,30 @@ private JsonObject Comparable(SchemaType owner, JsonObject item) return result; } - private JsonNode ComparableValue(SchemaField field, JsonNode value) => value switch + private JsonNode? ComparableValue(SchemaField field, JsonNode value, IReadOnlySet unseen) { - 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(), - }; + if (field.Relation is not null && IsUnseen(value, unseen)) + { + return null; + } + + if (field.FieldType == FieldTypeNames.RichText) + { + var text = value.DeepClone(); + NestedTargets.DropLinks(text, (_, id) => !unseen.Contains(id)); + return text; + } + + return value switch + { + JsonObject single when OwnerOf(field, single) is { } owner => Comparable(owner, single, unseen), + JsonArray items => new JsonArray([.. items.Where(item => item is not null && (field.Relation is null || !IsUnseen(item, unseen))).Select(item => ComparableValue(field, item!, unseen))]), + _ => value.DeepClone(), + }; + } + + private static bool IsUnseen(JsonNode? value, IReadOnlySet unseen) => + value is not JsonArray && EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) && unseen.Contains(id); private SchemaType? OwnerOf(SchemaField field, JsonObject item) => field.FieldType switch { @@ -256,7 +290,7 @@ JsonObject single when OwnerOf(field, single) is { } owner => Comparable(owner, _ => null, }; - private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject after) + private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject after, IReadOnlySet unseen) { foreach (var field in owner.Fields) { @@ -272,14 +306,25 @@ private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject aft after[field.Name] = value.DeepClone(); } } + else if (field.Relation is not null) + { + CarryTargets(field, value, after, unseen); + } + else if (field.FieldType == FieldTypeNames.RichText) + { + if (after[field.Name] is { } text && JsonNode.DeepEquals(ComparableValue(field, value, unseen), ComparableValue(field, text, unseen))) + { + after[field.Name] = value.DeepClone(); + } + } else if (after[field.Name] is { } inner) { - CarryInside(field, value, inner); + CarryInside(field, value, inner, unseen); } else if (field.FieldType == FieldTypeNames.Component && !field.Multiple && value is JsonObject nested && ComponentOf(field) is { } component) { JsonObject kept = []; - CarryIntoObject(component, nested, kept); + CarryIntoObject(component, nested, kept, unseen); if (kept.Count > 0) { after[field.Name] = kept; @@ -287,4 +332,32 @@ private void CarryIntoObject(SchemaType owner, JsonObject before, JsonObject aft } } } + + // Each goes back where it was in a list, which leaves a list sent as it was read unchanged. + private static void CarryTargets(SchemaField field, JsonNode before, JsonObject after, IReadOnlySet unseen) + { + if (before is not JsonArray oldTargets) + { + if (after[field.Name] is null && IsUnseen(before, unseen)) + { + after[field.Name] = before.DeepClone(); + } + + return; + } + + var targets = after[field.Name] as JsonArray ?? []; + for (var index = 0; index < oldTargets.Count; index++) + { + if (IsUnseen(oldTargets[index], unseen)) + { + targets.Insert(Math.Min(index, targets.Count), oldTargets[index]?.DeepClone()); + } + } + + if (targets.Count > 0) + { + after[field.Name] = targets; + } + } } diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index 65965e109..031bc4988 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -199,7 +199,7 @@ public Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocument, C Locale = row.Locale, Number = row.DraftVersion, Snapshot = EntryJson.Text(EntryJson.Fields(type, entry)), - StatusAfter = row.Status, + StatusAfter = row.Status == EntryStatus.Published ? EntryStatus.Modified : row.Status, AuthorId = row.UpdatedBy, Origin = origin, ClientId = clientId, @@ -292,12 +292,13 @@ await context.Versions return true; } - public async Task MarkUnpublishedAsync(Document row, DateTimeOffset at, Guid by, CancellationToken cancellationToken) + public async Task MarkUnpublishedAsync(Document row, DateTimeOffset at, Guid by, CancellationToken cancellationToken) { var id = row.DocumentId; var locale = row.Locale; - await context.Documents - .Where(document => document.DocumentId == id && document.Locale == locale) + var version = row.PublishedVersion; + var affected = await context.Documents + .Where(document => document.DocumentId == id && document.Locale == locale && document.PublishedVersion == version) .ExecuteUpdateAsync( set => set .SetProperty(document => document.Status, EntryStatus.Unpublished) @@ -307,11 +308,17 @@ await context.Documents .SetProperty(document => document.UpdatedBy, by), cancellationToken); + if (affected != 1) + { + return false; + } + row.Status = EntryStatus.Unpublished; row.PublishedVersion = null; row.PublishedAt = null; row.UpdatedAt = at; row.UpdatedBy = by; + return true; } public Task SetStatusAsync(Guid id, string locale, EntryStatus status, CancellationToken cancellationToken) => @@ -332,9 +339,9 @@ public async Task> VersionsAsync(Guid id, string locale, Ca context.Versions.AsNoTracking() .FirstOrDefaultAsync(version => version.DocumentId == id && version.Locale == locale && version.Number == number, cancellationToken); - // Whether a target is there and published in one of these locales. The query filter on Document leaves out the trash, - // and a target of another type or another site is not there either. - public async Task TargetStateAsync(SchemaType target, Guid id, SiteId site, IReadOnlyList locales, CancellationToken cancellationToken) + // The rows of a target, one per locale. The query filter on Document leaves out the trash, and a target of another type + // or another site has none. + public async Task> TargetRowsAsync(SchemaType target, Guid id, SiteId site, CancellationToken cancellationToken) { var typeName = target.Name; var siteId = (Guid?)site.Value; @@ -344,17 +351,16 @@ public async Task TargetStateAsync(SchemaType target, Guid rows = rows.Where(document => document.SiteId == siteId); } - var found = await rows.Select(document => new { document.Locale, document.PublishedVersion }).ToListAsync(cancellationToken); - if (found.Count == 0) - { - return ReferenceTargetState.Unavailable; - } - - return found.Any(row => row.PublishedVersion is not null && (!target.Localized || locales.Contains(row.Locale, StringComparer.OrdinalIgnoreCase))) - ? ReferenceTargetState.Published - : ReferenceTargetState.Unpublished; + return await rows.Select(document => new ReferenceTargetRow(document.Locale, document.PublishedVersion != null)).ToListAsync(cancellationToken); } + // Which of these ids are entries, in the trash too. + public async Task> ExistingEntriesAsync(List ids, CancellationToken cancellationToken) => + [.. await context.Documents.IgnoreQueryFilters().AsNoTracking() + .Where(document => ids.Contains(document.DocumentId)) + .Select(document => document.DocumentId) + .ToListAsync(cancellationToken)]; + // What an entry refers to, with whether the reference is outside what hidden fields hide. Media has no row here to check. public List TargetsOf(SchemaType type, ContentDocument entry) { @@ -769,5 +775,7 @@ internal enum ReferenceTargetState Published, } +internal sealed record ReferenceTargetRow(string Locale, bool IsPublished); + // One reference of an entry. Path is the stored field, Field the top-level field that holds it. internal sealed record EntryTarget(string Path, string Field, string TargetType, Guid Id, bool IsShown); diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs index 9ac43521c..6784e1192 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs @@ -50,6 +50,7 @@ public async Task ExecuteAsync(string typeName, ContentQuery var entries = rows.Select(row => Materialize(compiled.Plan, row, new ContentDocument(), parent: null)).ToList(); await FillReferencesAsync(compiled.Scope, compiled.Plan, entries, cancellationToken); + await FilterNestedTargetsAsync(compiled.Scope, compiled.Type, entries, cancellationToken); var last = hasMore ? rows[^1] : null; var nextCursor = last is null ? null : SortKeys.Encode(compiled.Keys, compiled.KeyColumns.Select(column => last[column]).ToList()); @@ -374,6 +375,40 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List reference.Field == output.ApiPath).Select(reference => reference.TargetId).Distinct().ToList(); + return await ReadableIdsAsync(query, target, ids, cancellationToken); + } + + private async Task FilterNestedTargetsAsync(QueryScope query, SchemaType type, List entries, CancellationToken cancellationToken) + { + var nested = new NestedTargets(schema); + Dictionary?> readable = new(StringComparer.Ordinal); + foreach (var entry in entries) + { + foreach (var (name, ids) in nested.Collect(type, entry.Document)) + { + readable[name] = readable.TryGetValue(name, out var known) ? [.. known ?? [], .. ids] : ids; + } + } + + foreach (var (name, ids) in readable.ToList()) + { + readable[name] = schema.FindContentType(name) is { } target + ? await ReadableIdsAsync(query, target, [.. ids!], cancellationToken) + : []; + } + + if (readable.Count > 0) + { + foreach (var entry in entries) + { + nested.Filter(type, entry.Document, (name, id) => readable[name]?.Contains(id) != false); + } + } + } + + private async Task?> ReadableIdsAsync(QueryScope query, SchemaType target, List ids, CancellationToken cancellationToken) + { if (!query.Access.CanReadType(target)) { return []; @@ -387,14 +422,14 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List reference.Field == output.ApiPath).Select(reference => reference.TargetId).Distinct().ToList(); if (!hasRule) { // In a draft a trashed target stays a reference, so this looks past the query filter. var documents = isPublished ? context.Documents.AsNoTracking().Where(document => document.PublishedVersion != null) : context.Documents.IgnoreQueryFilters().AsNoTracking(); - documents = documents.Where(document => ids.Contains(document.DocumentId)); + var typeName = target.Name; + documents = documents.Where(document => ids.Contains(document.DocumentId) && document.Type == typeName); if (inOneSite) { var site = query.Site!.Value.Value; diff --git a/src/FluentCMS.Core/Content/Query/HiddenFields.cs b/src/FluentCMS.Core/Content/Query/HiddenFields.cs index bdbcff27e..ecd38e49b 100644 --- a/src/FluentCMS.Core/Content/Query/HiddenFields.cs +++ b/src/FluentCMS.Core/Content/Query/HiddenFields.cs @@ -22,15 +22,16 @@ private static void Remove(SchemaModel schema, JsonNode? value, SchemaField fiel switch (field.FieldType) { case FieldTypeNames.Blocks when value is JsonArray blocks: - foreach (var block in blocks.OfType()) + foreach (var block in blocks.OfType().ToList()) { - if (block[Discriminator] is JsonValue name && name.TryGetValue(out var blockName) && schema.FindBlock(blockName) is { } type) + var known = block[Discriminator] is JsonValue name && name.TryGetValue(out var blockName) ? schema.FindBlock(blockName) : null; + if (dropUnknown && (known is null || !SettingValues.List(field, FieldSettings.Blocks).Contains(known.Name))) { - Remove(schema, block, type, dropUnknown, remove); + blocks.Remove(block); } - else if (dropUnknown) + else if (known is not null) { - block.Clear(); + Remove(schema, block, known, dropUnknown, remove); } } diff --git a/src/FluentCMS.Core/Content/Query/NestedTargets.cs b/src/FluentCMS.Core/Content/Query/NestedTargets.cs new file mode 100644 index 000000000..355abd091 --- /dev/null +++ b/src/FluentCMS.Core/Content/Query/NestedTargets.cs @@ -0,0 +1,194 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.ProseMirror; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Query; + +// The relations inside blocks and repeated components and the entry links inside rich text. They have no output of their +// own in a query, so the rules on a top-level relation reach them through here. A rich text link keeps its text. +internal sealed class NestedTargets(SchemaModel schema) +{ + private const string Discriminator = "type"; + + public void Filter(SchemaType owner, ContentDocument entry, Func keep) + { + foreach (var field in owner.Fields.Where(field => !field.Hidden)) + { + if (entry.TryGetValue(field.Name, out var value) && value is not null) + { + entry[field.Name] = FilterValue(field, value, keep); + } + } + } + + public void Filter(SchemaType owner, JsonObject item, Func keep) + { + foreach (var field in owner.Fields.Where(field => !field.Hidden)) + { + if (item[field.Name] is { } value) + { + FilterProperty(item, field, value, keep); + } + } + } + + public Dictionary> Collect(SchemaType owner, ContentDocument entry) + { + Dictionary> found = new(StringComparer.Ordinal); + Filter(owner, entry, Recording(found)); + return found; + } + + public Dictionary> Collect(SchemaType owner, JsonObject item) + { + Dictionary> found = new(StringComparer.Ordinal); + Filter(owner, item, Recording(found)); + return found; + } + + public static bool DropLinks(JsonNode? node, Func keep) + { + if (node is not JsonObject item) + { + return false; + } + + var changed = false; + if (item["marks"] is JsonArray marks) + { + foreach (var mark in marks.OfType().Where(mark => IsUnwantedLink(mark, keep)).ToList()) + { + marks.Remove(mark); + changed = true; + } + + if (changed && marks.Count == 0) + { + item.Remove("marks"); + } + } + + if (item["content"] is JsonArray content) + { + foreach (var child in content) + { + changed |= DropLinks(child, keep); + } + } + + return changed; + } + + public static RichText DropLinks(RichText document, Func keep) + { + if (!document.Json.Contains(RichTextMarkTypes.EntryLink, StringComparison.Ordinal)) + { + return document; + } + + var node = JsonNode.Parse(document.Json); + return DropLinks(node, keep) ? new RichText(EntryJson.Text(node!)) : document; + } + + private static Func Recording(Dictionary> found) => (type, id) => + { + if (!found.TryGetValue(type, out var ids)) + { + ids = []; + found[type] = ids; + } + + ids.Add(id); + return true; + }; + + private static bool IsUnwantedLink(JsonObject mark, Func keep) => + mark[Discriminator] is JsonValue kind && kind.TryGetValue(out var name) && name == RichTextMarkTypes.EntryLink + && mark["attrs"] is JsonObject attributes + && attributes["entryType"] is JsonValue type && type.TryGetValue(out var typeName) + && attributes["entryId"] is JsonValue target && target.TryGetValue(out var text) + && Guid.TryParse(text, out var id) + && !keep(typeName, id); + + private object? FilterValue(SchemaField field, object value, Func keep) + { + switch (value) + { + case RichText document when field.FieldType == FieldTypeNames.RichText: + return DropLinks(document, keep); + case JsonNode node: + FilterItems(field, node, keep); + break; + case ContentDocument group when field.FieldType == FieldTypeNames.Component && Component(field) is { } component: + Filter(component, group, keep); + break; + case ContentDocument populated when RelationTarget(field) is { } target: + Filter(target, populated, keep); + break; + case IEnumerable populated when RelationTarget(field) is { } target: + foreach (var item in populated) + { + Filter(target, item, keep); + } + + break; + } + + return value; + } + + private void FilterProperty(JsonObject holder, SchemaField field, JsonNode value, Func keep) + { + if (field.Relation is { } relation) + { + if (value is JsonArray ids) + { + foreach (var id in ids.Where(id => !IsKept(relation.Target, id, keep)).ToList()) + { + ids.Remove(id); + } + } + else if (!IsKept(relation.Target, value, keep)) + { + holder[field.Name] = null; + } + } + else if (field.FieldType == FieldTypeNames.RichText) + { + DropLinks(value, keep); + } + else + { + FilterItems(field, value, keep); + } + } + + private void FilterItems(SchemaField field, JsonNode node, Func keep) + { + foreach (var item in node is JsonArray items ? items.OfType() : node is JsonObject single ? [single] : []) + { + var owner = field.FieldType switch + { + FieldTypeNames.Component => Component(field), + FieldTypeNames.Blocks when item[Discriminator] is JsonValue name && name.TryGetValue(out var block) => schema.FindBlock(block), + _ => null, + }; + + if (owner is not null) + { + Filter(owner, item, keep); + } + } + } + + private static bool IsKept(string target, JsonNode? value, Func keep) => + !EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) || keep(target, id); + + private SchemaType? Component(SchemaField field) => + field.Settings.TryGetValue(FieldSettings.Component, out var name) && name is string component ? schema.FindComponent(component) : null; + + private SchemaType? RelationTarget(SchemaField field) => + field.Relation is { } relation ? schema.FindContentType(relation.Target) : null; +} diff --git a/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs b/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs index ddb398368..06bf0a41f 100644 --- a/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs +++ b/src/FluentCMS.Core/Storage/Entities/ContentVersion.cs @@ -17,7 +17,7 @@ public sealed class ContentVersion // JSON text. public string Snapshot { get; set; } = ""; - // The one thing that changes: Published once this version has been published, which is what keeps it from pruning. + // Published only once this version has been published, the one thing that changes it, and what keeps it from pruning. public EntryStatus StatusAfter { get; set; } public Guid AuthorId { get; set; } diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs index ac2512089..477b60992 100644 --- a/src/FluentCMS.Sdk/Content/IContentService.cs +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -108,6 +108,7 @@ public interface IContentService /// The entry with its system fields, without what the principal may not read. /// The principal may not unpublish the whole entry in the locale. /// The entry has no row in the locale, is in the trash, or the principal may not reach it. + /// Another write published or withdrew this locale first. /// The entry is not published in the locale. Task UnpublishAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default); @@ -130,7 +131,8 @@ public interface IContentService /// The locale, or for that of the operation. /// A token to cancel the work. /// The versions, without their values. - /// The principal may not read versions of the type in the locale. + /// The type does not exist or the principal may not read it in the locale; the two read the same. + /// The principal may read the type but does not manage content. /// The entry has no row in the locale, is in the trash, or the principal may not reach it. Task> GetVersionsAsync(string type, Guid id, string? locale = null, CancellationToken cancellationToken = default); @@ -141,7 +143,8 @@ public interface IContentService /// The locale, or for that of the operation. /// A token to cancel the work. /// The version, or when there is no such version. - /// The principal may not read versions of the type in the locale. + /// The type does not exist or the principal may not read it in the locale; the two read the same. + /// The principal may read the type but does not manage content. /// The entry has no row in the locale, is in the trash, or the principal may not reach it. Task GetVersionAsync(string type, Guid id, int number, string? locale = null, CancellationToken cancellationToken = default); @@ -170,7 +173,8 @@ public interface IContentService /// The locale, or for that of the operation. /// A token to cancel the work. /// The differences. - /// The principal may not read versions of the type in the locale. + /// The type does not exist or the principal may not read it in the locale; the two read the same. + /// The principal may read the type but does not manage content. /// The entry or a version does not exist in the locale, or the principal may not reach it. Task> DiffAsync(string type, Guid id, int fromVersion, int toVersion, string? locale = null, CancellationToken cancellationToken = default); diff --git a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs new file mode 100644 index 000000000..56d6f8cd3 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs @@ -0,0 +1,317 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Tests.Schema; +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; + +// Relations inside blocks and groups and entry links inside rich text, on read, on a version and on the way back in. +public sealed class NestedTargetTests : IDisposable +{ + private static readonly string[] Cards = ["card"]; + + private static SchemaModel Model() => SampleSchema.NewBuilder() + .AddComponent("box", component => component.Field("who", "relation", field => field.Relation("author")).Field("note", "richText")) + .AddBlock("card", block => block + .Field("who", "relation", field => field.Relation("author")) + .Field("many", "relation", field => field.Multiple().Relation("author")) + .Field("label", "text") + .Field("note", "richText") + .Field("box", "component", field => field.Setting(FieldSettings.Component, "box"))) + .AddCollection("vault", type => type.Field("name", "text")) + .AddCollection("author", type => type.Field("name", "text")) + .AddCollection("article", type => type + .Field("title", "text") + .Field("author", "relation", field => field.Relation("author")) + .Field("cards", "blocks", field => field.Setting(FieldSettings.Blocks, Cards)) + .Field("box", "component", field => field.Setting(FieldSettings.Component, "box")) + .Field("body", "richText")) + .Build(); + + private readonly ServiceDatabase _database = new(schema: Model()); + + public void Dispose() => _database.Dispose(); + + private static RichText Linked(string text, Guid target, string type = "author") => new( + "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"" + text + "\",\"marks\":[{\"type\":\"entryLink\",\"attrs\":{\"entryType\":\"" + type + "\",\"entryId\":\"" + target + "\"}}]}]}]}"); + + private static ContentDocument Card(Guid who, string label = "L") => new() { ["type"] = "card", ["who"] = who, ["label"] = label }; + + private async Task AuthorAsync(string state) + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, "author")); + if (state is "published" or "trashed") + { + await _database.PublishAsync(Editor, id, type: "author"); + } + + if (state == "trashed") + { + await _database.DeleteAsync(Editor, id, "author"); + } + + return id; + } + + private async Task ArticleAsync(ContentDocument values, bool publish = true) + { + var id = IdOf(await _database.CreateAsync(Editor, values, "article")); + if (publish) + { + await _database.PublishAsync(Editor, id, type: "article"); + } + + return id; + } + + private Task PublishedReadAsync(Principal principal, Guid id) => + _database.GetAsync(principal, id, new GetOptions { Status = QueryStatus.Published }, "article"); + + private static string Text(object? value) => JsonSerializer.Serialize(value); + + private static JsonObject FirstCard(ContentDocument entry) => (JsonObject)((JsonArray)entry["cards"]!)[0]!; + + private Principal Reader(params (string Permission, PermissionConstraints? Constraints)[] extra) => + WithRole(_database.Database.AddRoleForTest(Guid.NewGuid().ToString("N"), [("content.article.read", null), .. extra])); + + private Principal Writer(params (string Permission, PermissionConstraints? Constraints)[] extra) => + Reader([("content.article.update", null), .. extra]); + + [Fact] + public async Task AnIdOfAnotherTypeReadsAsNoTargetOnEveryRead() + { + var vault = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "v" }, "vault")); + await _database.PublishAsync(Editor, vault, type: "vault"); + var hidden = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "v" }, "vault")); + var principal = Reader(("content.author.read", null)); + + foreach (var target in new[] { vault, hidden, Guid.CreateVersion7() }) + { + var id = await ArticleAsync(new() { ["title"] = "x", ["author"] = target }); + + (await _database.GetAsync(principal, id, type: "article"))!["author"].ShouldBeNull(); + (await PublishedReadAsync(principal, id))!["author"].ShouldBeNull(); + (await _database.VersionAsync(principal, id, 1, type: "article"))!["author"].ShouldBeNull(); + } + } + + [Fact] + public async Task APublishedReadLeavesOutWhatIsNotPublishedAtEveryDepth() + { + var ok = await AuthorAsync("published"); + var draft = await AuthorAsync("draft"); + var trashed = await AuthorAsync("trashed"); + var id = await ArticleAsync(new() + { + ["title"] = "x", + ["cards"] = new object[] + { + new ContentDocument { ["type"] = "card", ["who"] = trashed, ["many"] = new[] { ok, draft, trashed }, ["note"] = Linked("in block", draft), ["box"] = new ContentDocument { ["who"] = draft } }, + Card(ok), + }, + ["box"] = new ContentDocument { ["note"] = Linked("in group", trashed) }, + ["body"] = Linked("in body", draft), + }); + + var read = (await PublishedReadAsync(Editor, id))!; + + var text = Text(read); + text.ShouldNotContain(draft.ToString()); + text.ShouldNotContain(trashed.ToString()); + text.ShouldContain("in block"); + text.ShouldContain("in group"); + text.ShouldContain("in body"); + + var card = FirstCard(read); + card["who"].ShouldBeNull(); + card["many"]!.AsArray().ShouldHaveSingleItem()!["id"]!.GetValue().ShouldBe(ok.ToString()); + card["box"]!["who"].ShouldBeNull(); + ((JsonArray)read["cards"]!)[1]!["who"]!["id"]!.GetValue().ShouldBe(ok.ToString()); + read["body"].ShouldBeOfType().Json.ShouldNotContain("entryLink"); + } + + [Fact] + public async Task ADraftReadKeepsATrashedTargetOfAReadableTypeLikeATopLevelRelation() + { + var trashed = await AuthorAsync("trashed"); + var id = await ArticleAsync(new() { ["title"] = "x", ["author"] = trashed, ["cards"] = new object[] { Card(trashed) }, ["body"] = Linked("t", trashed) }, publish: false); + + var read = (await _database.GetAsync(Editor, id, type: "article"))!; + + read["author"].ShouldNotBeNull(); + FirstCard(read)["who"]!["id"]!.GetValue().ShouldBe(trashed.ToString()); + read["body"].ShouldBeOfType().Json.ShouldContain(trashed.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ATargetOfATypeItMayNotReadIsNamedNowhere(bool published) + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() + { + ["title"] = "x", + ["author"] = author, + ["box"] = new ContentDocument { ["who"] = author, ["note"] = Linked("g", author) }, + ["cards"] = new object[] { Card(author) }, + ["body"] = Linked("keep me", author), + }); + var principal = Reader(); + + var read = published ? await PublishedReadAsync(principal, id) : await _database.GetAsync(principal, id, type: "article"); + var version = await _database.VersionAsync(principal, id, 1, type: "article"); + + foreach (var entry in new[] { read!, version! }) + { + var text = Text(entry); + text.ShouldNotContain(author.ToString()); + text.ShouldContain("keep me"); + } + } + + [Fact] + public async Task ATargetItsRowRuleRefusesIsNamedNowhereAndItsOwnIs() + { + var me = Guid.CreateVersion7(); + var principal = WithRole(_database.Database.AddRoleForTest( + "Own authors", + ("content.article.read", null), + ("content.author.create", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule })), me); + var theirs = await AuthorAsync("published"); + var mine = IdOf(await _database.CreateAsync(principal, new() { ["name"] = "Me" }, "author")); + await _database.PublishAsync(Editor, mine, type: "author"); + var id = await ArticleAsync(new() { ["title"] = "x", ["cards"] = new object[] { Card(theirs), Card(mine) }, ["body"] = Linked("t", theirs) }); + + var read = (await PublishedReadAsync(principal, id))!; + + var cards = (JsonArray)read["cards"]!; + cards[0]!["who"].ShouldBeNull(); + cards[1]!["who"]!["id"]!.GetValue().ShouldBe(mine.ToString()); + read["body"].ShouldBeOfType().Json.ShouldNotContain("entryLink"); + } + + [Fact] + public async Task ALinkToAnUnknownTypeIsDroppedLikeOneToAnUnreadableType() + { + var id = await ArticleAsync(new() { ["title"] = "x", ["body"] = Linked("t", Guid.CreateVersion7(), "nosuchtype") }); + + (await PublishedReadAsync(Editor, id))!["body"].ShouldBeOfType().Json.ShouldNotContain("entryLink"); + } + + [Fact] + public async Task AFullUpdateOfWhatAReadGaveKeepsTheTargetsItLeftOut() + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() + { + ["title"] = "One", + ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["who"] = author, ["many"] = new[] { author }, ["label"] = "L", ["note"] = Linked("n", author) } }, + ["box"] = new ContentDocument { ["note"] = Linked("g", author) }, + ["body"] = Linked("b", author), + }); + var writer = Writer(); + + var read = (await _database.GetAsync(writer, id, type: "article"))!; + Text(read).ShouldNotContain(author.ToString()); + read["title"] = "Two"; + var written = await _database.UpdateAsync(writer, id, read, 1, "article"); + + Text(written).ShouldNotContain(author.ToString()); + var snapshot = JsonNode.Parse(_database.Versions(id)[^1].Snapshot)!; + snapshot["title"]!.GetValue().ShouldBe("Two"); + var card = snapshot["cards"]![0]!; + card["who"]!["id"]!.GetValue().ShouldBe(author.ToString()); + card["many"]![0]!["id"]!.GetValue().ShouldBe(author.ToString()); + card["note"]!.ToJsonString().ShouldContain(author.ToString()); + snapshot["box"]!["note"]!.ToJsonString().ShouldContain(author.ToString()); + snapshot["body"]!.ToJsonString().ShouldContain(author.ToString()); + } + + [Fact] + public async Task APatchOfWhatAReadGaveKeepsThemToo() + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() { ["title"] = "One", ["cards"] = new object[] { Card(author) }, ["body"] = Linked("b", author) }); + var writer = Writer(); + + var read = (await _database.GetAsync(writer, id, type: "article"))!; + await _database.PatchAsync(writer, id, new() { ["cards"] = read["cards"], ["body"] = read["body"] }, 1, "article"); + + var snapshot = _database.Versions(id)[^1].Snapshot; + JsonNode.Parse(snapshot)!["cards"]![0]!["who"]!["id"]!.GetValue().ShouldBe(author.ToString()); + JsonNode.Parse(snapshot)!["body"]!.ToJsonString().ShouldContain(author.ToString()); + } + + [Fact] + public async Task AnEditedBlockKeepsItsTargetAndAnEditedDocumentLosesTheLink() + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() { ["title"] = "One", ["cards"] = new object[] { Card(author, "old") }, ["body"] = Linked("old", author) }); + var writer = Writer(); + + var edited = new RichText("""{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"new"}]}]}"""); + await _database.PatchAsync(writer, id, new() { ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["label"] = "new" } }, ["body"] = edited }, 1, "article"); + + var snapshot = JsonNode.Parse(_database.Versions(id)[^1].Snapshot)!; + snapshot["cards"]![0]!["label"]!.GetValue().ShouldBe("new"); + snapshot["cards"]![0]!["who"]!["id"]!.GetValue().ShouldBe(author.ToString()); + snapshot["body"]!.ToJsonString().ShouldNotContain(author.ToString()); + } + + [Fact] + public async Task ATargetItCanSeeCanStillBeCleared() + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() { ["title"] = "One", ["cards"] = new object[] { Card(author) } }); + var writer = Writer(("content.author.read", null)); + + await _database.PatchAsync(writer, id, new() { ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["label"] = "L" } } }, 1, "article"); + + JsonNode.Parse(_database.Versions(id)[^1].Snapshot)!["cards"]![0]!["who"].ShouldBeNull(); + } + + [Fact] + public async Task ARestoreDoesNotBringBackNorLoseANestedTargetItMayNotRead() + { + var old = await AuthorAsync("published"); + var current = await AuthorAsync("published"); + var id = await ArticleAsync(new() { ["title"] = "One", ["cards"] = new object[] { Card(old) } }, publish: false); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Two", ["cards"] = new object[] { Card(current) } }, 1, "article"); + + await _database.RestoreVersionAsync(Writer(), id, 1, type: "article"); + + var snapshot = JsonNode.Parse(_database.Versions(id)[^1].Snapshot)!; + snapshot["title"]!.GetValue().ShouldBe("One"); + snapshot["cards"]![0]!["who"]!["id"]!.GetValue().ShouldBe(current.ToString()); + } + + [Fact] + public async Task AVersionDropsOnlyTheBlockTheSchemaHasLost() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One" }, "article")); + await _database.PatchAsync(Editor, id, new() { ["title"] = "Two" }, 1, "article"); + var row = _database.Versions(id)[0]; + var snapshot = JsonNode.Parse(row.Snapshot)!.AsObject(); + snapshot["cards"] = new JsonArray(new JsonObject { ["type"] = "gone", ["x"] = "ghost" }, new JsonObject { ["type"] = "card", ["label"] = "kept", ["extra"] = "ghost" }); + var text = snapshot.ToJsonString(); + _database.Read(context => context.Versions.Where(version => version.Id == row.Id).ExecuteUpdate(set => set.SetProperty(version => version.Snapshot, text))); + + var version = (await _database.VersionAsync(Editor, id, 1, type: "article"))!; + var card = ((JsonArray)version["cards"]!).ShouldHaveSingleItem()!; + card["label"]!.GetValue().ShouldBe("kept"); + Text(version).ShouldNotContain("ghost"); + + var restored = await _database.RestoreVersionAsync(Editor, id, 1, type: "article"); + ((JsonArray)restored["cards"]!).ShouldHaveSingleItem()!["label"]!.GetValue().ShouldBe("kept"); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs index b4a6c2352..d94fb6393 100644 --- a/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs @@ -129,7 +129,7 @@ public async Task ATokenThatOnlyReadsCannotReadVersionsEvenWithAPreviewKey() await Should.ThrowAsync(() => _database.DiffAsync(token, id, 1, 2)); } - await Should.ThrowAsync(() => _database.VersionsAsync(Principal.Anonymous, id)); + (await Should.ThrowAsync(() => _database.VersionsAsync(Principal.Anonymous, id))).Errors.ShouldHaveSingleItem().Path.ShouldBe("type"); } [Fact] @@ -160,7 +160,7 @@ public async Task EachTransitionNeedsItsOwnPermission() var publisher = Limited(("content.article.publish", null), ("content.article.unpublish", null), ("content.article.restore", null)); await Should.ThrowAsync(() => _database.DiscardAsync(publisher, id)); await Should.ThrowAsync(() => _database.RestoreVersionAsync(publisher, id, 1)); - await Should.ThrowAsync(() => _database.VersionsAsync(publisher, id)); + await Should.ThrowAsync(() => _database.VersionsAsync(publisher, id)); _database.Trace(id).ShouldBe(before); @@ -209,6 +209,103 @@ private sealed class Regional : ContentItem public string? Region { get; set; } } + private sealed class MoveToUs : IBeforePublishHook + { + public ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + context.Entry["region"] = "us"; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task APublishHookThatTakesTheEntryOutOfTheRowRuleIsRolledBack() + { + using var database = new ServiceDatabase(cms => + { + cms.Authorization.AddRowRule("region", ctx => article => article.Region == ctx.Principal.Claim("region")); + cms.Services.AddSingleton, MoveToUs>(); + }); + var inRegion = new PermissionConstraints { RowRule = "region" }; + var role = database.Database.AddRoleForTest("Regional", ("content.article.read", inRegion), ("content.article.publish", inRegion)); + var eu = AuthorizationDatabase.User([role], [TokenScopes.Management], claims: new() { ["region"] = "eu" }); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "One", ["region"] = "eu" })); + var before = database.Trace(id); + + await Should.ThrowAsync(() => database.PublishAsync(eu, id)); + + database.Trace(id).ShouldBe(before); + } + + private sealed class SaveAnotherLocale : IBeforePublishHook + { + private readonly IServiceProvider _services; + + public SaveAnotherLocale(IServiceProvider services) => _services = services; + + public async ValueTask BeforePublishAsync(HookContext context, CancellationToken cancellationToken) + { + var accessor = _services.GetRequiredService(); + var operation = accessor.Current!; + accessor.Current = operation with { Locale = "fr" }; + try + { + await _services.GetRequiredService().PatchAsync("article", (Guid)context.Entry["id"]!, new() { ["views"] = 7 }, 1, cancellationToken); + } + finally + { + accessor.Current = operation; + } + } + } + + [Fact] + public async Task AHookThatSavesTheDraftDuringAPublishIsRefusedSoNothingGoesOutUnvalidated() + { + using var database = new ServiceDatabase(cms => cms.Services.AddScoped, SaveAnotherLocale>()); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "One", ["views"] = 1 })); + await database.PatchAsync(Editor, id, Titled("Un"), 0, locale: "fr"); + var before = database.Trace(id); + + await Should.ThrowAsync(() => database.PublishAsync(Editor, id)); + + database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AnUnpublishThatLostTheSwapChangesNothing() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); + await _database.PublishAsync(Editor, id); + var before = _database.Trace(id); + + var stale = _database.Document(id); + stale.PublishedVersion = 99; + await _database.AsAsync(Editor, async services => + { + (await services.GetRequiredService().MarkUnpublishedAsync(stale, DateTimeOffset.UtcNow, Editor.Id, Token)).ShouldBeFalse(); + return true; + }); + + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task AnUnknownTypeAndAnUnreadableOneReadTheSameOnAVersion() + { + var schema = SampleSchema.NewBuilder().AddCollection("vault", type => type.Field("name", "text")).AddCollection("page", type => type.Field("name", "text")).Build(); + using var database = new ServiceDatabase(schema: schema); + var principal = WithRole(database.Database.AddRoleForTest("Pages", ("content.page.read", null))); + + var unreadable = await Should.ThrowAsync(() => database.VersionsAsync(principal, Guid.CreateVersion7(), type: "vault")); + var unknown = await Should.ThrowAsync(() => database.VersionsAsync(principal, Guid.CreateVersion7(), type: "nosuch")); + var read = await Should.ThrowAsync(() => database.GetAsync(principal, Guid.CreateVersion7(), type: "vault")); + + unreadable.Errors.ShouldHaveSingleItem().Code.ShouldBe(unknown.Errors.ShouldHaveSingleItem().Code); + unreadable.Errors[0].Code.ShouldBe(read.Errors.ShouldHaveSingleItem().Code); + unreadable.Errors[0].Path.ShouldBe("type"); + } + [Fact] public async Task ARestoreOrDiscardThatTakesTheEntryOutOfTheRowRuleIsRolledBack() { @@ -251,9 +348,9 @@ public async Task ALocaleLimitHoldsOnEveryTransitionWhicheverWayTheLocaleIsNamed } await Should.ThrowAsync(() => _database.RestoreVersionAsync(translator, id, 1, "en")); - await Should.ThrowAsync(() => _database.VersionsAsync(translator, id, "en")); - await Should.ThrowAsync(() => _database.VersionAsync(translator, id, 1, "en")); - await Should.ThrowAsync(() => _database.DiffAsync(translator, id, 1, 2, "en")); + await Should.ThrowAsync(() => _database.VersionsAsync(translator, id, "en")); + await Should.ThrowAsync(() => _database.VersionAsync(translator, id, 1, "en")); + await Should.ThrowAsync(() => _database.DiffAsync(translator, id, 1, 2, "en")); await Should.ThrowAsync(() => _database.PublishAsync(translator, id, "all")); _database.Trace(id).ShouldBe(before); @@ -309,6 +406,31 @@ public async Task AGrantLimitedToSomeFieldsPublishesOnlyWhatStaysWithinThem() _database.Trace(id).ShouldBe(before); } + [Fact] + public async Task AFieldItCanWriteButNotReadCannotBeGuessedThroughAPublish() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["code"] = "SECRET" })); + await _database.PublishAsync(Editor, id); + var guesser = Limited( + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.update", new PermissionConstraints { Fields = ["title", "code"] }), + ("content.article.publish", new PermissionConstraints { Fields = ["title"] })); + + var version = 1; + foreach (var guess in new[] { "WRONG", "SECRET" }) + { + version = VersionOf(await _database.PatchAsync(guesser, id, new() { ["code"] = guess }, version)); + await Should.ThrowAsync(() => _database.PublishAsync(guesser, id)); + } + + // A field it cannot write is not its guess, so the comparison stands. + await _database.PatchAsync(Editor, id, new() { ["code"] = "SECRET", ["title"] = "Two" }, version); + var titles = Limited( + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.publish", new PermissionConstraints { Fields = ["title", "slug", "category"] })); + await Should.NotThrowAsync(() => _database.PublishAsync(titles, id)); + } + [Fact] public async Task AGrantLimitedToSomeFieldsCannotWithdrawTheWholeEntry() { @@ -338,6 +460,29 @@ public async Task DiscardChangesOnlyWhatThePrincipalMayWrite() _database.Document(id).Status.ShouldBe(EntryStatus.Modified); } + [Fact] + public async Task APartialDiscardLeavesHiddenValuesAtEveryDepth() + { + var id = IdOf(await _database.AsAsync(Principal.System, services => services.GetRequiredService().CreateAsync("article", Secretive("One", "published-top", heading: "H1", inner: "published-block"), Token), origin: Origin.Seed)); + await _database.AsAsync(Principal.System, services => services.GetRequiredService().PublishAsync("article", id, null, null, Token), origin: Origin.Seed); + var draft = Secretive("Two", "draft-top", heading: "H2", inner: "draft-block"); + draft["seo"] = new ContentDocument { ["metaTitle"] = "Meta2", ["internal"] = "draft-group" }; + await _database.AsAsync(Principal.System, services => services.GetRequiredService().UpdateAsync("article", id, draft, 1, Token), origin: Origin.Seed); + var fields = new PermissionConstraints { Fields = ["sections", "seo"] }; + var limited = Limited(("content.article.read", null), ("content.article.update", fields)); + + var entry = await _database.DiscardAsync(limited, id); + + entry["status"].ShouldBe(EntryStatus.Modified); + var snapshot = System.Text.Json.Nodes.JsonNode.Parse(_database.Versions(id)[^1].Snapshot)!; + snapshot["sections"]![0]!["heading"]!.GetValue().ShouldBe("H1"); + snapshot["sections"]![0]!["internal"]!.GetValue().ShouldBe("draft-block"); + snapshot["seo"]!["metaTitle"]!.GetValue().ShouldBe("Meta"); + snapshot["seo"]!["internal"]!.GetValue().ShouldBe("draft-group"); + snapshot["secret"]!.GetValue().ShouldBe("draft-top"); + snapshot["title"]!.GetValue().ShouldBe("Two"); + } + [Fact] public async Task DiscardByAGrantLimitedToALocaleLeavesTheSharedFields() { @@ -461,7 +606,7 @@ public async Task AWriterWhoCannotReadGetsOnlyTheIdAndVersionFromARestoreAndADis (await _database.RestoreVersionAsync(writer, id, 1)).Keys.ShouldBe(["id", "version"]); await _database.PatchAsync(Editor, id, Titled("Three"), 3); (await _database.DiscardAsync(writer, id)).Keys.ShouldBe(["id", "version"]); - await Should.ThrowAsync(() => _database.VersionAsync(writer, id, 1)); + await Should.ThrowAsync(() => _database.VersionAsync(writer, id, 1)); } [Fact] diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs b/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs index 82d014301..f0997f067 100644 --- a/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/PublishingTransitionTests.cs @@ -192,6 +192,45 @@ public async Task DiscardingADraftThatEqualsThePublishedStateWritesNothing() _sink.Changes.ShouldBeEmpty(); } + [Fact] + public async Task DiscardingADraftThatWasEditedBackPutsTheStatusRightAndWritesNoVersion() + { + var id = await PublishedAsync("One"); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + await _database.PatchAsync(Editor, id, Titled("One"), 2); + _database.Document(id).Status.ShouldBe(EntryStatus.Modified); + + var entry = await _database.DiscardAsync(Editor, id); + + entry["status"].ShouldBe(EntryStatus.Published); + entry["version"].ShouldBe(3); + _database.Document(id).Status.ShouldBe(EntryStatus.Published); + _database.Versions(id).Count.ShouldBe(3); + } + + [Fact] + public async Task OnlyPublishingMarksAVersionPublishedSoDiscardsCannotPassTheCap() + { + var schema = SampleSchema.NewBuilder().AddCollection("note", type => type.PublishPolicy(maxVersions: 2).Field("title", "text")).Build(); + using var database = new ServiceDatabase(schema: schema); + var id = IdOf(await database.CreateAsync(Editor, Titled("published"), "note")); + await database.PublishAsync(Editor, id, type: "note"); + + var version = 1; + for (var round = 0; round < 10; round++) + { + version = VersionOf(await database.PatchAsync(Editor, id, Titled($"edit {round}"), version, "note")); + var discarded = await database.DiscardAsync(Editor, id, type: "note"); + discarded["status"].ShouldBe(EntryStatus.Published); + version = VersionOf(discarded); + } + + var versions = await database.VersionsAsync(Editor, id, type: "note"); + versions.Count.ShouldBe(2); + versions.Count(item => item.Status == EntryStatus.Published).ShouldBe(1); + versions.Single(item => item.Status == EntryStatus.Published).Number.ShouldBe(1); + } + [Fact] public async Task RestoringAVersionMakesANewDraftAndLeavesThePublishedRows() { diff --git a/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs index 0c7ce2065..2b0cd460e 100644 --- a/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs @@ -222,6 +222,159 @@ public async Task ATargetItsRowRuleRefusesReadsTheSameAsOneThatIsNotThere() first.TargetId.ShouldBeNull(); } + // A write-only field is where it chooses the id: the answer must not depend on the target at all. + [Fact] + public async Task UnderBlockAFieldItCanWriteButNotReadIsAlwaysTheSameRefusal() + { + var fields = new PermissionConstraints { Fields = ["title", "author"] }; + var publisher = WithRole(_blocking.Database.AddRoleForTest( + "Write only", + ("content.article.create", fields), + ("content.article.update", fields), + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.publish", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }))); + List answers = []; + + foreach (var target in new[] { await PublishedAuthorAsync(_blocking), await AuthorAsync(_blocking), await TrashedAuthorAsync(_blocking), Guid.CreateVersion7() }) + { + var id = IdOf(await _blocking.CreateAsync(publisher, new() { ["title"] = "One", ["author"] = target })); + var error = (await Should.ThrowAsync(() => _blocking.PublishAsync(publisher, id))).Errors.ShouldHaveSingleItem(); + error.Path.ShouldBe(""); + answers.Add($"{error.Code}|{error.Message}"); + } + + answers.Distinct().ShouldHaveSingleItem(); + } + + [Fact] + public async Task UnderBlockAFieldItCanNeitherReadNorWriteIsDecidedOnWhatIsTrue() + { + var published = await PublishedAuthorAsync(_blocking); + var draft = await AuthorAsync(_blocking); + var publisher = WithRole(_blocking.Database.AddRoleForTest( + "Publisher", + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.publish", null))); + + var good = await ArticleAsync(_blocking, new() { ["title"] = "One", ["author"] = published }); + (await _blocking.PublishAsync(publisher, good)).Warnings.ShouldBeEmpty(); + + var bad = await ArticleAsync(_blocking, new() { ["title"] = "Two", ["author"] = draft }); + (await Should.ThrowAsync(() => _blocking.PublishAsync(publisher, bad))).Errors.ShouldHaveSingleItem().Path.ShouldBe(""); + } + + [Theory] + [InlineData(UnpublishedReferencePolicy.Warn)] + [InlineData(UnpublishedReferencePolicy.Block)] + public async Task ATargetOnlyInALocaleItsGrantDoesNotCoverReadsTheSameAsOneThatIsNotThere(UnpublishedReferencePolicy policy) + { + var database = policy == UnpublishedReferencePolicy.Warn ? _warning : _blocking; + var publisher = WithRole(database.Database.AddRoleForTest( + "English", + ("content.article.read", null), + ("content.article.publish", null), + ("content.author.read", new PermissionConstraints { Locales = ["en"] }))); + List answers = []; + + var french = await AuthorAsync(database, "fr"); + var publishedFrench = await AuthorAsync(database, "fr"); + await database.PublishAsync(Editor, publishedFrench, "fr", type: "author", operationLocale: "fr"); + foreach (var target in new[] { french, publishedFrench, Guid.CreateVersion7() }) + { + var id = await ArticleAsync(database, new() { ["title"] = "One", ["author"] = target }); + if (policy == UnpublishedReferencePolicy.Warn) + { + var warning = (await database.PublishAsync(publisher, id)).Warnings.ShouldHaveSingleItem(); + warning.TargetId.ShouldBeNull(); + answers.Add($"{warning.Path}|{warning.Code}|{warning.Message}"); + } + else + { + var error = (await Should.ThrowAsync(() => database.PublishAsync(publisher, id))).Errors.ShouldHaveSingleItem(); + answers.Add($"{error.Path}|{error.Code}|{error.Message}"); + } + } + + answers.Distinct().ShouldHaveSingleItem(); + } + + [Theory] + [InlineData(UnpublishedReferencePolicy.Warn)] + [InlineData(UnpublishedReferencePolicy.Block)] + public async Task TheRuleOnATargetIsAskedOfTheLocaleThatServesIt(UnpublishedReferencePolicy policy) + { + var database = policy == UnpublishedReferencePolicy.Warn ? _warning : _blocking; + var publisher = WithRole(database.Database.AddRoleForTest( + "Own authors", + ("content.article.read", null), + ("content.article.publish", null), + ("content.author.create", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }))); + var mine = IdOf(await database.CreateAsync(publisher, new() { ["name"] = "Ann" }, "author", "fr")); + await database.PublishAsync(Editor, mine, "fr", type: "author", operationLocale: "fr"); + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "x", ["author"] = mine }, locale: "fr-CA")); + + (await database.PublishAsync(publisher, id, "fr-CA", operationLocale: "fr-CA")).Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task AnUnknownTypeInALinkIsTheSameAsOneItMayNotRead() + { + foreach (var (database, blocks) in new[] { (_warning, false), (_blocking, true) }) + { + var publisher = WithRole(database.Database.AddRoleForTest( + "Articles", + ("content.article.read", null), + ("content.article.publish", null), + ("content.author.read", null))); + List answers = []; + + foreach (var type in new[] { "vault", "nosuchtype" }) + { + var body = new RichText("{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"x\",\"marks\":[{\"type\":\"entryLink\",\"attrs\":{\"entryType\":\"" + type + "\",\"entryId\":\"" + Guid.CreateVersion7() + "\"}}]}]}]}"); + var id = await ArticleAsync(database, new() { ["title"] = type, ["body"] = body }); + if (blocks) + { + var error = (await Should.ThrowAsync(() => database.PublishAsync(publisher, id))).Errors.ShouldHaveSingleItem(); + answers.Add($"{error.Path}|{error.Code}|{error.Message}"); + } + else + { + (await database.PublishAsync(publisher, id)).Warnings.ShouldBeEmpty(); + answers.Add("none"); + } + } + + answers.Distinct().ShouldHaveSingleItem(); + } + } + + [Fact] + public async Task ASelfReferenceIsNoProblemAndACycleStaysBlockedUntilOneSideIsSetAfterwards() + { + var schema = SampleSchema.NewBuilder() + .AddCollection("page", type => type.PublishPolicy(UnpublishedReferencePolicy.Block).Field("title", "text").Field("next", "relation", field => field.Relation("page"))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + + var self = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "a" }, "page")); + await database.PatchAsync(Editor, self, new() { ["next"] = self }, 1, "page"); + (await database.PublishAsync(Editor, self, type: "page")).Warnings.ShouldBeEmpty(); + + var first = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "b" }, "page")); + var second = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "c", ["next"] = first }, "page")); + await database.PatchAsync(Editor, first, new() { ["next"] = second }, 1, "page"); + await Should.ThrowAsync(() => database.PublishAsync(Editor, first, type: "page")); + await Should.ThrowAsync(() => database.PublishAsync(Editor, second, type: "page")); + + await database.PatchAsync(Editor, first, new() { ["next"] = null }, 2, "page"); + await database.PublishAsync(Editor, first, type: "page"); + await database.PublishAsync(Editor, second, type: "page"); + await database.PatchAsync(Editor, first, new() { ["next"] = second }, 3, "page"); + (await database.PublishAsync(Editor, first, type: "page")).Warnings.ShouldBeEmpty(); + } + [Fact] public async Task AReferenceInAFieldItMayNotReadIsNoWarningAndUnderBlockSaysNotWhere() { From fc0885def45ce27295966b4bfb42a743a1a7b3e9 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 19:10:15 -0500 Subject: [PATCH 5/8] Test a target judged by the locales its grant covers --- .../Content/ReferenceIntegrityTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs index 2b0cd460e..f90bc317d 100644 --- a/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/ReferenceIntegrityTests.cs @@ -299,6 +299,25 @@ public async Task ATargetOnlyInALocaleItsGrantDoesNotCoverReadsTheSameAsOneThatI answers.Distinct().ShouldHaveSingleItem(); } + [Fact] + public async Task ARowInALocaleItsGrantDoesNotCoverIsNotWhatTheTargetIsJudgedBy() + { + var publisher = WithRole(_warning.Database.AddRoleForTest( + "Canadian", + ("content.article.read", null), + ("content.article.publish", null), + ("content.author.read", new PermissionConstraints { Locales = ["fr-CA"] }))); + var target = await AuthorAsync(_warning, "fr"); + await _warning.PublishAsync(Editor, target, "fr", type: "author", operationLocale: "fr"); + await _warning.PatchAsync(Editor, target, new() { ["name"] = "Anne" }, 0, "author", "fr-CA"); + var id = IdOf(await _warning.CreateAsync(Editor, new() { ["title"] = "x", ["author"] = target }, locale: "fr-CA")); + + var warning = (await _warning.PublishAsync(publisher, id, "fr-CA", operationLocale: "fr-CA")).Warnings.ShouldHaveSingleItem(); + + warning.Code.ShouldBe(PublishWarningCodes.UnpublishedReference); + warning.TargetId.ShouldBe(target); + } + [Theory] [InlineData(UnpublishedReferencePolicy.Warn)] [InlineData(UnpublishedReferencePolicy.Block)] From 179b9c1f417abe1830c116008ba308500b19f3ab Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 19:34:37 -0500 Subject: [PATCH 6/8] Close the second round of leaks and stop nested lists doubling --- docs/planning/backend-technical-spec.md | 6 +- .../Authorization/AuthorizationEvaluator.cs | 28 +- .../Authorization/EffectivePermissions.cs | 34 ++- .../Content/ContentService.Publishing.cs | 16 +- src/FluentCMS.Core/Content/ContentService.cs | 31 +- src/FluentCMS.Core/Content/EntryComposer.cs | 16 +- src/FluentCMS.Core/Content/EntryStore.cs | 35 +-- .../Content/Query/ContentQueryAccess.cs | 2 + .../Content/Query/ContentQueryEngine.cs | 14 +- .../Content/Query/IContentQueryAccess.cs | 3 + .../Content/Query/NestedTargets.cs | 44 ++- .../RichText/RichTextReferences.cs | 20 +- src/FluentCMS.Sdk/Content/IContentService.cs | 4 +- .../Content/NestedTargetTests.cs | 265 ++++++++++++++++++ .../Content/PublishingAccessTests.cs | 36 ++- 15 files changed, 486 insertions(+), 68 deletions(-) diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index ffe0211f6..65020121d 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -554,7 +554,7 @@ Rows 6.1 to 6.11; decisions 38, 39, 40, 41. | Purge | delete all rows; separate permission `content.{type}.purge` | | `EntryPurgedEvent` | | Schedule | row in `cms_schedule`, job enqueued | | `EntryScheduledEvent` | -**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, and only a publish marks one so, in `status_after`. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. Access to versions is decided on the entry's current row: history is part of the entry, so a reader the row rule lets in also sees versions from before the entry came within the rule. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. +**Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, and only a publish marks one so, in `status_after`. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. Access to versions is decided on the entry's current row: history is part of the entry, so a reader the row rule lets in also sees versions from before the entry came within the rule. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. A principal that can write a field it cannot read may not discard or restore at all, whatever that field holds, because the status and version those answer with would check a saved guess against the value. **Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. A target counts as published when it has a published row in the entry's locale or its fallback chain. A self-reference is exempt. A cycle is not: under `Block` neither side can go first, and the way out is to publish one side without the reference and set it afterwards. @@ -571,9 +571,9 @@ The answer must not be a way to ask about an entry the principal may not read, s The system principal is told the id on `FCMS2404`. -**Relation targets on a read.** The same rules hold for a top-level relation, a relation inside a block or component, and an entry link inside rich text. A target the principal may not read by type or row rule, in another site, or of another type than the field's, is returned as null or left out of a list; a rich text link keeps its text and loses its target, and a link to an unknown type is treated as one to an unreadable type. A published read shows only what is published: a target that is trashed or has no published row in any locale goes too, and one published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed target of a type the principal may read as a reference, because that is what its author attached. A filter on the relation still matches a target left out: it answers about the source entry and a value the caller named. +**Relation targets on a read.** The same rules hold for a top-level relation, a relation inside a block or component, an entry link inside rich text, and a relation in the `data` of an inline block, which also count in `cms_reference` and reference integrity. A target the principal may not read by type or row rule, in another site, of another type than the field's, or with rows only in locales its grants do not cover, is returned as null or left out of a list; a rich text link keeps its text and loses its target, and a link to an unknown type is treated as one to an unreadable type. A published read shows only what is published: a target that is trashed or has no published row in any locale goes too, and one published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed target of a type the principal may read as a reference, because that is what its author attached. A filter on the relation still matches a target left out: it answers about the source entry and a value the caller named. -A write answers what a read showed, so what the read left out stays: a nested target goes back in its block, and a rich text document keeps its links when it comes back as it was read. Rich text nodes have no ids, so an edited document loses the links the principal could not see. +A write answers what a read showed, so what the read left out stays: a nested target goes back in its block, and a rich text document keeps its links when it comes back as it was read. Rich text nodes have no ids, so an edited document loses the links the principal could not see. Keeping must not test whether the target exists, or a later warning would say so: a principal keeps every target its read left out, whatever the id is. Only one whose read grant on that type has no row rule and no locale limit, which a read tells anyway, loses a target that does not exist in that type and site, trashed counting as existing. So a limited principal cannot clear a ghost id, and under `Block` the entry stays refused until a wider principal does. The system principal keeps nothing it did not send. **Shared fields.** Non-localizable fields live on one row per state, so publishing one locale publishes them for every published locale. A grant limited to some locales may publish only when those fields have not changed, or no other locale is published. The other locales' status is recomputed after a transition that moves the shared row. So `published_version` names the version a locale last published, and what that locale serves can differ from that snapshot in its shared fields. diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index 7bea99cf5..41d39ae65 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -56,7 +56,7 @@ public async ValueTask CanAccessEntryAsync( return false; } - if (!await IsInSiteAsync(type, operation, entry, target, cancellationToken)) + if (!await IsInReachAsync(type, operation, entry, target, permissions, cancellationToken)) { return false; } @@ -81,7 +81,7 @@ public async ValueTask WritableFieldsAsync( return WritableFields.None; } - if (entry is { } inSite && !await IsInSiteAsync(type, operation, inSite, target, cancellationToken)) + if (entry is { } inSite && !await IsInReachAsync(type, operation, inSite, target, permissions, cancellationToken)) { return WritableFields.None; } @@ -128,17 +128,31 @@ public async ValueTask WritableFieldsAsync( } // A grant's site limit is about the entry, so the entry's own site must be the one asked about. A global type's - // entries belong to no site, and a target of every site has nothing to compare. - private async Task IsInSiteAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, CancellationToken cancellationToken) + // entries belong to no site, and a target of every site has nothing to compare. A read's locale limit is about the entry + // too: one with rows only in other locales reads as no entry. A write may add a locale, so this is for reads alone. + private async Task IsInReachAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, EffectivePermissions permissions, CancellationToken cancellationToken) { - if (type.Scope != ContentScope.Site || target.Site is not { } site) + var locales = operation == ContentOperation.Read && type.Localized ? permissions.ReadLocalesOf(type, target) : null; + var site = type.Scope == ContentScope.Site ? target.Site : null; + if (site is null && locales is null) { return true; } - IQueryable documents = ReachesTrash(operation) ? database.Documents.IgnoreQueryFilters() : database.Documents; var typeName = type.Name; - return await documents.AsNoTracking().AnyAsync(document => document.DocumentId == entry && document.Type == typeName && document.SiteId == site.Value, cancellationToken); + IQueryable documents = ReachesTrash(operation) ? database.Documents.IgnoreQueryFilters() : database.Documents; + documents = documents.AsNoTracking().Where(document => document.DocumentId == entry && document.Type == typeName); + if (site is { } one) + { + documents = documents.Where(document => document.SiteId == one.Value); + } + + if (locales is null) + { + return await documents.AnyAsync(cancellationToken); + } + + return (await documents.Select(document => document.Locale).ToListAsync(cancellationToken)).Any(locales.Contains); } private async Task MatchesAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, QueryStatus status, ContentFilter rule, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs index e22b59cfa..9ca5b9fec 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -15,10 +15,13 @@ namespace FluentCMS.Core.Authorization; internal sealed record EffectiveGrant(string Pattern, PermissionConstraints Constraints, Guid? RoleSite) { public bool Applies(string permission, AccessTarget target) => + AppliesInSomeLocale(permission, target) + && (Constraints.Locales is null || (!string.IsNullOrEmpty(target.Locale) && Constraints.Locales.Contains(target.Locale, StringComparer.OrdinalIgnoreCase))); + + public bool AppliesInSomeLocale(string permission, AccessTarget target) => PermissionPattern.Matches(Pattern, permission) && (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))); + && (Constraints.Sites is null || (target.Site is { } site && Constraints.Sites.Contains(site))); // 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; @@ -78,6 +81,33 @@ private IEnumerable Applying(SchemaType type, ContentOperation o return grants.Where(grant => grant.Applies(permission, target) && (!writesEverySite || grant.IsInstanceWide)); } + // The locales a read of the type is limited to, or null when a grant has no such limit. Across every grant, since a + // relation's target may be read in another locale than the one asked about. + public IReadOnlySet? ReadLocalesOf(SchemaType type, AccessTarget target) + { + if (IsSystem) + { + return null; + } + + HashSet locales = new(StringComparer.OrdinalIgnoreCase); + var permission = CorePermissions.Content(type.Name, ContentOperation.Read); + if (ScopeAllows(permission)) + { + foreach (var grant in grants.Where(grant => grant.AppliesInSomeLocale(permission, target))) + { + if (grant.Constraints.Locales is not { } limited) + { + return null; + } + + locales.UnionWith(limited); + } + } + + return locales; + } + // Null when every field is readable. A response has one set of fields for all its rows, and rows are the union of // the grants, so a list that holds only on some rows cannot count on the others: a field is readable when a grant // with no row rule allows it, or when every applying grant does. This fails closed until there is a per-row diff --git a/src/FluentCMS.Core/Content/ContentService.Publishing.cs b/src/FluentCMS.Core/Content/ContentService.Publishing.cs index 87593de70..3634733fc 100644 --- a/src/FluentCMS.Core/Content/ContentService.Publishing.cs +++ b/src/FluentCMS.Core/Content/ContentService.Publishing.cs @@ -137,6 +137,9 @@ public async Task DiscardDraftAsync(string type, Guid id, strin ThrowIfNotPublished(write, document); await ThrowIfNotExpectedAsync(write, id, document, expectedVersion, cancellationToken); + var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); + ThrowIfWritesBlind(write, writable); + var stored = await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken); var published = await store.ReadAsync(write.Type, document, ContentState.Published, cancellationToken); if (SameFields(write.Type, stored, published)) @@ -149,7 +152,6 @@ public async Task DiscardDraftAsync(string type, Guid id, strin return document; } - var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); var working = Prepare(write, EntryComposer.Replace(write.Type, stored, DiscardedValues(write, published, writable), static _ => true), document, stored); if (writable.Fields is not null) { @@ -249,6 +251,8 @@ public async Task RestoreVersionAsync(string type, Guid id, int ?? throw new EntryNotFoundException(write.Type.Name, id); var writable = await evaluator.WritableFieldsAsync(write.Operation, write.Type, ContentOperation.Update, id, target, cancellationToken); + ThrowIfWritesBlind(write, writable); + var stored = await store.ReadAsync(write.Type, document, ContentState.Draft, cancellationToken); var restored = await RestorableFieldsAsync(write, version, writable, cancellationToken); @@ -354,6 +358,16 @@ private static void ThrowIfNotPublished(WriteScope write, Document document) } } + // A discard or restore answers with a status and a version that depend on whether the draft equals what it is compared + // with. Where the principal can write a field it cannot read, that would check a saved guess, so it is refused whatever the field holds. + private static void ThrowIfWritesBlind(WriteScope write, WritableFields writable) + { + if (!write.Permissions.IsSystem && write.Type.Fields.Any(field => !field.Hidden && !field.ReadOnly && writable.Includes(field.Name) && !write.Permissions.CanReadField(write.Type, field.Name, write.Target))) + { + throw new ContentAccessDeniedException(write.Type.Name, CorePermissions.OperationName(ContentOperation.Update)); + } + } + // A grant limited to some fields publishes only what stays within them. A field it can write but not read is refused // whatever it holds: otherwise it could save a guess and let this answer say whether it equals the published value. private async Task ThrowIfBeyondThePublishGrantAsync(WriteScope write, Guid id, AccessTarget target, ContentDocument draft, ContentDocument published, WritableFields writable, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index 04b56b289..652c8c620 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -683,22 +683,35 @@ private bool NeedsWhatARead(WriteScope write, ContentDocument sent, bool isPatch || FieldStorage.Of(schema, write.Type).Any(item => item.Kind == FieldStorageKind.Reference && item.Field.Multiple && sent.ContainsKey(QueryPaths.ApiPath(write.Type, item).Split('.')[0])) || sent.Keys.Any(key => write.Type.FindField(key)?.FieldType is FieldTypeNames.Blocks or FieldTypeNames.Component or FieldTypeNames.RichText)); - // A nested target that the read left out and that still exists. One no longer there is nothing to keep. private async Task> UnseenNestedTargetsAsync(WriteScope write, ContentDocument stored, ContentDocument? shown, CancellationToken cancellationToken) { var nested = new NestedTargets(schema); - var ids = nested.Collect(write.Type, stored).Values.SelectMany(found => found).ToHashSet(); - if (ids.Count == 0) + var seen = shown is null ? null : nested.Collect(write.Type, shown); + HashSet unseen = []; + foreach (var (name, ids) in nested.Collect(write.Type, stored)) { - return ids; + var left = ids.Except(seen?.GetValueOrDefault(name) ?? []).ToList(); + if (left.Count > 0) + { + unseen.UnionWith(await KeptTargetsAsync(write, name, left, cancellationToken)); + } } - if (shown is not null) + return unseen; + } + + // Which of the targets a read left out stay. All of them, so that the result, and any warning on it later, is the same + // whatever the id is. Only a principal whose read of that type has no row rule and no locale limit may lose one that + // is not there, trashed counting as there: a read tells it that anyway, and it is how a ghost gets cleared. + private async Task> KeptTargetsAsync(WriteScope write, string targetName, List ids, CancellationToken cancellationToken) + { + if (schema.FindContentType(targetName) is not { } target + || !write.Permissions.GrantsOf(target, ContentOperation.Read, write.Target).Any(grant => grant.Constraints.RowRule is null && grant.Constraints.Locales is null)) { - ids.ExceptWith(nested.Collect(write.Type, shown).Values.SelectMany(found => found)); + return ids; } - return ids.Count == 0 ? ids : await store.ExistingEntriesAsync([.. ids], cancellationToken); + return await store.ExistingTargetsAsync(target, ids, write.Site, cancellationToken); } // A locale being added has no row to read, so this reads a locale it has for the shared fields. One that has since @@ -719,7 +732,7 @@ private async Task> UnseenNestedTargetsAsync(WriteScope write, Con // 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. + // 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) @@ -745,7 +758,7 @@ private async Task KeepUnreadableTargetsAsync(WriteScope write, ContentDocument continue; } - var kept = await store.ExistingTargetsAsync(item.Field, unseen, cancellationToken); + var kept = await KeptTargetsAsync(write, EntryPaths.ReferenceTarget(item.Field), unseen, cancellationToken); var sent = IdsOf(EntryPaths.ValueAt(working, item, write.Type)); if (item.Field.Multiple) { diff --git a/src/FluentCMS.Core/Content/EntryComposer.cs b/src/FluentCMS.Core/Content/EntryComposer.cs index 62573d332..55a0ef50e 100644 --- a/src/FluentCMS.Core/Content/EntryComposer.cs +++ b/src/FluentCMS.Core/Content/EntryComposer.cs @@ -9,6 +9,8 @@ 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 readonly NestedTargets _nested = new(schema); + private const string Discriminator = "type"; private static readonly HashSet NoTargets = []; @@ -145,8 +147,8 @@ public void CarryProtected(SchemaType type, ContentDocument stored, ContentDocum } } - private static RichText CarryLinks(RichText before, RichText after, IReadOnlySet unseen) => - unseen.Count > 0 && JsonNode.DeepEquals(JsonNode.Parse(NestedTargets.DropLinks(before, (_, id) => !unseen.Contains(id)).Json), JsonNode.Parse(after.Json)) ? before : after; + private RichText CarryLinks(RichText before, RichText after, IReadOnlySet unseen) => + unseen.Count > 0 && JsonNode.DeepEquals(JsonNode.Parse(_nested.DropLinks(before, (_, id) => !unseen.Contains(id)).Json), JsonNode.Parse(after.Json)) ? before : after; private SchemaType? ComponentOf(SchemaField field) => field.Settings.TryGetValue(FieldSettings.Component, out var name) && name is string component ? schema.FindComponent(component) : null; @@ -268,7 +270,7 @@ private JsonObject Comparable(SchemaType owner, JsonObject item, IReadOnlySet !unseen.Contains(id)); + _nested.DropLinks(text, (_, id) => !unseen.Contains(id)); return text; } @@ -281,7 +283,10 @@ JsonObject single when OwnerOf(field, single) is { } owner => Comparable(owner, } private static bool IsUnseen(JsonNode? value, IReadOnlySet unseen) => - value is not JsonArray && EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) && unseen.Contains(id); + value is not JsonArray && TargetId(value) is { } id && unseen.Contains(id); + + private static Guid? TargetId(JsonNode? value) => + EntryCanonicalizer.TryReadId(RawValue.Unwrap(value), out var id) ? id : null; private SchemaType? OwnerOf(SchemaField field, JsonObject item) => field.FieldType switch { @@ -347,9 +352,10 @@ private static void CarryTargets(SchemaField field, JsonNode before, JsonObject } var targets = after[field.Name] as JsonArray ?? []; + HashSet present = [.. targets.Select(TargetId).OfType()]; for (var index = 0; index < oldTargets.Count; index++) { - if (IsUnseen(oldTargets[index], unseen)) + if (IsUnseen(oldTargets[index], unseen) && TargetId(oldTargets[index]) is { } id && present.Add(id)) { targets.Insert(Math.Min(index, targets.Count), oldTargets[index]?.DeepClone()); } diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index b9cdd4ab3..1f430081d 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -353,12 +353,6 @@ public async Task> TargetRowsAsync(SchemaType target, G return await rows.Select(document => new ReferenceTargetRow(document.Locale, document.PublishedVersion != null)).ToListAsync(cancellationToken); } - // Which of these ids are entries, in the trash too. - public async Task> ExistingEntriesAsync(List ids, CancellationToken cancellationToken) => - [.. await context.Documents.IgnoreQueryFilters().AsNoTracking() - .Where(document => ids.Contains(document.DocumentId)) - .Select(document => document.DocumentId) - .ToListAsync(cancellationToken)]; // What an entry refers to, with whether the reference is outside what hidden fields hide. Media has no row here to check. public List TargetsOf(SchemaType type, ContentDocument entry) @@ -480,19 +474,18 @@ private IQueryable ReferencesOf(SchemaType type, Document row, short private Task DeleteReferencesAsync(SchemaType type, Document row, short state, bool includingShared, CancellationToken cancellationToken) => ReferencesOf(type, row, state, includingShared).ExecuteDeleteAsync(cancellationToken); - // 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) + // Which of these ids are entries of this type in this site, in the trash too. + public async Task> ExistingTargetsAsync(SchemaType target, List ids, SiteId site, CancellationToken cancellationToken) { - if (schema.FindContentType(EntryPaths.ReferenceTarget(field)) is null) + var typeName = target.Name; + var siteId = (Guid?)site.Value; + var rows = context.Documents.IgnoreQueryFilters().AsNoTracking().Where(document => ids.Contains(document.DocumentId) && document.Type == typeName); + if (target.Scope == ContentScope.Site) { - return ids; + rows = rows.Where(document => document.SiteId == siteId); } - return await context.Documents.IgnoreQueryFilters().AsNoTracking() - .Where(document => ids.Contains(document.DocumentId)) - .Select(document => document.DocumentId) - .Distinct() - .ToListAsync(cancellationToken); + return await rows.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) @@ -644,7 +637,7 @@ private static void CollectReference(SchemaField field, object? value, List<(str } } - private static void CollectRichText(object? value, List<(string Target, Guid Id)> into) + private void CollectRichText(object? value, List<(string Target, Guid Id)> into) { var document = value switch { @@ -661,6 +654,16 @@ private static void CollectRichText(object? value, List<(string Target, Guid Id) var found = RichTextReferenceExtractor.Extract(document); into.AddRange(found.Entries.Select(entry => (entry.Type, entry.Id))); into.AddRange(found.Media.Select(media => (MediaTarget, media.Id))); + foreach (var block in found.InlineBlocks) + { + foreach (var field in schema.FindBlock(block.BlockType)?.Fields ?? []) + { + if (block.Data.TryGetValue(field.Name, out var data)) + { + Collect(field, EntryJson.ToNode(data), into); + } + } + } } private void CollectInside(SchemaField field, JsonNode node, List<(string Target, Guid Id)> into) diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryAccess.cs b/src/FluentCMS.Core/Content/Query/ContentQueryAccess.cs index baf9269e4..d7c2e85bb 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryAccess.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryAccess.cs @@ -40,6 +40,8 @@ private sealed class Bound(EffectivePermissions permissions, SiteId operationSit public bool CanReadField(SchemaType type, string field) => permissions.CanReadField(type, field, Target(type)); + public IReadOnlySet? ReadLocales(SchemaType type) => permissions.ReadLocalesOf(type, Target(type)); + public ContentFilter? RowRule(SchemaType type) { // One instance per type, because the compiler recognises the rule in a filter by reference. diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs index 6784e1192..86062d898 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs @@ -366,7 +366,8 @@ private async Task FillReferencesAsync(QueryScope query, EntryPlan plan, List?> ReadableTargetsAsync(QueryScope query, ReferenceOutput output, List references, CancellationToken cancellationToken) { var target = output.Stored.Field.Relation is { } relation ? schema.FindContentType(relation.Target) : null; @@ -417,7 +418,8 @@ private async Task FilterNestedTargetsAsync(QueryScope query, SchemaType type, L var inOneSite = target.Scope == ContentScope.Site && query.Site is not null; var hasRule = query.Access.RowRule(target) is not null; var isPublished = query.State == ContentState.Published; - if (!inOneSite && !hasRule && !isPublished) + var locales = target.Localized ? query.Access.ReadLocales(target) : null; + if (!inOneSite && !hasRule && !isPublished && locales is null) { return null; } @@ -436,7 +438,13 @@ private async Task FilterNestedTargetsAsync(QueryScope query, SchemaType type, L documents = documents.Where(document => document.SiteId == site); } - return [.. await documents.Select(document => document.DocumentId).ToListAsync(cancellationToken)]; + if (locales is null) + { + return [.. await documents.Select(document => document.DocumentId).ToListAsync(cancellationToken)]; + } + + var rows = await documents.Select(document => new { document.DocumentId, document.Locale }).ToListAsync(cancellationToken); + return [.. rows.Where(row => locales.Contains(row.Locale)).Select(row => row.DocumentId)]; } var row = Expression.Parameter(typeof(QueryRow), "row"); diff --git a/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs b/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs index b47197f6b..9f6a0a03d 100644 --- a/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs +++ b/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs @@ -20,6 +20,9 @@ internal interface IContentQueryAccess // The field is a top-level field of the type or a system field such as createdBy. bool CanReadField(SchemaType type, string field); + // The locales a relation's target may be read in, or null when there is no limit. + IReadOnlySet? ReadLocales(SchemaType type) => null; + // Joins the client's filter in an And node before compilation, on the queried type and on every relation target. ContentFilter? RowRule(SchemaType type); } diff --git a/src/FluentCMS.Core/Content/Query/NestedTargets.cs b/src/FluentCMS.Core/Content/Query/NestedTargets.cs index 355abd091..a54a323ed 100644 --- a/src/FluentCMS.Core/Content/Query/NestedTargets.cs +++ b/src/FluentCMS.Core/Content/Query/NestedTargets.cs @@ -6,8 +6,9 @@ namespace FluentCMS.Core.Content.Query; -// The relations inside blocks and repeated components and the entry links inside rich text. They have no output of their -// own in a query, so the rules on a top-level relation reach them through here. A rich text link keeps its text. +// The relations inside blocks and repeated components, the entry links inside rich text, and the relations in the data of +// an inline block. They have no output of their own in a query, so the rules on a top-level relation reach them through +// here. A rich text link keeps its text. internal sealed class NestedTargets(SchemaModel schema) { private const string Discriminator = "type"; @@ -23,9 +24,12 @@ public void Filter(SchemaType owner, ContentDocument entry, Func keep) + public void Filter(SchemaType owner, JsonObject item, Func keep) => + FilterFields(owner.Fields.Where(field => !field.Hidden), item, keep); + + private void FilterFields(IEnumerable fields, JsonObject item, Func keep) { - foreach (var field in owner.Fields.Where(field => !field.Hidden)) + foreach (var field in fields) { if (item[field.Name] is { } value) { @@ -48,23 +52,24 @@ public Dictionary> Collect(SchemaType owner, JsonObject it return found; } - public static bool DropLinks(JsonNode? node, Func keep) + public bool DropLinks(JsonNode? node, Func keep) { if (node is not JsonObject item) { return false; } - var changed = false; + var changed = FilterInlineBlock(item, keep); if (item["marks"] is JsonArray marks) { - foreach (var mark in marks.OfType().Where(mark => IsUnwantedLink(mark, keep)).ToList()) + var unwanted = marks.OfType().Where(mark => IsUnwantedLink(mark, keep)).ToList(); + foreach (var mark in unwanted) { marks.Remove(mark); - changed = true; } - if (changed && marks.Count == 0) + changed |= unwanted.Count > 0; + if (unwanted.Count > 0 && marks.Count == 0) { item.Remove("marks"); } @@ -81,9 +86,9 @@ public static bool DropLinks(JsonNode? node, Func keep) return changed; } - public static RichText DropLinks(RichText document, Func keep) + public RichText DropLinks(RichText document, Func keep) { - if (!document.Json.Contains(RichTextMarkTypes.EntryLink, StringComparison.Ordinal)) + if (!document.Json.Contains(RichTextMarkTypes.EntryLink, StringComparison.Ordinal) && !document.Json.Contains(RichTextNodeTypes.InlineBlock, StringComparison.Ordinal)) { return document; } @@ -92,6 +97,23 @@ public static RichText DropLinks(RichText document, Func kee return DropLinks(node, keep) ? new RichText(EntryJson.Text(node!)) : document; } + // The data of an inline block is what its author wrote, not a stored block, so a field marked hidden is no exception here. + private bool FilterInlineBlock(JsonObject node, Func keep) + { + if (node[Discriminator] is not JsonValue kind || !kind.TryGetValue(out var name) || name != RichTextNodeTypes.InlineBlock + || node["attrs"] is not JsonObject attributes + || attributes["data"] is not JsonObject data + || attributes["blockType"] is not JsonValue type || !type.TryGetValue(out var blockType) + || schema.FindBlock(blockType) is not { } block) + { + return false; + } + + var before = data.DeepClone(); + FilterFields(block.Fields, data, keep); + return !JsonNode.DeepEquals(before, data); + } + private static Func Recording(Dictionary> found) => (type, id) => { if (!found.TryGetValue(type, out var ids)) diff --git a/src/FluentCMS.Core/RichText/RichTextReferences.cs b/src/FluentCMS.Core/RichText/RichTextReferences.cs index 59076a57d..69bf16857 100644 --- a/src/FluentCMS.Core/RichText/RichTextReferences.cs +++ b/src/FluentCMS.Core/RichText/RichTextReferences.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Schema; namespace FluentCMS.Core.ProseMirror; @@ -6,9 +7,12 @@ public sealed record RichTextEntryReference(Guid Id, string Type, string Path); public sealed record RichTextMediaReference(Guid Id, string Path); +public sealed record RichTextInlineBlock(string BlockType, IReadOnlyDictionary Data, string Path); + public sealed record RichTextReferences( IReadOnlyList Entries, - IReadOnlyList Media); + IReadOnlyList Media, + IReadOnlyList InlineBlocks); public static class RichTextReferenceExtractor { @@ -24,12 +28,18 @@ public static RichTextReferences Extract(DocumentNode document) var entries = new List(); var media = new List(); - Visit(document, "$", entries, media); - return new RichTextReferences(entries, media); + var blocks = new List(); + Visit(document, "$", entries, media, blocks); + return new RichTextReferences(entries, media, blocks); } - private static void Visit(RichTextNode node, string path, List entries, List media) + private static void Visit(RichTextNode node, string path, List entries, List media, List blocks) { + if (node is InlineBlockNode block) + { + blocks.Add(new RichTextInlineBlock(block.BlockType, block.Data, path)); + } + if (node is MediaNode mediaNode) { media.Add(new RichTextMediaReference(mediaNode.MediaId, path)); @@ -45,7 +55,7 @@ private static void Visit(RichTextNode node, string path, ListThe version being discarded, or for whatever the draft holds. /// 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 in the locale. + /// The principal may not update entries of the type in the locale, or may write a field it may not read. /// The entry has no row in the locale, is in the trash, or the principal may not reach it. /// The stored version is not the expected one. /// The entry is not published in the locale, or a hook left a value that is not valid. @@ -159,7 +159,7 @@ public interface IContentService /// The version being replaced, or for whatever the draft holds. /// 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 in the locale. + /// The principal may not update entries of the type in the locale, or may write a field it may not read. /// The entry or the version does not exist in the locale, or the principal may not reach it. /// The stored version is not the expected one. /// The values are not valid for a draft today. diff --git a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs index 56d6f8cd3..102b6703b 100644 --- a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs @@ -32,6 +32,7 @@ private static SchemaModel Model() => SampleSchema.NewBuilder() .Field("title", "text") .Field("author", "relation", field => field.Relation("author")) .Field("cards", "blocks", field => field.Setting(FieldSettings.Blocks, Cards)) + .Field("other", "blocks", field => field.Setting(FieldSettings.Blocks, Cards)) .Field("box", "component", field => field.Setting(FieldSettings.Component, "box")) .Field("body", "richText")) .Build(); @@ -295,6 +296,270 @@ public async Task ARestoreDoesNotBringBackNorLoseANestedTargetItMayNotRead() snapshot["cards"]![0]!["who"]!["id"]!.GetValue().ShouldBe(current.ToString()); } + private int CardReferences(Guid id) => _database.References(id, Core.Storage.ContentState.Draft).Count(reference => reference.Field == "cards"); + + private static ContentDocument ManyTargets(Guid first, Guid second) => new() + { + ["title"] = Guid.NewGuid().ToString("N"), + ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["many"] = new[] { first, second } } }, + }; + + [Fact] + public async Task RepeatedSavesOfWhatItCannotSeeLeaveATargetListAsItWas() + { + var first = await AuthorAsync("draft"); + var second = await AuthorAsync("draft"); + var id = await ArticleAsync(ManyTargets(first, second), publish: false); + var writer = Writer(); + + var version = 1; + for (var round = 0; round < 3; round++) + { + version = VersionOf(await _database.PatchAsync(writer, id, new() { ["other"] = new object[] { new ContentDocument { ["type"] = "card", ["label"] = $"round {round}" } } }, version, "article")); + CardReferences(id).ShouldBe(2); + } + + var read = (await _database.GetAsync(writer, id, type: "article"))!; + version = VersionOf(await _database.UpdateAsync(writer, id, read, version, "article")); + CardReferences(id).ShouldBe(2); + + var order = _database.References(id, Core.Storage.ContentState.Draft).Where(reference => reference.Field == "cards").OrderBy(reference => reference.Position).Select(reference => reference.TargetId); + order.ShouldBe([first, second]); + } + + [Fact] + public async Task AFieldItMayNotWriteKeepsItsListThroughAnUpdateAndARestore() + { + var first = await AuthorAsync("draft"); + var second = await AuthorAsync("draft"); + var limited = Reader(("content.article.update", new PermissionConstraints { Fields = ["title"] })); + + var updated = await ArticleAsync(ManyTargets(first, second), publish: false); + await _database.PatchAsync(limited, updated, new() { ["title"] = "Changed" }, 1, "article"); + CardReferences(updated).ShouldBe(2); + await _database.UpdateAsync(limited, updated, new() { ["title"] = "Changed again" }, 2, "article"); + CardReferences(updated).ShouldBe(2); + + var restored = await ArticleAsync(ManyTargets(first, second), publish: false); + await _database.PatchAsync(Editor, restored, new() { ["title"] = "Two" }, 1, "article"); + await _database.RestoreVersionAsync(limited, restored, 1, type: "article"); + CardReferences(restored).ShouldBe(2); + } + + [Fact] + public async Task AListItemItRemovesThatItCouldSeeStaysRemovedAndAnUnseenOneStays() + { + var unseen = await AuthorAsync("draft"); + var seen = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Seen" }, "author")); + var owner = Guid.CreateVersion7(); + var writer = WithRole(_database.Database.AddRoleForTest( + "Own authors", + ("content.article.read", null), + ("content.article.update", null), + ("content.author.create", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule })), owner); + var mine = IdOf(await _database.CreateAsync(writer, new() { ["name"] = "Mine" }, "author")); + var id = await ArticleAsync(new() + { + ["title"] = "x", + ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["many"] = new[] { unseen, mine, seen } } }, + }, publish: false); + + await _database.PatchAsync(writer, id, new() { ["cards"] = new object[] { new ContentDocument { ["type"] = "card" } } }, 1, "article"); + + var targets = _database.References(id, Core.Storage.ContentState.Draft).Where(reference => reference.Field == "cards").Select(reference => reference.TargetId).ToList(); + targets.ShouldContain(unseen); + targets.ShouldContain(seen); + targets.ShouldNotContain(mine); + targets.Count.ShouldBe(2); + } + + private static RichText InlineBlock(Guid target, string label = "kept") => new( + "{\"type\":\"doc\",\"content\":[{\"type\":\"inlineBlock\",\"attrs\":{\"blockType\":\"card\",\"data\":{\"label\":\"" + label + "\",\"who\":{\"id\":\"" + target + "\",\"type\":\"author\"}}}}]}"); + + [Fact] + public async Task ARelationInAnInlineBlockIsRecordedCheckedAndFiltered() + { + var draft = await AuthorAsync("draft"); + var id = await ArticleAsync(new() { ["title"] = "x", ["body"] = InlineBlock(draft) }, publish: false); + + _database.References(id, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(draft); + _database.References(id, Core.Storage.ContentState.Draft)[0].Field.ShouldBe("body"); + + var warning = (await _database.PublishAsync(Editor, id, type: "article")).Warnings.ShouldHaveSingleItem(); + warning.Path.ShouldBe("body"); + warning.TargetId.ShouldBe(draft); + + var published = (await PublishedReadAsync(Editor, id))!["body"].ShouldBeOfType(); + published.Json.ShouldNotContain(draft.ToString()); + published.Json.ShouldContain("kept"); + (await _database.GetAsync(Editor, id, type: "article"))!["body"].ShouldBeOfType().Json.ShouldContain(draft.ToString()); + + var blind = Reader(); + (await _database.GetAsync(blind, id, type: "article"))!["body"].ShouldBeOfType().Json.ShouldNotContain(draft.ToString()); + Text(await _database.VersionAsync(blind, id, 1, type: "article")).ShouldNotContain(draft.ToString()); + } + + [Fact] + public async Task AnInlineBlockSentBackAsItWasReadKeepsItsTarget() + { + var author = await AuthorAsync("published"); + var id = await ArticleAsync(new() { ["title"] = "One", ["body"] = InlineBlock(author) }); + var writer = Writer(); + + var read = (await _database.GetAsync(writer, id, type: "article"))!; + await _database.PatchAsync(writer, id, new() { ["body"] = read["body"] }, 1, "article"); + + _database.References(id, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(author); + } + + [Fact] + public async Task TheDataOfAnInlineBlockOfAnUnknownTypeIsLeftAlone() + { + var body = new RichText("{\"type\":\"doc\",\"content\":[{\"type\":\"inlineBlock\",\"attrs\":{\"blockType\":\"pluginThing\",\"data\":{\"who\":\"anything\"}}}]}"); + var id = await ArticleAsync(new() { ["title"] = "x", ["body"] = body }); + + (await PublishedReadAsync(Editor, id))!["body"].ShouldBeOfType().Json.ShouldContain("anything"); + _database.References(id, Core.Storage.ContentState.Draft).ShouldBeEmpty(); + } + + [Fact] + public async Task AnEntryOnlyInALocaleTheGrantDoesNotCoverIsNoTargetOnAnyRead() + { + var schema = SampleSchema.NewBuilder() + .AddBlock("card", block => block.Field("who", "relation", field => field.Relation("author"))) + .AddCollection("author", type => type.Localized().Field("name", "text", field => field.Localizable())) + .AddCollection("article", type => type.Field("title", "text").Field("author", "relation", field => field.Relation("author")).Field("cards", "blocks", field => field.Setting(FieldSettings.Blocks, Cards))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var english = WithRole(database.Database.AddRoleForTest( + "English", + ("content.article.read", null), + ("content.author.read", new PermissionConstraints { Locales = ["en"] }))); + + var draft = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "fr" }, "author", "fr")); + var published = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "fr" }, "author", "fr")); + await database.PublishAsync(Editor, published, "fr", type: "author", operationLocale: "fr"); + var inEnglish = IdOf(await database.CreateAsync(Editor, new() { ["name"] = "en" }, "author")); + await database.PublishAsync(Editor, inEnglish, type: "author"); + + foreach (var (target, visible) in new[] { (draft, false), (published, false), (Guid.CreateVersion7(), false), (inEnglish, true) }) + { + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = Guid.NewGuid().ToString("N"), ["author"] = target, ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["who"] = target } } }, "article")); + await database.PublishAsync(Editor, id, type: "article"); + + foreach (var read in new[] + { + await database.GetAsync(english, id, type: "article"), + await database.GetAsync(english, id, new GetOptions { Status = QueryStatus.Published }, "article"), + await database.VersionAsync(english, id, 1, type: "article"), + }) + { + (read!["author"] is not null).ShouldBe(visible); + Text(read["cards"]).Contains(target.ToString(), StringComparison.Ordinal).ShouldBe(visible); + } + } + } + + // Whatever X is, a principal limited on the type keeps it, and publishing then says the same. + private async Task> GhostsAsync() + { + var vault = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "v" }, "vault")); + var elsewhere = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "v" }, "vault", site: SiteB)); + var trashed = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "v" }, "vault")); + await _database.DeleteAsync(Editor, trashed, "vault"); + return [vault, elsewhere, trashed, Guid.CreateVersion7()]; + } + + [Fact] + public async Task KeepingATargetItCannotSeeSaysNothingOfWhatTheIdIs() + { + var limited = WithRole(_database.Database.AddRoleForTest( + "Own authors", + ("content.article.create", null), + ("content.article.read", null), + ("content.article.update", null), + ("content.article.publish", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }))); + List answers = []; + + foreach (var ghost in await GhostsAsync()) + { + var block = IdOf(await _database.CreateAsync(limited, new() { ["title"] = "x", ["cards"] = new object[] { Card(ghost) } }, "article")); + var read = (await _database.GetAsync(limited, block, type: "article"))!; + await _database.UpdateAsync(limited, block, new() { ["title"] = "x", ["cards"] = read["cards"] }, 1, "article"); + + var top = IdOf(await _database.CreateAsync(limited, new() { ["title"] = "x", ["author"] = ghost }, "article")); + await _database.UpdateAsync(limited, top, new() { ["title"] = "x" }, 1, "article"); + + var text = IdOf(await _database.CreateAsync(limited, new() { ["title"] = "x", ["body"] = Linked("t", ghost) }, "article")); + var readText = (await _database.GetAsync(limited, text, type: "article"))!; + await _database.UpdateAsync(limited, text, new() { ["title"] = "x", ["body"] = readText["body"] }, 1, "article"); + + List warnings = []; + foreach (var id in new[] { block, top, text }) + { + _database.References(id, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(ghost); + warnings.AddRange((await _database.PublishAsync(limited, id, type: "article")).Warnings.Select(warning => $"{warning.Path}|{warning.Code}|{warning.TargetId}")); + } + + answers.Add(string.Join(';', warnings)); + } + + answers.Distinct().ShouldHaveSingleItem(); + } + + [Fact] + public async Task APrincipalWithAnOpenReadOfTheTypeClearsAGhostAndKeepsWhatIsThere() + { + var trashed = await AuthorAsync("trashed"); + foreach (var ghost in await GhostsAsync()) + { + var block = await ArticleAsync(new() { ["title"] = Guid.NewGuid().ToString("N"), ["cards"] = new object[] { Card(ghost) } }, publish: false); + var read = (await _database.GetAsync(Editor, block, type: "article"))!; + await _database.UpdateAsync(Editor, block, read, 1, "article"); + _database.References(block, Core.Storage.ContentState.Draft).ShouldBeEmpty(); + + var top = await ArticleAsync(new() { ["title"] = Guid.NewGuid().ToString("N"), ["author"] = ghost }, publish: false); + await _database.UpdateAsync(Editor, top, new() { ["title"] = "cleared " + Guid.NewGuid().ToString("N") }, 1, "article"); + _database.References(top, Core.Storage.ContentState.Draft).ShouldBeEmpty(); + + var text = await ArticleAsync(new() { ["title"] = Guid.NewGuid().ToString("N"), ["body"] = Linked("t", ghost) }, publish: false); + var readText = (await _database.GetAsync(Editor, text, type: "article"))!; + await _database.UpdateAsync(Editor, text, readText, 1, "article"); + _database.References(text, Core.Storage.ContentState.Draft).ShouldBeEmpty(); + } + + var kept = await ArticleAsync(new() { ["title"] = "trashed target", ["cards"] = new object[] { Card(trashed) } }, publish: false); + var shown = (await _database.GetAsync(Editor, kept, type: "article"))!; + await _database.UpdateAsync(Editor, kept, shown, 1, "article"); + _database.References(kept, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(trashed); + } + + [Fact] + public async Task AGhostStaysRefusedUnderBlockUntilAWiderPrincipalClearsIt() + { + var schema = SampleSchema.NewBuilder() + .AddCollection("author", type => type.Field("name", "text")) + .AddCollection("page", type => type.PublishPolicy(UnpublishedReferencePolicy.Block).Field("title", "text").Field("author", "relation", field => field.Relation("author"))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var limited = WithRole(database.Database.AddRoleForTest( + "Own authors", + ("content.page.create", null), + ("content.page.read", null), + ("content.page.update", null), + ("content.page.publish", null), + ("content.author.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }))); + var id = IdOf(await database.CreateAsync(limited, new() { ["title"] = "x", ["author"] = Guid.CreateVersion7() }, "page")); + + await database.UpdateAsync(limited, id, new() { ["title"] = "y" }, 1, "page"); + await Should.ThrowAsync(() => database.PublishAsync(limited, id, type: "page")); + + await database.UpdateAsync(Editor, id, new() { ["title"] = "z" }, 2, "page"); + await Should.NotThrowAsync(() => database.PublishAsync(limited, id, type: "page")); + } + [Fact] public async Task AVersionDropsOnlyTheBlockTheSchemaHasLost() { diff --git a/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs index d94fb6393..ae55d9e23 100644 --- a/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/PublishingAccessTests.cs @@ -596,17 +596,45 @@ public async Task ReadOnlyValuesStayOnARestore() } [Fact] - public async Task AWriterWhoCannotReadGetsOnlyTheIdAndVersionFromARestoreAndADiscard() + public async Task AWriterWhoCannotReadMayNotRestoreOrDiscard() { var id = IdOf(await _database.CreateAsync(Editor, Titled("One"))); await _database.PublishAsync(Editor, id); await _database.PatchAsync(Editor, id, Titled("Two"), 1); + var before = _database.Trace(id); var writer = Limited(("content.article.update", null)); - (await _database.RestoreVersionAsync(writer, id, 1)).Keys.ShouldBe(["id", "version"]); - await _database.PatchAsync(Editor, id, Titled("Three"), 3); - (await _database.DiscardAsync(writer, id)).Keys.ShouldBe(["id", "version"]); + await Should.ThrowAsync(() => _database.RestoreVersionAsync(writer, id, 1)); + await Should.ThrowAsync(() => _database.DiscardAsync(writer, id)); await Should.ThrowAsync(() => _database.VersionAsync(writer, id, 1)); + _database.Trace(id).ShouldBe(before); + } + + [Fact] + public async Task ADiscardOrRestoreCannotCheckAGuessAtAFieldItCannotRead() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "One", ["code"] = "SECRET" })); + await _database.PublishAsync(Editor, id); + var guesser = Limited( + ("content.article.read", new PermissionConstraints { Fields = ["title"] }), + ("content.article.update", new PermissionConstraints { Fields = ["title", "code"] })); + List answers = []; + + var version = 1; + foreach (var guess in new[] { "WRONG", "SECRET" }) + { + version = VersionOf(await _database.PatchAsync(guesser, id, new() { ["code"] = guess }, version)); + var before = _database.Trace(id); + foreach (var call in new Func>[] { () => _database.DiscardAsync(guesser, id), () => _database.RestoreVersionAsync(guesser, id, 1) }) + { + var refused = await Should.ThrowAsync(call); + answers.Add(refused.Message); + } + + _database.Trace(id).ShouldBe(before); + } + + answers.Distinct().ShouldHaveSingleItem(); } [Fact] From 155f1c7d6eb472b13c6ac8a5f7b5b4548927d3c1 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 19:43:54 -0500 Subject: [PATCH 7/8] Test that a trashed target counts as there --- .../Content/NestedTargetTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs index 102b6703b..af2b67816 100644 --- a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs @@ -536,6 +536,21 @@ public async Task APrincipalWithAnOpenReadOfTheTypeClearsAGhostAndKeepsWhatIsThe _database.References(kept, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(trashed); } + [Fact] + public async Task ATrashedTargetCountsAsThereForOneThatCouldNotSeeIt() + { + var trashed = await AuthorAsync("trashed"); + var id = await ArticleAsync(new() { ["title"] = "x", ["cards"] = new object[] { Card(trashed) } }, publish: false); + var blindToTheEntry = WithRole(_database.Database.AddRoleForTest( + "Blind", + ("content.article.update", null), + ("content.author.read", null))); + + await _database.PatchAsync(blindToTheEntry, id, new() { ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["label"] = "L" } } }, 1, "article"); + + _database.References(id, Core.Storage.ContentState.Draft).ShouldHaveSingleItem().TargetId.ShouldBe(trashed); + } + [Fact] public async Task AGhostStaysRefusedUnderBlockUntilAWiderPrincipalClearsIt() { From d65481971bac49c3a1274c95ec7f785b79235f89 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 19:50:23 -0500 Subject: [PATCH 8/8] Count only rule-free grants for a relation target's locale --- docs/planning/backend-technical-spec.md | 2 +- .../Authorization/AuthorizationEvaluator.cs | 5 +- .../Authorization/EffectivePermissions.cs | 7 ++- .../Content/Query/IContentQueryAccess.cs | 2 +- .../Content/NestedTargetTests.cs | 57 +++++++++++++++++++ 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 65020121d..1a822c292 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -571,7 +571,7 @@ The answer must not be a way to ask about an entry the principal may not read, s The system principal is told the id on `FCMS2404`. -**Relation targets on a read.** The same rules hold for a top-level relation, a relation inside a block or component, an entry link inside rich text, and a relation in the `data` of an inline block, which also count in `cms_reference` and reference integrity. A target the principal may not read by type or row rule, in another site, of another type than the field's, or with rows only in locales its grants do not cover, is returned as null or left out of a list; a rich text link keeps its text and loses its target, and a link to an unknown type is treated as one to an unreadable type. A published read shows only what is published: a target that is trashed or has no published row in any locale goes too, and one published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed target of a type the principal may read as a reference, because that is what its author attached. A filter on the relation still matches a target left out: it answers about the source entry and a value the caller named. +**Relation targets on a read.** The same rules hold for a top-level relation, a relation inside a block or component, an entry link inside rich text, and a relation in the `data` of an inline block, which also count in `cms_reference` and reference integrity. A target the principal may not read by type or row rule, in another site, of another type than the field's, or with rows only in locales its grants do not cover, is returned as null or left out of a list. A row rule is asked only of the locale being read, so where that locale has no rule, only grants with no rule count as covering a locale: a rule on another locale's grant cannot vouch for a row there, and such a target is left out even if the principal could read it directly in that locale. A rich text link keeps its text and loses its target, and a link to an unknown type is treated as one to an unreadable type. A published read shows only what is published: a target that is trashed or has no published row in any locale goes too, and one published only in another locale stays `{ id, type }`, since a reader with a fallback can follow it. A draft read keeps a trashed target of a type the principal may read as a reference, because that is what its author attached. A filter on the relation still matches a target left out: it answers about the source entry and a value the caller named. A write answers what a read showed, so what the read left out stays: a nested target goes back in its block, and a rich text document keeps its links when it comes back as it was read. Rich text nodes have no ids, so an edited document loses the links the principal could not see. Keeping must not test whether the target exists, or a later warning would say so: a principal keeps every target its read left out, whatever the id is. Only one whose read grant on that type has no row rule and no locale limit, which a read tells anyway, loses a target that does not exist in that type and site, trashed counting as existing. So a limited principal cannot clear a ghost id, and under `Block` the entry stays refused until a wider principal does. The system principal keeps nothing it did not send. diff --git a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs index 41d39ae65..69e9edc84 100644 --- a/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs +++ b/src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs @@ -129,10 +129,11 @@ public async ValueTask WritableFieldsAsync( // A grant's site limit is about the entry, so the entry's own site must be the one asked about. A global type's // entries belong to no site, and a target of every site has nothing to compare. A read's locale limit is about the entry - // too: one with rows only in other locales reads as no entry. A write may add a locale, so this is for reads alone. + // too: one with rows only in other locales reads as no entry. A row rule answers that itself, since it reads the row in + // the locale asked about. A write may add a locale, so this is for reads alone. private async Task IsInReachAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, EffectivePermissions permissions, CancellationToken cancellationToken) { - var locales = operation == ContentOperation.Read && type.Localized ? permissions.ReadLocalesOf(type, target) : null; + var locales = operation == ContentOperation.Read && type.Localized && permissions.RowRuleOf(type, operation, target) is null ? permissions.ReadLocalesOf(type, target) : null; var site = type.Scope == ContentScope.Site ? target.Site : null; if (site is null && locales is null) { diff --git a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs index 9ca5b9fec..92a19a8e7 100644 --- a/src/FluentCMS.Core/Authorization/EffectivePermissions.cs +++ b/src/FluentCMS.Core/Authorization/EffectivePermissions.cs @@ -81,8 +81,9 @@ private IEnumerable Applying(SchemaType type, ContentOperation o return grants.Where(grant => grant.Applies(permission, target) && (!writesEverySite || grant.IsInstanceWide)); } - // The locales a read of the type is limited to, or null when a grant has no such limit. Across every grant, since a - // relation's target may be read in another locale than the one asked about. + // The locales a read reaches with no row rule to ask, or null when such a grant has no locale limit. A relation's target + // may be read in another locale than the one asked about, but a rule is only asked of that one, so a grant with a rule + // cannot vouch for a row elsewhere. public IReadOnlySet? ReadLocalesOf(SchemaType type, AccessTarget target) { if (IsSystem) @@ -94,7 +95,7 @@ private IEnumerable Applying(SchemaType type, ContentOperation o var permission = CorePermissions.Content(type.Name, ContentOperation.Read); if (ScopeAllows(permission)) { - foreach (var grant in grants.Where(grant => grant.AppliesInSomeLocale(permission, target))) + foreach (var grant in grants.Where(grant => grant.Constraints.RowRule is null && grant.AppliesInSomeLocale(permission, target))) { if (grant.Constraints.Locales is not { } limited) { diff --git a/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs b/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs index 9f6a0a03d..446a8c821 100644 --- a/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs +++ b/src/FluentCMS.Core/Content/Query/IContentQueryAccess.cs @@ -20,7 +20,7 @@ internal interface IContentQueryAccess // The field is a top-level field of the type or a system field such as createdBy. bool CanReadField(SchemaType type, string field); - // The locales a relation's target may be read in, or null when there is no limit. + // The locales a relation's target may be read in with no row rule to ask, or null when there is no limit. IReadOnlySet? ReadLocales(SchemaType type) => null; // Joins the client's filter in an And node before compilation, on the queried type and on every relation target. diff --git a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs index af2b67816..ce3bf211c 100644 --- a/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/NestedTargetTests.cs @@ -461,6 +461,63 @@ await database.VersionAsync(english, id, 1, type: "article"), } } + [Fact] + public async Task AGrantWithARuleInAnotherLocaleDoesNotVouchForARowThere() + { + var schema = SampleSchema.NewBuilder() + .AddBlock("card", block => block.Field("who", "relation", field => field.Relation("author"))) + .AddCollection("author", type => type.Localized().Field("name", "text", field => field.Localizable())) + .AddCollection("article", type => type.Field("title", "text").Field("author", "relation", field => field.Relation("author")).Field("cards", "blocks", field => field.Setting(FieldSettings.Blocks, Cards))) + .Build(); + using var database = new ServiceDatabase(schema: schema); + var reader = WithRole(database.Database.AddRoleForTest( + "Two grants", + ("content.article.read", null), + ("content.author.create", null), + ("content.author.read", new PermissionConstraints { Locales = ["en"] }), + ("content.author.read", new PermissionConstraints { Locales = ["fr"], RowRule = BuiltInRoles.OwnEntriesRule }))); + + async Task AuthorInAsync(Principal by, string locale) + { + var id = IdOf(await database.CreateAsync(by, new() { ["name"] = "Ann" }, "author", locale)); + await database.PublishAsync(Editor, id, locale, type: "author", operationLocale: locale); + return id; + } + + async Task ShouldReadAsync(Guid target, string locale, bool visible) + { + var id = IdOf(await database.CreateAsync(Editor, new() { ["title"] = Guid.NewGuid().ToString("N"), ["author"] = target, ["cards"] = new object[] { new ContentDocument { ["type"] = "card", ["who"] = target } } }, "article")); + await database.PublishAsync(Editor, id, type: "article"); + + foreach (var read in new[] + { + await database.GetAsync(reader, id, null, "article", locale), + await database.GetAsync(reader, id, new GetOptions { Status = QueryStatus.Published }, "article", locale), + await database.VersionAsync(reader, id, 1, locale, "article"), + }) + { + (read!["author"] is not null).ShouldBe(visible); + Text(read["cards"]).Contains(target.ToString(), StringComparison.Ordinal).ShouldBe(visible); + } + } + + var refused = await AuthorInAsync(Editor, "fr"); + var own = await AuthorInAsync(reader, "fr"); + var open = await AuthorInAsync(Editor, "en"); + + // Read in en, which has no rule: only what that grant reaches counts, and a row in fr tells nothing either way. + await ShouldReadAsync(refused, "en", visible: false); + await ShouldReadAsync(own, "en", visible: false); + await ShouldReadAsync(Guid.CreateVersion7(), "en", visible: false); + await ShouldReadAsync(open, "en", visible: true); + + // Read in fr, where the rule is asked of the row in fr: a target only in en has none to ask. + await ShouldReadAsync(refused, "fr", visible: false); + await ShouldReadAsync(own, "fr", visible: true); + await ShouldReadAsync(Guid.CreateVersion7(), "fr", visible: false); + await ShouldReadAsync(open, "fr", visible: false); + } + // Whatever X is, a principal limited on the type keeps it, and publishing then says the same. private async Task> GhostsAsync() {