diff --git a/AGENTS.md b/AGENTS.md index 0cb11e0b62..767620b536 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ The libraries integrate with: - Use internal accessibility on types by default - Use sealed for classes unless we consider inheritance a valid use-case - Use Nullable Reference Types +- Don't use null forgiveness operator without justification - Remember to dispose `IDisposable`/`IAsyncDisposable` instances - We want to minimize external dependencies - For HTTP APIs we should have `...Request` and `...Response` DTOs (see `LookupPersonRequest.cs` and the corresponding response as an example) diff --git a/src/Altinn.App.Api/Controllers/InstancesController.cs b/src/Altinn.App.Api/Controllers/InstancesController.cs index c11d3a1244..fd1ac0f32c 100644 --- a/src/Altinn.App.Api/Controllers/InstancesController.cs +++ b/src/Altinn.App.Api/Controllers/InstancesController.cs @@ -334,7 +334,7 @@ public async Task> Post( Instance instance; instanceTemplate.Process = null; - ProcessStateChange? change = null; + ProcessStateChange change; try { @@ -381,9 +381,18 @@ await _instanceClient.DeleteInstance( Guid.Parse(instance.Id.Split("/")[1]) ); - // notify app and store events - _logger.LogInformation("Events sent to process engine: {Events}", change?.Events); - await _processEngine.HandleEventsAndUpdateStorage(instance, null, change?.Events); + ProcessChangeResult startProcessResult = await _processEngine.Start( + instance, + change, + User, + language: language, + ct: HttpContext.RequestAborted + ); + + if (!startProcessResult.Success) + return Conflict(startProcessResult.ErrorMessage); + + instance = startProcessResult.MutatedInstance; } catch (Exception exception) { @@ -565,6 +574,9 @@ public async Task> PostSimplified( processResult = await _processEngine.GenerateProcessStartEvents(request); + if (!processResult.Success) + return Conflict(processResult.ErrorMessage); + Instance? source = null; if (isCopyRequest) @@ -598,11 +610,20 @@ public async Task> PostSimplified( } instance = await _instanceClient.GetInstance(instance); - await _processEngine.HandleEventsAndUpdateStorage( + + var startProcessResult = await _processEngine.Start( instance, - instansiationInstance.Prefill, - processResult.ProcessStateChange?.Events + processResult.ProcessStateChange, + User, + prefill: instansiationInstance.Prefill, + language: language, + ct: HttpContext.RequestAborted ); + + if (!startProcessResult.Success) + return Conflict(startProcessResult.ErrorMessage); + + instance = startProcessResult.MutatedInstance; } catch (Exception exception) { @@ -724,7 +745,21 @@ public async Task CopyInstance( targetInstance = await _instanceClient.GetInstance(targetInstance); - await _processEngine.HandleEventsAndUpdateStorage(targetInstance, null, startResult.ProcessStateChange?.Events); + if (!startResult.Success) + return Conflict(startResult.ErrorMessage); + + var startProcessResult = await _processEngine.Start( + targetInstance, + startResult.ProcessStateChange, + User, + language: language, + ct: HttpContext.RequestAborted + ); + + if (!startProcessResult.Success) + return Conflict(startProcessResult.ErrorMessage); + + targetInstance = startProcessResult.MutatedInstance; await RegisterEvent("app.instance.created", targetInstance); diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index af08623f2b..ba2e6e5dfc 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -147,12 +147,20 @@ public async Task> StartProcess( return Conflict(result.ErrorMessage); } - await _processEngine.HandleEventsAndUpdateStorage(instance, null, result.ProcessStateChange?.Events); - - AppProcessState appProcessState = await ConvertAndAuthorizeActions( + var startProcessResult = await _processEngine.Start( instance, - result.ProcessStateChange?.NewProcessState + result.ProcessStateChange, + User, + ct: HttpContext.RequestAborted ); + if (!startProcessResult.Success) + { + return GetResultForError(startProcessResult); + } + + instance = startProcessResult.MutatedInstance; + + AppProcessState appProcessState = await ConvertAndAuthorizeActions(instance, instance.Process); return Ok(appProcessState); } catch (PlatformHttpException e) diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Processes.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Processes.cs index 95ed301b89..d704f49f5c 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Processes.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Processes.cs @@ -41,6 +41,13 @@ internal void ProcessEnded(ProcessStateChange processChange) return activity; } + internal Activity? StartProcessStartExecutionActivity(Instance instance) + { + var activity = ActivitySource.StartActivity($"{Prefix}.StartExecution"); + activity?.SetInstanceId(instance); + return activity; + } + internal Activity? StartProcessNextActivity(Instance instance, string? action) { var activity = ActivitySource.StartActivity($"{Prefix}.Next"); diff --git a/src/Altinn.App.Core/Internal/Process/Interfaces/IProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/Interfaces/IProcessEngine.cs index f5018b5524..995352d263 100644 --- a/src/Altinn.App.Core/Internal/Process/Interfaces/IProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/Interfaces/IProcessEngine.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; using Altinn.App.Core.Models.Process; using Altinn.Platform.Storage.Interface.Models; @@ -35,4 +36,17 @@ Task HandleEventsAndUpdateStorage( Dictionary? prefill, List? events ); + + /// + /// Dispatches process start events to storage and auto-runs any initial service tasks. + /// Call after instance creation and data storage, with the result from . + /// + Task Start( + Instance instance, + ProcessStateChange processStateChange, + ClaimsPrincipal user, + Dictionary? prefill = null, + string? language = null, + CancellationToken ct = default + ); } diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 375bde1c83..56cc513d8d 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; @@ -149,6 +150,46 @@ out ProcessError? startEventError return changeResult; } + /// + public async Task Start( + Instance instance, + ProcessStateChange processStateChange, + ClaimsPrincipal user, + Dictionary? prefill = null, + string? language = null, + CancellationToken ct = default + ) + { + using var activity = _telemetry?.StartProcessStartExecutionActivity(instance); + + // HandleEvents mutates instance in-place; we discard the DispatchToStorage + // return (same as callers did before this method existed). + bool isServiceTask = IsServiceTask(instance); + await HandleEventsAndUpdateStorage(instance, isServiceTask ? null : prefill, processStateChange.Events); + + if (!isServiceTask) + { + var result = new ProcessChangeResult(instance) { Success = true, ProcessStateChange = processStateChange }; + activity?.SetProcessChangeResult(result); + return result; + } + + // Auto-run through initial service tasks, forwarding prefill to the first user task + var nextResult = await Next( + new ProcessNextRequest + { + Instance = instance, + User = user, + Action = null, + Language = language, + Prefill = prefill, + }, + ct + ); + activity?.SetProcessChangeResult(nextResult); + return nextResult; + } + /// public async Task Next(ProcessNextRequest request, CancellationToken ct = default) { @@ -196,6 +237,7 @@ public async Task Next(ProcessNextRequest request, Cancella Action = firstIteration ? request.Action : null, ActionOnBehalfOf = firstIteration ? request.ActionOnBehalfOf : null, Language = request.Language, + Prefill = request.Prefill, }; result = await ProcessNext(processNextRequest, ct); @@ -360,7 +402,7 @@ serviceTaskResult is ServiceTaskFailedResult } } - MoveToNextResult moveToNextResult = await HandleMoveToNext(instance, processNextAction); + MoveToNextResult moveToNextResult = await HandleMoveToNext(instance, processNextAction, request.Prefill); if (moveToNextResult.IsEndEvent) { @@ -747,7 +789,11 @@ private async Task GenerateProcessChangeEvent(string eventType, I return instanceEvent; } - private async Task HandleMoveToNext(Instance instance, string? action) + private async Task HandleMoveToNext( + Instance instance, + string? action, + Dictionary? prefill = null + ) { ProcessStateChange? processStateChange = await MoveProcessStateToNextAndGenerateEvents(instance, action); @@ -756,7 +802,10 @@ private async Task HandleMoveToNext(Instance instance, string? return new MoveToNextResult(instance, null); } - instance = await HandleEventsAndUpdateStorage(instance, null, processStateChange.Events); + // Only apply prefill when the destination is a user task, not a service task. + // Service tasks can have data types too, and we don't want prefill applied to them. + Dictionary? prefillForTask = IsServiceTask(instance) ? null : prefill; + instance = await HandleEventsAndUpdateStorage(instance, prefillForTask, processStateChange.Events); await _processEventDispatcher.RegisterEventWithEventsComponent(instance); return new MoveToNextResult(instance, processStateChange); diff --git a/src/Altinn.App.Core/Models/Process/ProcessChangeResult.cs b/src/Altinn.App.Core/Models/Process/ProcessChangeResult.cs index 9d9408aed1..1123cc8abf 100644 --- a/src/Altinn.App.Core/Models/Process/ProcessChangeResult.cs +++ b/src/Altinn.App.Core/Models/Process/ProcessChangeResult.cs @@ -13,6 +13,7 @@ public class ProcessChangeResult /// Gets or sets a value indicating whether the process change was successful /// [MemberNotNullWhen(true, nameof(ProcessStateChange))] + [MemberNotNullWhen(true, nameof(MutatedInstance))] [MemberNotNullWhen(false, nameof(ErrorMessage), nameof(ErrorType))] public bool Success { get; init; } diff --git a/src/Altinn.App.Core/Models/Process/ProcessNextRequest.cs b/src/Altinn.App.Core/Models/Process/ProcessNextRequest.cs index 1edaacbfde..4691d86982 100644 --- a/src/Altinn.App.Core/Models/Process/ProcessNextRequest.cs +++ b/src/Altinn.App.Core/Models/Process/ProcessNextRequest.cs @@ -32,4 +32,10 @@ public sealed record ProcessNextRequest /// The language the user sent with process/next (not required) /// public required string? Language { get; init; } + + /// + /// Prefill data to apply when the next user task starts. + /// Used during instantiation to forward prefill past initial service tasks. + /// + public Dictionary? Prefill { get; init; } } diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesControllerFixture.cs b/test/Altinn.App.Api.Tests/Controllers/InstancesControllerFixture.cs index 8ccdba5245..3845a58887 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesControllerFixture.cs +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesControllerFixture.cs @@ -133,6 +133,7 @@ internal static InstancesControllerFixture Create(Authenticated? auth = null) services.AddSingleton(new Mock(MockBehavior.Strict).Object); var httpContextMock = new Mock(MockBehavior.Strict); + httpContextMock.Setup(hc => hc.RequestAborted).Returns(CancellationToken.None); services.AddTransient(_ => httpContextMock.Object); services.AddTransient(); diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_CopyInstanceTests.cs b/test/Altinn.App.Api.Tests/Controllers/InstancesController_CopyInstanceTests.cs index 5f984c0978..5edfa97201 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_CopyInstanceTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_CopyInstanceTests.cs @@ -379,16 +379,35 @@ public async Task CopyInstance_EverythingIsFine_ReturnsRedirect() .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) .ReturnsAsync(() => { - return new ProcessChangeResult() { Success = true }; + return new ProcessChangeResult() { Success = true, ProcessStateChange = new() }; }); fixture .Mock() .Setup(p => - p.HandleEventsAndUpdateStorage( + p.Start( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny>(), - It.IsAny>() + It.IsAny(), + It.IsAny() ) + ) + .ReturnsAsync( + ( + Instance inst, + ProcessStateChange _, + System.Security.Claims.ClaimsPrincipal _, + Dictionary? _, + string? _, + CancellationToken _ + ) => + new ProcessChangeResult + { + Success = true, + MutatedInstance = inst, + ProcessStateChange = new(), + } ); fixture .Mock() @@ -500,15 +519,34 @@ public async Task CopyInstance_WithBinaryData_CopiesBothFormAndBinaryData() fixture .Mock() .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) - .ReturnsAsync(() => new ProcessChangeResult() { Success = true }); + .ReturnsAsync(() => new ProcessChangeResult() { Success = true, ProcessStateChange = new() }); fixture .Mock() .Setup(p => - p.HandleEventsAndUpdateStorage( + p.Start( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny>(), - It.IsAny>() + It.IsAny(), + It.IsAny() ) + ) + .ReturnsAsync( + ( + Instance inst, + ProcessStateChange _, + System.Security.Claims.ClaimsPrincipal _, + Dictionary? _, + string? _, + CancellationToken _ + ) => + new ProcessChangeResult + { + Success = true, + MutatedInstance = inst, + ProcessStateChange = new(), + } ); // Form data mocks @@ -699,15 +737,34 @@ public async Task CopyInstance_WithExcludedBinaryDataType_SkipsExcludedType() fixture .Mock() .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) - .ReturnsAsync(() => new ProcessChangeResult() { Success = true }); + .ReturnsAsync(() => new ProcessChangeResult() { Success = true, ProcessStateChange = new() }); fixture .Mock() .Setup(p => - p.HandleEventsAndUpdateStorage( + p.Start( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny>(), - It.IsAny>() + It.IsAny(), + It.IsAny() ) + ) + .ReturnsAsync( + ( + Instance inst, + ProcessStateChange _, + System.Security.Claims.ClaimsPrincipal _, + Dictionary? _, + string? _, + CancellationToken _ + ) => + new ProcessChangeResult + { + Success = true, + MutatedInstance = inst, + ProcessStateChange = new(), + } ); // Form data mocks (should be copied) @@ -897,15 +954,34 @@ public async Task CopyInstance_IncludeAttachmentsIsTrue_CopiesBinaryData() fixture .Mock() .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) - .ReturnsAsync(() => new ProcessChangeResult() { Success = true }); + .ReturnsAsync(() => new ProcessChangeResult() { Success = true, ProcessStateChange = new() }); fixture .Mock() .Setup(p => - p.HandleEventsAndUpdateStorage( + p.Start( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny>(), - It.IsAny>() + It.IsAny(), + It.IsAny() ) + ) + .ReturnsAsync( + ( + Instance inst, + ProcessStateChange _, + System.Security.Claims.ClaimsPrincipal _, + Dictionary? _, + string? _, + CancellationToken _ + ) => + new ProcessChangeResult + { + Success = true, + MutatedInstance = inst, + ProcessStateChange = new(), + } ); // Binary data mocks (should be called) @@ -1102,15 +1178,34 @@ public async Task CopyInstance_OnlyBinaryData_NotCopiedByDefault() fixture .Mock() .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) - .ReturnsAsync(() => new ProcessChangeResult() { Success = true }); + .ReturnsAsync(() => new ProcessChangeResult() { Success = true, ProcessStateChange = new() }); fixture .Mock() .Setup(p => - p.HandleEventsAndUpdateStorage( + p.Start( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny>(), - It.IsAny>() + It.IsAny(), + It.IsAny() ) + ) + .ReturnsAsync( + ( + Instance inst, + ProcessStateChange _, + System.Security.Claims.ClaimsPrincipal _, + Dictionary? _, + string? _, + CancellationToken _ + ) => + new ProcessChangeResult + { + Success = true, + MutatedInstance = inst, + ProcessStateChange = new(), + } ); // Binary data mocks (should be called) @@ -1216,6 +1311,151 @@ public async Task CopyInstance_OnlyBinaryData_NotCopiedByDefault() ); } + [Fact] + public async Task CopyInstance_StartFails_ReturnsConflict() + { + // Arrange + const string Org = "ttd"; + const string AppName = "copy-instance"; + const int instanceOwnerPartyId = 343234; + Guid instanceGuid = Guid.NewGuid(); + Instance instance = new() + { + Id = $"{instanceOwnerPartyId}/{instanceGuid}", + AppId = $"{Org}/{AppName}", + InstanceOwner = new InstanceOwner() { PartyId = instanceOwnerPartyId.ToString() }, + Status = new InstanceStatus() { IsArchived = true }, + Process = new ProcessState() { CurrentTask = new ProcessElementInfo() { ElementId = "First" } }, + Data = new List(), + }; + var auth = TestAuthentication.GetUserAuthentication(userPartyId: instanceOwnerPartyId); + using var fixture = InstancesControllerFixture.Create(auth); + + fixture + .Mock() + .Setup(httpContext => httpContext.User) + .Returns(TestAuthentication.GetUserPrincipal(partyId: instanceOwnerPartyId)); + fixture.Mock().Setup(hc => hc.Request).Returns(Mock.Of()); + fixture + .Mock() + .Setup(a => a.GetApplicationMetadata()) + .ReturnsAsync(CreateApplicationMetadata(Org, AppName, true)); + fixture + .Mock() + .Setup>(p => p.GetDecisionForRequest(It.IsAny())) + .ReturnsAsync(CreateXacmlResponse("Permit")); + fixture + .Mock() + .Setup(i => i.GetInstance(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(instance); + fixture + .Mock() + .Setup(i => i.CreateInstance(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(instance); + fixture.Mock().Setup(i => i.GetInstance(It.IsAny())).ReturnsAsync(instance); + fixture + .Mock() + .Setup(v => v.Validate(It.IsAny())) + .ReturnsAsync(new InstantiationValidationResult { Valid = true }); + fixture + .Mock() + .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) + .ReturnsAsync(new ProcessChangeResult { Success = true, ProcessStateChange = new() }); + fixture + .Mock() + .Setup(p => + p.Start( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new ProcessChangeResult + { + Success = false, + ErrorMessage = "Process next failed", + ErrorType = ProcessErrorType.Conflict, + } + ); + + // Act + var controller = fixture.ServiceProvider.GetRequiredService(); + ActionResult actual = await controller.CopyInstance(Org, AppName, instanceOwnerPartyId, instanceGuid); + + // Assert + Assert.IsType(actual); + } + + [Fact] + public async Task CopyInstance_GenerateProcessStartEventsFails_ReturnsConflict() + { + // Arrange + const string Org = "ttd"; + const string AppName = "copy-instance"; + const int instanceOwnerPartyId = 343234; + Guid instanceGuid = Guid.NewGuid(); + Instance instance = new() + { + Id = $"{instanceOwnerPartyId}/{instanceGuid}", + AppId = $"{Org}/{AppName}", + InstanceOwner = new InstanceOwner() { PartyId = instanceOwnerPartyId.ToString() }, + Status = new InstanceStatus() { IsArchived = true }, + Process = new ProcessState() { CurrentTask = new ProcessElementInfo() { ElementId = "First" } }, + Data = new List(), + }; + var auth = TestAuthentication.GetUserAuthentication(userPartyId: instanceOwnerPartyId); + using var fixture = InstancesControllerFixture.Create(auth); + + fixture + .Mock() + .Setup(httpContext => httpContext.User) + .Returns(TestAuthentication.GetUserPrincipal(partyId: instanceOwnerPartyId)); + fixture.Mock().Setup(hc => hc.Request).Returns(Mock.Of()); + fixture + .Mock() + .Setup(a => a.GetApplicationMetadata()) + .ReturnsAsync(CreateApplicationMetadata(Org, AppName, true)); + fixture + .Mock() + .Setup>(p => p.GetDecisionForRequest(It.IsAny())) + .ReturnsAsync(CreateXacmlResponse("Permit")); + fixture + .Mock() + .Setup(i => i.GetInstance(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(instance); + fixture + .Mock() + .Setup(i => i.CreateInstance(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(instance); + fixture.Mock().Setup(i => i.GetInstance(It.IsAny())).ReturnsAsync(instance); + fixture + .Mock() + .Setup(v => v.Validate(It.IsAny())) + .ReturnsAsync(new InstantiationValidationResult { Valid = true }); + fixture + .Mock() + .Setup(p => p.GenerateProcessStartEvents(It.IsAny())) + .ReturnsAsync( + new ProcessChangeResult + { + Success = false, + ErrorMessage = "Process is already started. Use next.", + ErrorType = ProcessErrorType.Conflict, + } + ); + + // Act + var controller = fixture.ServiceProvider.GetRequiredService(); + ActionResult actual = await controller.CopyInstance(Org, AppName, instanceOwnerPartyId, instanceGuid); + + // Assert + Assert.IsType(actual); + } + private static ApplicationMetadata CreateApplicationMetadata(string org, string app, bool enableCopyInstance) { return new($"{org}/{app}") diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SelfIdentifiedUser.verified.txt b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SelfIdentifiedUser.verified.txt index cc49694843..402927531c 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SelfIdentifiedUser.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SelfIdentifiedUser.verified.txt @@ -162,6 +162,37 @@ ], HasParent: true }, + { + Name: Process.StartExecution, + IdFormat: W3C, + Status: Ok, + Tags: [ + { + instance.guid: Guid_2 + } + ], + Events: [ + { + Name: change, + Timestamp: DateTimeOffset_2, + Tags: [ + { + events: [ + Type=process_StartEvent DataId=, + Type=process_StartTask DataId= + ] + }, + { + to.started: DateTime_1 + }, + { + to.task.name: Utfylling + } + ] + } + ], + HasParent: true + }, { Name: Process.StoreEvents, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_ServiceOwner.verified.txt b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_ServiceOwner.verified.txt index 691c3806b4..373511463b 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_ServiceOwner.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_ServiceOwner.verified.txt @@ -162,6 +162,37 @@ ], HasParent: true }, + { + Name: Process.StartExecution, + IdFormat: W3C, + Status: Ok, + Tags: [ + { + instance.guid: Guid_3 + } + ], + Events: [ + { + Name: change, + Timestamp: DateTimeOffset_2, + Tags: [ + { + events: [ + Type=process_StartEvent DataId=, + Type=process_StartTask DataId= + ] + }, + { + to.started: DateTime_1 + }, + { + to.task.name: Utfylling + } + ] + } + ], + HasParent: true + }, { Name: Process.StoreEvents, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SystemUser.verified.txt b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SystemUser.verified.txt index 3642c40802..46224d589b 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SystemUser.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_SystemUser.verified.txt @@ -162,6 +162,37 @@ ], HasParent: true }, + { + Name: Process.StartExecution, + IdFormat: W3C, + Status: Ok, + Tags: [ + { + instance.guid: Guid_4 + } + ], + Events: [ + { + Name: change, + Timestamp: DateTimeOffset_2, + Tags: [ + { + events: [ + Type=process_StartEvent DataId=, + Type=process_StartTask DataId= + ] + }, + { + to.started: DateTime_1 + }, + { + to.task.name: Utfylling + } + ] + } + ], + HasParent: true + }, { Name: Process.StoreEvents, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_User.verified.txt b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_User.verified.txt index 7234eb6449..a0b32d85df 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_User.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstanceTests.PostNewInstance_Simplified_User.verified.txt @@ -162,6 +162,37 @@ ], HasParent: true }, + { + Name: Process.StartExecution, + IdFormat: W3C, + Status: Ok, + Tags: [ + { + instance.guid: Guid_2 + } + ], + Events: [ + { + Name: change, + Timestamp: DateTimeOffset_2, + Tags: [ + { + events: [ + Type=process_StartEvent DataId=, + Type=process_StartTask DataId= + ] + }, + { + to.started: DateTime_1 + }, + { + to.task.name: Utfylling + } + ] + } + ], + HasParent: true + }, { Name: Process.StoreEvents, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/appsettings.json b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/appsettings.json new file mode 100644 index 0000000000..95a02d0ffd --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/appsettings.json @@ -0,0 +1,29 @@ +{ + "Kestrel": { + "EndPoints": { + "Http": { + "Url": "http://*:5005" + } + } + }, + "AppSettings": { + "RuntimeCookieName": "AltinnStudioRuntime" + }, + "GeneralSettings": { + "HostName": "altinn3.no", + "SoftValidationPrefix": "*WARNING*", + "AltinnPartyCookieName": "AltinnPartyId" + }, + "PlatformSettings": { + "ApiStorageEndpoint": "http://localhost:5101/storage/api/v1/", + "ApiRegisterEndpoint": "http://localhost:5101/register/api/v1/", + "ApiProfileEndpoint": "http://localhost:5101/profile/api/v1/", + "ApiAuthenticationEndpoint": "http://localhost:5101/authentication/api/v1/", + "ApiAuthorizationEndpoint": "http://localhost:5101/authorization/api/v1/", + "ApiEventsEndpoint": "http://localhost:5101/events/api/v1/", + "SubscriptionKey": "retrieved from environment at runtime" + }, + "ApplicationInsights": { + "InstrumentationKey": "retrieved from environment at runtime" + } +} diff --git a/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/applicationmetadata.json b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/applicationmetadata.json new file mode 100644 index 0000000000..4ca024c878 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/applicationmetadata.json @@ -0,0 +1,33 @@ +{ + "id": "ttd/service-task-first", + "org": "ttd", + "title": { + "nb": "service-task-first" + }, + "dataTypes": [ + { + "id": "default", + "allowedContentTypes": [ + "application/xml" + ], + "maxCount": 1, + "appLogic": { + "autoCreate": true, + "classRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema" + }, + "taskId": "Task_1" + } + ], + "partyTypesAllowed": { + "bankruptcyEstate": false, + "organisation": false, + "person": false, + "subUnit": false + }, + "autoDeleteOnProcessEnd": false, + "disallowUserInstantiation": false, + "created": "2025-01-01T00:00:00Z", + "createdBy": "test", + "lastChanged": "2025-01-01T00:00:00Z", + "lastChangedBy": "test" +} diff --git a/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/authorization/policy.xml b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/authorization/policy.xml new file mode 100644 index 0000000000..b5f47c54c9 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/authorization/policy.xml @@ -0,0 +1,119 @@ + + + + + A rule giving user with role REGNA or DAGL or org tdd the right to instantiate ttd/service-task-first + + + + + REGNA + + + + + + DAGL + + + + + + tdd + + + + + + + + ttd + + + + service-task-first + + + + + + + + instantiate + + + + + + + + Rule giving REGNA/DAGL read and write access on ServiceTask_1 and Task_1 + + + + + REGNA + + + + + + DAGL + + + + + + + + ttd + + + + service-task-first + + + + ServiceTask_1 + + + + + + ttd + + + + service-task-first + + + + Task_1 + + + + + + + + read + + + + + + write + + + + + + + + + + 2 + + + + diff --git a/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/process/process.bpmn b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/process/process.bpmn new file mode 100644 index 0000000000..3fd78986a7 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/apps/ttd/service-task-first/config/process/process.bpmn @@ -0,0 +1,39 @@ + + + + + Flow_start_st1 + + + Flow_start_st1 + Flow_st1_task1 + + + custom-service + + + + + Flow_st1_task1 + Flow_task1_end + + + data + + + + + Flow_task1_end + + + + + + diff --git a/test/Altinn.App.Api.Tests/Process/ServiceTasks/ServiceTaskFirstTests.cs b/test/Altinn.App.Api.Tests/Process/ServiceTasks/ServiceTaskFirstTests.cs new file mode 100644 index 0000000000..ee403d3946 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Process/ServiceTasks/ServiceTaskFirstTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Text.Json; +using Altinn.App.Api.Tests.Controllers; +using Altinn.App.Api.Tests.Data; +using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; +using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Process.ServiceTasks; + +public class ServiceTaskFirstTests : ApiTestBase, IClassFixture> +{ + private const string Org = "ttd"; + private const string App = "service-task-first"; + private const int InstanceOwnerPartyId = 501337; + + private bool _serviceTaskExecuted; + + public ServiceTaskFirstTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) + : base(factory, outputHelper) + { + OverrideServicesForAllTests = services => + { + services.AddTransient(sp => + { + var task = new CustomServiceTask(() => _serviceTaskExecuted = true); + return task; + }); + }; + + SendAsync = _ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + [Fact] + public async Task Instantiate_AutoRuns_ServiceTask_And_Lands_On_UserTask() + { + using HttpClient client = GetRootedUserClient(Org, App); + + var (instance, _) = await InstancesControllerFixture.CreateInstanceSimplified( + Org, + App, + InstanceOwnerPartyId, + client, + TestAuthentication.GetUserToken(userId: 1337, partyId: InstanceOwnerPartyId) + ); + + Assert.True(_serviceTaskExecuted, "the service task should have been auto-executed on instantiation"); + + Assert.NotNull(instance.Process); + Assert.NotNull(instance.Process.CurrentTask); + Assert.Equal("Task_1", instance.Process.CurrentTask.ElementId); + Assert.Equal("data", instance.Process.CurrentTask.AltinnTaskType); + + Assert.Single(instance.Data); + + TestData.DeleteInstanceAndData(Org, App, instance.Id); + } + + [Fact] + public async Task Instantiate_With_Prefill_Reaches_UserTask_DataModel() + { + using HttpClient client = GetRootedUserClient(Org, App); + + var prefill = new Dictionary { { "melding.name", "PrefillThroughServiceTask" } }; + var (instance, _) = await InstancesControllerFixture.CreateInstanceSimplified( + Org, + App, + InstanceOwnerPartyId, + client, + TestAuthentication.GetUserToken(userId: 1337, partyId: InstanceOwnerPartyId), + prefill + ); + + Assert.True(_serviceTaskExecuted, "the service task should have been auto-executed on instantiation"); + + Assert.NotNull(instance.Process); + Assert.NotNull(instance.Process.CurrentTask); + Assert.Equal("Task_1", instance.Process.CurrentTask.ElementId); + var dataElement = Assert.Single(instance.Data); + + var readResponse = await client.GetAsync($"/{Org}/{App}/instances/{instance.Id}/data/{dataElement.Id}"); + Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode); + + var content = await readResponse.Content.ReadAsStringAsync(); + var model = JsonSerializer.Deserialize(content); + + Assert.NotNull(model); + Assert.NotNull(model.Melding); + Assert.Equal("PrefillThroughServiceTask", model.Melding.Name); + + TestData.DeleteInstanceAndData(Org, App, instance.Id); + } + + private sealed class CustomServiceTask : IServiceTask + { + private readonly Action _onExecute; + + public CustomServiceTask(Action onExecute) + { + _onExecute = onExecute; + } + + public string Type => "custom-service"; + + public Task Execute(ServiceTaskContext context) + { + _onExecute(); + return Task.FromResult(ServiceTaskResult.Success()); + } + } +} diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 4343aab001..4f6671cdfc 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3693,6 +3693,7 @@ namespace Altinn.App.Core.Internal.Process System.Threading.Tasks.Task GenerateProcessStartEvents(Altinn.App.Core.Models.Process.ProcessStartRequest processStartRequest); System.Threading.Tasks.Task HandleEventsAndUpdateStorage(Altinn.Platform.Storage.Interface.Models.Instance instance, System.Collections.Generic.Dictionary? prefill, System.Collections.Generic.List? events); System.Threading.Tasks.Task Next(Altinn.App.Core.Models.Process.ProcessNextRequest request, System.Threading.CancellationToken ct = default); + System.Threading.Tasks.Task Start(Altinn.Platform.Storage.Interface.Models.Instance instance, Altinn.App.Core.Models.Process.ProcessStateChange processStateChange, System.Security.Claims.ClaimsPrincipal user, System.Collections.Generic.Dictionary? prefill = null, string? language = null, System.Threading.CancellationToken ct = default); } public interface IProcessEngineAuthorizer { @@ -3739,6 +3740,7 @@ namespace Altinn.App.Core.Internal.Process public System.Threading.Tasks.Task GenerateProcessStartEvents(Altinn.App.Core.Models.Process.ProcessStartRequest processStartRequest) { } public System.Threading.Tasks.Task HandleEventsAndUpdateStorage(Altinn.Platform.Storage.Interface.Models.Instance instance, System.Collections.Generic.Dictionary? prefill, System.Collections.Generic.List? events) { } public System.Threading.Tasks.Task Next(Altinn.App.Core.Models.Process.ProcessNextRequest request, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task Start(Altinn.Platform.Storage.Interface.Models.Instance instance, Altinn.App.Core.Models.Process.ProcessStateChange processStateChange, System.Security.Claims.ClaimsPrincipal user, System.Collections.Generic.Dictionary? prefill = null, string? language = null, System.Threading.CancellationToken ct = default) { } } public class ProcessEventDispatcher : Altinn.App.Core.Internal.Process.IProcessEventDispatcher { @@ -4855,14 +4857,17 @@ namespace Altinn.App.Core.Models.Process [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(false, new string?[]?[] { "ErrorMessage", "ErrorType"})] + [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "MutatedInstance")] [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "ProcessStateChange")] [get: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(false, new string?[]?[] { "ErrorMessage", "ErrorType"})] + [get: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "MutatedInstance")] [get: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "ProcessStateChange")] [set: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(false, new string?[]?[] { "ErrorMessage", "ErrorType"})] + [set: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "MutatedInstance")] [set: System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, "ProcessStateChange")] public bool Success { get; init; } public System.Collections.Generic.List? ValidationIssues { get; set; } @@ -4887,6 +4892,7 @@ namespace Altinn.App.Core.Models.Process public string? ActionOnBehalfOf { get; set; } public required Altinn.Platform.Storage.Interface.Models.Instance Instance { get; init; } public required string? Language { get; init; } + public System.Collections.Generic.Dictionary? Prefill { get; init; } public required System.Security.Claims.ClaimsPrincipal User { get; init; } } public class ProcessStartRequest