diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca1dda62d..d332a1ac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot ## Background jobs -Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. +Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler that names no engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. diff --git a/docs/adr/0012-system-initiator-for-engine-driven-actions.md b/docs/adr/0012-system-initiator-for-engine-driven-actions.md index 30458360b..2ef11dc0c 100644 --- a/docs/adr/0012-system-initiator-for-engine-driven-actions.md +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -21,10 +21,12 @@ These run inside a request or as a job (ADR-0010), and a job runs as whoever enq - It is never a change set's initiator. `ChangeSet.Open` refuses it, and so do session grouping and revert, which open sets through it. - `AgentPermissions` treats it as not an agent and lets it make only the moves its component owns. `checks` moves a set from checks running to checks failed or awaiting review; routing will add auto-publish. - It reads content, and never creates, edits or deletes content, opens or adds to a change set, or calls a tool. - - What only a person may do refuses a system initiator as it refuses an agent: managing users, issuing credentials, and activating, writing or revising checks. + - What only a person may do refuses a system initiator as it refuses an agent: managing users and issuing their reset links, issuing and revoking credentials, and activating or deactivating checks. An engine cannot write or revise a check either, though an agent may propose one. - Maintenance names what it may do: the prompt purge accepts `retention` as well as a person. - - It spends no model budget of its own. A model call it makes for an agent's change set is charged to that agent (SEC-04). + - It spends no model budget of its own. A model call it makes for a change set is made on behalf of whoever opened the set, and an agent's counts against that agent's cap (SEC-04). - A job type can run as a system initiator, whoever enqueued the job (`IJobHandler.RunAs`). The job still records who enqueued it. The check run is enqueued in the submitting agent's call and runs as `checks`. The job type decides this, not the code that enqueues it, so no caller can borrow an engine's permissions. +- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, a stored row reads back only as one of the product's engines, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails, without running it, a job whose handler claims an engine it is not listed for, and a job an engine enqueued for a handler that names no engine. A listed handler runs as its own engine whoever enqueued its job, since the job type decides. So a plugin's job handler (EXT-03) never runs as an engine, whatever its `RunAs` says or whoever enqueued its job. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. +- Check types run inside the check engine's scope, a plugin's as well as Core's, so the content they read is read as the engine and what they could do is what the engine may. The engine's limits above bound them; per-plugin permissions come with the plugin SDK (EXT-03). ## Consequences diff --git a/src/FluentCMS.Core/Audit/InitiatorKinds.cs b/src/FluentCMS.Core/Audit/InitiatorKinds.cs index ba9dfef78..13a4b9e09 100644 --- a/src/FluentCMS.Core/Audit/InitiatorKinds.cs +++ b/src/FluentCMS.Core/Audit/InitiatorKinds.cs @@ -29,17 +29,12 @@ public static string NameOf(Initiator initiator) return initiator is SystemInitiator system ? system.Component : initiator.DisplayName; } - // A stored system initiator, refused when its id is not the one its component derives, as a stored kind nobody knows is. + // A stored system initiator: one of the product's engines, with the id its component derives. Any other is refused as a fault, + // as a stored kind nobody knows is, so reading a row never makes up an engine. public static SystemInitiator RestoreSystem(string component, Guid id) { - try - { - var system = new SystemInitiator(component); - return system.Id == id ? system : throw new InvalidOperationException($"The system component '{component}' is stored with an id that is not its own."); - } - catch (ArgumentException exception) - { - throw new InvalidOperationException($"'{component}' is stored as a system component, and it is not a component name.", exception); - } + var system = SystemInitiator.Engines.FirstOrDefault(engine => engine.Component == component) + ?? throw new InvalidOperationException($"'{component}' is stored as a system component, and the product has no engine of that name."); + return system.Id == id ? system : throw new InvalidOperationException($"The system component '{component}' is stored with an id that is not its own."); } } diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 5a5ac3061..b44b33737 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -1,9 +1,12 @@ using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; +using FluentCMS.Core.Builtin.Jobs; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Desk; using FluentCMS.Core.Extensions; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; using FluentCMS.Core.Media; using FluentCMS.Core.Models; using FluentCMS.Core.Schema; @@ -48,6 +51,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Writes through IEntryDirectory, IContentTypeDirectory and IChangeSetDirectory in one unit of work, which Infrastructure provides. services.AddScoped(); + // The handlers that run as an engine; a job worker runs any other handler as whoever enqueued its job (ADR-0012). + services.AddSingleton(new EngineJobs(new Dictionary { [typeof(CheckRunJob)] = SystemInitiator.Checks })); + return services; } } diff --git a/src/FluentCMS.Core/Identity/Initiator.cs b/src/FluentCMS.Core/Identity/Initiator.cs index a2f35f81a..4086c3d5c 100644 --- a/src/FluentCMS.Core/Identity/Initiator.cs +++ b/src/FluentCMS.Core/Identity/Initiator.cs @@ -16,7 +16,8 @@ public sealed record AgentInitiator(Guid CredentialId, string DisplayName) : Ini // make only the moves its component owns and read content, never write it. public sealed record SystemInitiator : Initiator { - public SystemInitiator(string component) + // Only Core names its engines, so no other code makes up one to act as. + internal SystemInitiator(string component) : base(component) { if (!SchemaNames.IsSlug(component)) @@ -34,6 +35,9 @@ public SystemInitiator(string component) // Maintenance that clears data past its retention, such as stored prompts (AUD-04). public static SystemInitiator Retention { get; } = new("retention"); + // The product's engines; a stored row naming any other is a fault, not an engine. + internal static IReadOnlyList Engines => [Checks, Retention]; + public string Component { get; } // The same for a component every time, so the audit log names one engine one way in every row. diff --git a/src/FluentCMS.Core/Jobs/EngineJobs.cs b/src/FluentCMS.Core/Jobs/EngineJobs.cs new file mode 100644 index 000000000..15d4f0641 --- /dev/null +++ b/src/FluentCMS.Core/Jobs/EngineJobs.cs @@ -0,0 +1,41 @@ +using System.Collections.Frozen; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Jobs; + +// The job handlers that run as an engine, each with the one engine it runs as (ADR-0012). Only Core lists them, so no other +// handler, a plugin's included (EXT-03), borrows an engine's permissions by naming it in RunAs. +public sealed class EngineJobs +{ + private readonly FrozenDictionary _engines; + + internal EngineJobs(IReadOnlyDictionary engines) + { + _engines = engines.ToFrozenDictionary(); + } + + // The engine a job runs as, null to run it as whoever enqueued it, or a failure, so the worker fails the job instead of running + // it as anyone: when its handler names an engine it is not listed for, or when an engine enqueued it for a handler not listed for + // that engine, as code running inside an engine's scope might, to pass the engine on. + public Result RunAs(IJobHandler handler, Initiator? enqueuedBy) + { + ArgumentNullException.ThrowIfNull(handler); + + var listed = _engines.GetValueOrDefault(handler.GetType()); + if (handler.RunAs is { } claimed) + { + return listed == claimed + ? Result.Success(claimed) + : Result.Failure( + JobErrorCodes.EngineNotAllowed, + $"The handler of '{handler.Type}' jobs claims to run as the '{claimed.Component}' engine, which only Core's own handlers may."); + } + + return enqueuedBy is SystemInitiator engine && listed != engine + ? Result.Failure( + JobErrorCodes.EngineNotAllowed, + $"The '{engine.Component}' engine enqueued a '{handler.Type}' job, and an engine's jobs run only through the handlers Core lists for it.") + : Result.Success(null); + } +} diff --git a/src/FluentCMS.Core/Jobs/JobErrorCodes.cs b/src/FluentCMS.Core/Jobs/JobErrorCodes.cs index d2692c21e..a8ed825fe 100644 --- a/src/FluentCMS.Core/Jobs/JobErrorCodes.cs +++ b/src/FluentCMS.Core/Jobs/JobErrorCodes.cs @@ -3,4 +3,5 @@ namespace FluentCMS.Core.Jobs; public static class JobErrorCodes { public const string JobNotFound = "job_not_found"; + public const string EngineNotAllowed = "job_engine_not_allowed"; } diff --git a/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs b/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs index 2b90ba4e8..8978f3bed 100644 --- a/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs +++ b/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs @@ -15,6 +15,7 @@ internal sealed class IdentityPasswordReset( IUnitOfWork unitOfWork, IAuditLog auditLog, IHttpContextAccessor accessor, + ICurrentInitiator currentInitiator, IOptions tokenOptions) : IPasswordReset { public const string LifespanSetting = "PasswordReset:TokenLifespan"; @@ -28,6 +29,14 @@ internal sealed class IdentityPasswordReset( public async Task> IssueAsync(Guid userId, CancellationToken cancellationToken = default) { + // A reset link takes over the account it resets, so only a person issues one, never an agent or an engine (ADR-0012). + if (currentInitiator.Current is AgentInitiator or SystemInitiator) + { + return Result.Failure( + UserErrorCodes.PeopleOnly, + $"Only a person can issue a password reset link; {currentInitiator.Current.DisplayName} cannot."); + } + var users = signInManager.UserManager; var user = await users.FindByIdAsync(userId.ToString()); if (user is null) diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index 56b641ecb..e308e1852 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -74,7 +74,17 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } - runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; + // Only a handler Core lists runs as an engine, or a job an engine enqueued; any other claim fails, run as no one (ADR-0012). + var runAs = runScope.ServiceProvider.GetRequiredService().RunAs(handler, job.Initiator); + if (!runAs.IsSuccess) + { + var refused = runAs.Errors[0]; + logger.LogWarning("Job {JobId} of the type {JobType} was not run: {Reason}", job.Id, job.Type, refused.Message); + await leases.FailAsync(job.Id, owner, $"{refused.Code}: {refused.Message}", stoppingToken); + return true; + } + + runScope.ServiceProvider.GetRequiredService().Initiator = runAs.Value ?? job.Initiator; var context = new JobContext( job, diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs index 5f36aa095..3232299b1 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; @@ -15,6 +16,7 @@ using FluentCMS.IntegrationTests.Audit; using FluentCMS.IntegrationTests.Checks; using FluentCMS.IntegrationTests.Models; +using FluentCMS.IntegrationTests.Mcp; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -78,6 +80,35 @@ public async Task An_engine_cannot_write_or_activate_a_check_issue_a_credential_ Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(user.Errors).Code); } + // An engine is refused the other moves only people make: grouping an operation into a session's set, disabling and enabling a + // user, revoking a credential and deactivating a check. Each refusal comes before anything is read, so no item needs to exist. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_engine_cannot_group_a_session_manage_users_revoke_a_credential_or_deactivate_a_check(string provider) + { + await using var scenario = await CheckScenario.CreateAsync(provider); + await scenario.CreateAsync("no-superlatives", CheckScenario.NoSuperlatives()); + scenario.Initiator = SystemInitiator.Checks; + await using var scope = scenario.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + + var grouped = await services.GetRequiredService().AddToSessionAsync( + "tidy-up", + new NewChangeSet("Tidy up.", []), + ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, Guid.NewGuid(), null, 1).Value); + var disabled = await services.GetRequiredService().DisableAsync(Guid.NewGuid()); + var enabled = await services.GetRequiredService().EnableAsync(Guid.NewGuid()); + var revoked = await services.GetRequiredService().RevokeAsync(Guid.NewGuid()); + var deactivated = await scenario.RunAsync(directory => directory.DeactivateAsync("no-superlatives")); + + Assert.Equal(ChangeSetErrorCodes.InitiatorNotAllowed, Assert.Single(grouped.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(disabled.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(enabled.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.PeopleOnly, Assert.Single(revoked.Errors).Code); + Assert.Equal(CheckErrorCodes.PeopleOnly, Assert.Single(deactivated.Errors).Code); + } + // AUD-04: the retention purge runs as the retention engine, and no other engine may purge. [Theory] [InlineData(DatabaseProviders.Sqlite)] @@ -120,34 +151,99 @@ public async Task A_job_an_engine_enqueues_is_stored_as_that_engine(string provi Assert.Equal(SystemInitiator.Retention, (await services.GetRequiredService().GetAsync(enqueued.Id)).Value.Initiator); } - // A submission enqueues the check run in the agent's call; the job type, not whoever enqueued it, decides that it runs as the - // check engine, and the job still records who enqueued it. - [Fact] - public async Task The_worker_runs_a_job_as_the_engine_its_handler_names() + // EXT-03: a handler Core does not list cannot borrow an engine's permissions by naming it in RunAs; its job fails without running, + // and says why. The check run, which Core lists, runs as the check engine (ChangeSetChecksTests). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_handler_that_claims_an_engine_it_is_not_listed_for_fails_its_job_unrun(string provider) { - using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync( - install, - settings: new Dictionary { ["JobQueue:PollingInterval"] = "00:00:00.050" }, + await using var agent = await ConnectedAgent.StartAsync( + provider, + settings: FastQueue, configure: builder => builder.Services.AddFluentCmsExtension()); - var administrator = await host.CreateFirstAdministratorAsync(); - var job = await host.AsUserAsync(administrator, async services => + var job = await agent.AsAgentAsync(services => EnqueueAsync(services, EngineHandler.Name)); + var ended = await EndedAsync(agent, job.Id); + + Assert.Equal(JobState.Failed, ended.State); + Assert.StartsWith(JobErrorCodes.EngineNotAllowed, ended.LastError); + Assert.False(EngineHandler.RanAs.ContainsKey(job.Id)); + } + + // ADR-0012: a reset link takes over the account it resets, so only a person issues one. Code running inside the check engine's + // scope, as a check type does, is refused, and cannot pass the engine on to a job of a handler Core does not list for it either. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Code_running_as_an_engine_cannot_issue_a_reset_link_or_pass_the_engine_to_a_job(string provider) + { + // The probe's records are shared by both providers' runs, which run one after the other. + EngineProbe.Refusals.Clear(); + EngineProbe.Enqueued.Clear(); + Follower.Ran.Clear(); + await using var agent = await ConnectedAgent.StartAsync( + provider, + settings: FastQueue, + configure: builder => + { + builder.Services.AddFluentCmsExtension(); + builder.Services.AddFluentCmsExtension(); + }); + await agent.CreateAsPersonAsync("probe"); + var definition = new CheckDefinition( + EngineProbe.Name, + "Probes what the check engine may do.", + JsonSerializer.SerializeToElement(new { }), + [ + new CheckExample("passes", CheckVerdict.Pass, JsonSerializer.SerializeToElement(new { title = "A kettle" }), contentType: "probe"), + new CheckExample("fails", CheckVerdict.Fail, JsonSerializer.SerializeToElement(new { title = "fail" }), contentType: "probe"), + ], + scope: new CheckScope(["probe"])); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("engine-probe", definition))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("engine-probe", 1)); + var byAgent = await agent.AsAgentAsync(services => services.GetRequiredService().IssueAsync(Guid.NewGuid())); + + var created = await agent.CallAsync("create_entry", new() { - await using var work = await services.GetRequiredService().BeginAsync(); - var enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob(EngineHandler.Name, JsonDocument.Parse("{}").RootElement.Clone())); - await work.CommitAsync(); - return enqueued; + ["content_type"] = "probe", + ["fields"] = new JsonObject { ["title"] = "A kettle" }, + ["session_id"] = "probe", + ["rationale"] = "Probe the engine.", }); + await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)created["changeSet"]!["id"] }); + var followed = await EndedAsync(agent, Assert.Single(EngineProbe.Enqueued)); + + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(byAgent.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(EngineProbe.Refusals)); + Assert.Equal(JobState.Failed, followed.State); + Assert.StartsWith(JobErrorCodes.EngineNotAllowed, followed.LastError); + Assert.Empty(Follower.Ran); + Assert.DoesNotContain(await agent.QueryAsync(context => context.AuditEvents.AsNoTracking().ToListAsync()), row => row.Operation == "user.password_reset_issued"); + } + private static readonly Dictionary FastQueue = new() { ["JobQueue:PollingInterval"] = "00:00:00.050" }; + + private static async Task EnqueueAsync(IServiceProvider services, string type) + { + await using var work = await services.GetRequiredService().BeginAsync(); + var enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob(type, JsonDocument.Parse("{}").RootElement.Clone())); + await work.CommitAsync(); + return enqueued; + } + + private static async Task EndedAsync(ConnectedAgent agent, Guid jobId) + { var deadline = DateTime.UtcNow.AddSeconds(30); - while (!EngineHandler.RanAs.ContainsKey(job.Id) && DateTime.UtcNow < deadline) + Job read; + do { await Task.Delay(50); + read = await agent.AsAgentAsync(async services => (await services.GetRequiredService().GetAsync(jobId)).Value); } + while (read.State is JobState.Queued or JobState.Running && DateTime.UtcNow < deadline); - Assert.Equal(SystemInitiator.Checks, EngineHandler.RanAs[job.Id]); - Assert.IsType(job.Initiator); + return read; } // A component's name and id are stored together, and a row whose id is not its component's is refused as a fault. @@ -165,6 +261,37 @@ public async Task An_engine_is_stored_by_its_component_whatever_its_display_name Assert.Equal("checks", Assert.IsType(read.Initiator).Component); Assert.Throws(() => InitiatorKinds.RestoreSystem("checks", SystemInitiator.Retention.Id)); Assert.Throws(() => InitiatorKinds.RestoreSystem("Not A Component", Guid.NewGuid())); + + // Read back through the log, a row whose id is not its component's is a fault, not an engine. + await QueryAsync(scenario, context => context.AuditEvents + .Where(row => row.Operation == "checks.renamed") + .ExecuteUpdateAsync(row => row.SetProperty(stored => stored.InitiatorId, SystemInitiator.Retention.Id))); + await Assert.ThrowsAsync(() => scenario.ReadAsync(new AuditQuery { Subject = Set })); + } + + // A job row whose engine id is not its component's is refused when it is read, as an audit row is. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_stored_with_another_engine_s_id_is_refused_when_it_is_read(string provider) + { + await using var scenario = await CheckScenario.CreateAsync(provider); + scenario.Initiator = SystemInitiator.Retention; + await using var scope = scenario.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + Job enqueued; + await using (var work = await services.GetRequiredService().BeginAsync()) + { + enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob("retention.purge", JsonDocument.Parse("{}").RootElement.Clone())); + await work.CommitAsync(); + } + + await scenario.QueryAsync(context => context.Jobs + .Where(row => row.Id == enqueued.Id) + .ExecuteUpdateAsync(row => row.SetProperty(stored => stored.InitiatorId, SystemInitiator.Checks.Id))); + + await using var reading = scenario.Services.CreateAsyncScope(); + await Assert.ThrowsAsync(() => reading.ServiceProvider.GetRequiredService().GetAsync(enqueued.Id)); } private static async Task QueryAsync(AuditScenario scenario, Func> query) @@ -173,6 +300,53 @@ private static async Task QueryAsync(AuditScenario scenario, Func()); } + // A check type that, run as the check engine, tries what only a person does and to enqueue a job for a handler Core does not list. + private sealed class EngineProbe : ICheckType + { + public const string Name = "sample.engineProbe"; + + public static ConcurrentQueue Refusals { get; } = new(); + + public static ConcurrentQueue Enqueued { get; } = new(); + + string ICheckType.Name => Name; + + public CheckKind Kind => CheckKind.Deterministic; + + public IReadOnlyList ValidateSettings(JsonElement settings) + { + return new CheckSettingsReader(Name, settings).Errors(); + } + + public async Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) + { + var services = input.Services; + if (services.GetRequiredService().Current is SystemInitiator) + { + var reset = await services.GetRequiredService().IssueAsync(Guid.NewGuid(), cancellationToken); + Refusals.Enqueue(reset.Errors.FirstOrDefault()?.Code ?? "issued"); + Enqueued.Enqueue((await EnqueueAsync(services, Follower.Name)).Id); + } + + return input.Subject.Fields.GetRawText().Contains("fail", StringComparison.Ordinal) ? CheckOutcome.Fail("Remove 'fail'.") : CheckOutcome.Pass(); + } + } + + private sealed class Follower : IJobHandler + { + public const string Name = "test.follower"; + + public static ConcurrentQueue Ran { get; } = new(); + + public string Type => Name; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + Ran.Enqueue(context.Job.Id); + return Task.FromResult(Result.Success()); + } + } + private sealed class EngineHandler : IJobHandler { public const string Name = "test.engine"; diff --git a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs index 0786647d9..217e76049 100644 --- a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs @@ -15,11 +15,13 @@ public sealed class SystemInitiatorTests [Fact] public void A_component_has_one_stable_id_and_the_audit_log_names_it_system() { - Assert.Equal(SystemInitiator.Checks.Id, new SystemInitiator("checks").Id); + Assert.Equal(SystemInitiator.Checks, InitiatorKinds.RestoreSystem("checks", SystemInitiator.Checks.Id)); Assert.NotEqual(SystemInitiator.Checks.Id, SystemInitiator.Retention.Id); Assert.Equal(("checks", "checks"), (SystemInitiator.Checks.Component, SystemInitiator.Checks.DisplayName)); Assert.Equal(("system", SystemInitiator.Retention.Id), InitiatorKinds.Describe(SystemInitiator.Retention)); - Assert.Throws(() => new SystemInitiator("Check Engine")); + Assert.Throws(() => InitiatorKinds.RestoreSystem("Check Engine", SystemInitiator.Checks.Id)); + Assert.Throws(() => InitiatorKinds.RestoreSystem("billing", Guid.NewGuid())); + Assert.DoesNotContain(typeof(SystemInitiator).GetConstructors(), constructor => constructor.GetParameters() is [{ ParameterType: var type }] && type == typeof(string)); } // CHG-01: a change set always has a person or an agent behind it. diff --git a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs new file mode 100644 index 000000000..6d41dceb8 --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs @@ -0,0 +1,61 @@ +using FluentCMS.Core; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Jobs; + +// ADR-0012, EXT-03: a handler runs as an engine only if Core lists it as that engine's; naming one in RunAs is not enough. +public sealed class EngineJobsTests +{ + [Fact] + public void A_handler_that_names_no_engine_runs_as_whoever_enqueued_its_job() + { + Assert.Null(Engines().RunAs(new Handler(null), new UserInitiator(Guid.NewGuid(), "Amir")).Value); + } + + [Fact] + public void The_check_run_runs_as_the_check_engine() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + var checkRun = provider.GetServices().Single(handler => handler.Type == "checks.run"); + + Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(checkRun, new AgentInitiator(Guid.NewGuid(), "agent")).Value); + } + + [Fact] + public void A_handler_Core_does_not_list_cannot_claim_an_engine() + { + var claimed = Engines().RunAs(new Handler(SystemInitiator.Checks), null); + + var error = Assert.Single(claimed.Errors); + Assert.Equal(JobErrorCodes.EngineNotAllowed, error.Code); + Assert.Contains("'checks' engine", error.Message); + } + + // Code running inside an engine's scope, such as a check type, enqueues as that engine; the job still runs only through a + // handler Core lists for the engine, so the engine is not passed on. + [Fact] + public void A_job_an_engine_enqueued_for_a_handler_Core_does_not_list_is_refused() + { + var passedOn = Engines().RunAs(new Handler(null), SystemInitiator.Checks); + + Assert.Equal(JobErrorCodes.EngineNotAllowed, Assert.Single(passedOn.Errors).Code); + } + + private static EngineJobs Engines() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private sealed class Handler(SystemInitiator? runAs) : IJobHandler + { + public string Type => "sample.claims"; + + public SystemInitiator? RunAs => runAs; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) => Task.FromResult(Result.Success()); + } +}