diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index ff0cf4d0b8..47e70f5d87 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -138,7 +138,7 @@ jobs: - name: Install dependencies run: cd eform-client && yarn install --frozen-lockfile - name: Run Jest unit tests - run: cd eform-client && npm run test:unit + run: cd eform-client && npm run test:ci test-dotnet: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 2c7dc44668..75f8f06a50 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -138,7 +138,7 @@ jobs: - name: Install dependencies run: cd eform-client && yarn install --frozen-lockfile - name: Run Jest unit tests - run: cd eform-client && npm run test:unit + run: cd eform-client && npm run test:ci test-dotnet: runs-on: ubuntu-latest steps: diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/AuthServiceTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/AuthServiceTests.cs index 74ca3c864c..6d26fb5b2f 100644 --- a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/AuthServiceTests.cs +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/AuthServiceTests.cs @@ -22,6 +22,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Threading.Tasks; using NUnit.Framework; using eFormAPI.Web.Services; using Microsoft.Extensions.Logging; @@ -48,6 +53,10 @@ public class AuthServiceTests : DbTestFixture private ILocalizationService _localizationService; private IClaimsService _claimsService; private IAuthCacheService _authCacheService; +#pragma warning disable NUnit1032 + private UserManager _userManager; + private RoleManager _roleManager; +#pragma warning restore NUnit1032 private AuthService _authService; public override void DoSetup() @@ -60,7 +69,7 @@ public override void DoSetup() _claimsService = Substitute.For(); _authCacheService = Substitute.For(); - var roleManager = Substitute.For>( + _roleManager = Substitute.For>( Substitute.For>(), null, null, null, null); var signInManager = Substitute.For>( Substitute.For>( @@ -68,7 +77,7 @@ public override void DoSetup() Substitute.For(), Substitute.For>(), null, null, null, null); - var userManager = Substitute.For>( + _userManager = Substitute.For>( Substitute.For>(), null, null, null, null, null, null, null, null); _localizationService.GetString(Arg.Any()) @@ -81,9 +90,9 @@ public override void DoSetup() _tokenOptions, _logger, _appSettings, - roleManager, + _roleManager, signInManager, - userManager, + _userManager, _userService, _localizationService, _claimsService, @@ -96,5 +105,42 @@ public void AuthService_InitializesCorrectly() // Assert Assert.That(_authService, Is.Not.Null); } + + // A refreshed token must say whether the caller is the first user, exactly as Login + // does: the client replaces its stored user with the refresh result, so a missing + // flag would drop the first user's first-user-only actions after every refresh. + [TestCase(1, true)] + [TestCase(2, false)] + public async Task RefreshToken_ReportsWhetherTheCallerIsTheFirstUser(int userId, bool expectedIsFirstUser) + { + // Arrange + const int firstUserId = 1; + var user = new EformUser + { + Id = userId, + UserName = $"user{userId}@example.com", + Email = $"user{userId}@example.com" + }; + _userService.UserId.Returns(userId); + _userService.GetByIdAsync(userId).Returns(user); + _userService.GetFirstUserIdInDb().Returns(firstUserId); + _userManager.GetRolesAsync(user).Returns(new List { EformRole.Admin }); + _userManager.GetClaimsAsync(user).Returns(new List()); + _roleManager.FindByNameAsync(Arg.Any()).Returns((EformRole)null); + _claimsService.GetUserPermissions(userId, Arg.Any()).Returns(new List()); + // HMAC-SHA256 needs a key of at least 256 bits; generate one per run. + _tokenOptions.Value.Returns(new EformTokenOptions + { + Issuer = "eform-tests", + SigningKey = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)) + }); + + // Act + var result = await _authService.RefreshToken(); + + // Assert + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model.IsFirstUser, Is.EqualTo(expectedIsFirstUser)); + } } } diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceDeleteTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceDeleteTests.cs new file mode 100644 index 0000000000..cff7a2b17c --- /dev/null +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceDeleteTests.cs @@ -0,0 +1,49 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2021 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System.Threading.Tasks; +using NUnit.Framework; +using eFormAPI.Web.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Microting.eFormApi.BasePn.Infrastructure.Models.API; + +namespace eFormAPI.Web.Integration.Tests.Services +{ + /// + /// The first-user delete rule on DELETE api/device-users/delete/{id}. + /// + [TestFixture] + public class DeviceUsersServiceDeleteTests : FirstUserOnlyDeleteTestsBase + { + protected override Task DeleteAsync(int id) => + new DeviceUsersService( + LocalizationService, + CoreHelper, + UserService, + Substitute.For>()).Delete(id); + + protected override string SdkFailureMessageKey => "DeviceUserParamCouldNotBeDeleted"; + } +} diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceTests.cs index 06bcb92d67..dea45c8f34 100644 --- a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceTests.cs +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/DeviceUsersServiceTests.cs @@ -44,6 +44,7 @@ public override void DoSetup() { _localizationService = Substitute.For(); _coreHelper = Substitute.For(); + var userService = Substitute.For(); _logger = Substitute.For>(); _localizationService.GetString(Arg.Any()) @@ -52,6 +53,7 @@ public override void DoSetup() _deviceUsersService = new DeviceUsersService( _localizationService, _coreHelper, + userService, _logger); } diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/FirstUserOnlyDeleteTestsBase.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/FirstUserOnlyDeleteTestsBase.cs new file mode 100644 index 0000000000..f5da231827 --- /dev/null +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/FirstUserOnlyDeleteTestsBase.cs @@ -0,0 +1,128 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2021 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using NSubstitute; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Models.API; +using eFormAPI.Web.Abstractions; + +namespace eFormAPI.Web.Integration.Tests.Services +{ + /// + /// Only the first user (lowest AspNetUsers Id) may delete a device user, on every path + /// that removes its Site row; everyone else, admins included, is refused before the SDK + /// is touched. Each subclass points at one delete path and + /// inherits these tests. They never touch the database, so they avoid DbTestFixture. + /// + public abstract class FirstUserOnlyDeleteTestsBase + { + private const int FirstUserId = 1; + private const int OtherUserId = 2; + private const int SiteId = 42; + + protected ILocalizationService LocalizationService; + protected IEFormCoreService CoreHelper; + protected IUserService UserService; + + /// Calls the delete path under test with fresh service wiring. + protected abstract Task DeleteAsync(int id); + + /// The key the service reports when the (faked) SDK delete fails. + protected abstract string SdkFailureMessageKey { get; } + + [SetUp] + public void SetUpSubstitutes() + { + LocalizationService = Substitute.For(); + CoreHelper = Substitute.For(); + UserService = Substitute.For(); + + LocalizationService.GetString(Arg.Any()) + .Returns(args => args.Arg()); + LocalizationService.GetStringWithFormat(Arg.Any(), Arg.Any()) + .Returns(args => args.ArgAt(0)); + + UserService.GetFirstUserIdInDb().Returns(FirstUserId); + + // Every SDK call in Delete goes through the core instance, so a GetCore that + // throws marks the point where the SDK path is entered. + CoreHelper.GetCore() + .Returns(_ => throw new InvalidOperationException("SDK reached")); + } + + [Test] + public async Task Delete_ByAdminWhoIsNotTheFirstUser_IsRefusedWithoutReachingTheSdk() + { + // Arrange + UserService.UserId.Returns(OtherUserId); + // Admin status is deliberately irrelevant: an admin who is not the first user is refused. + UserService.IsAdmin().Returns(true); + + // Act + var result = await DeleteAsync(SiteId); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanDeleteWorkers")); + // The SDK delete is only reachable through GetCore. + await CoreHelper.DidNotReceive().GetCore(); + } + + [Test] + public async Task Delete_WithoutAUserIdAgainstAnEmptyUsersTable_IsRefusedWithoutReachingTheSdk() + { + // Arrange: no signed-in user (UserId 0) and no users, so GetFirstUserIdInDb is 0 too. + UserService.UserId.Returns(0); + UserService.GetFirstUserIdInDb().Returns(0); + + // Act + var result = await DeleteAsync(SiteId); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanDeleteWorkers")); + await CoreHelper.DidNotReceive().GetCore(); + } + + [Test] + public async Task Delete_ByTheFirstUser_PassesTheCheckAndReachesTheSdk() + { + // Arrange + UserService.UserId.Returns(FirstUserId); + + // Act + var result = await DeleteAsync(SiteId); + + // Assert + await UserService.Received(1).GetFirstUserIdInDb(); + await CoreHelper.Received(1).GetCore(); + // The faked SDK fails, so the result is the SDK-failure message, not the refusal. + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(SdkFailureMessageKey)); + } + } +} diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceDeleteTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceDeleteTests.cs new file mode 100644 index 0000000000..c6ba5b5ff6 --- /dev/null +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceDeleteTests.cs @@ -0,0 +1,49 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2021 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System.Threading.Tasks; +using NUnit.Framework; +using eFormAPI.Web.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Microting.eFormApi.BasePn.Infrastructure.Models.API; + +namespace eFormAPI.Web.Integration.Tests.Services +{ + /// + /// The first-user delete rule on Advanced > Sites, which removes the same Site row. + /// + [TestFixture] + public class SitesServiceDeleteTests : FirstUserOnlyDeleteTestsBase + { + protected override Task DeleteAsync(int id) => + new SitesService( + CoreHelper, + LocalizationService, + UserService, + Substitute.For>()).Delete(id); + + protected override string SdkFailureMessageKey => "SiteParamCouldNotBeDeleted"; + } +} diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceTests.cs index 8769a76bcc..37d7e3cec6 100644 --- a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceTests.cs +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/SitesServiceTests.cs @@ -43,6 +43,7 @@ public override void DoSetup() { _coreHelper = Substitute.For(); _localizationService = Substitute.For(); + var userService = Substitute.For(); _logger = Substitute.For>(); _localizationService.GetString(Arg.Any()) @@ -51,6 +52,7 @@ public override void DoSetup() _sitesService = new SitesService( _coreHelper, _localizationService, + userService, _logger); } diff --git a/eFormAPI/eFormAPI.Web/Controllers/Eforms/EFormFilesController.cs b/eFormAPI/eFormAPI.Web/Controllers/Eforms/EFormFilesController.cs index 4aadc8923d..243e3879d7 100644 --- a/eFormAPI/eFormAPI.Web/Controllers/Eforms/EFormFilesController.cs +++ b/eFormAPI/eFormAPI.Web/Controllers/Eforms/EFormFilesController.cs @@ -260,6 +260,11 @@ public async Task UpdateImage([FromForm]int uploadedObjId) [Authorize(Policy = AuthConsts.EformPolicies.Cases.CaseUpdate)] public async Task AddNewImage([FromForm] int fieldId, [FromForm] int caseId) { + // Temp files are tracked outside the try so they are always removed in + // finally, on success and on failure alike. + string filePath = null; + string smallTempPath = null; + string bigTempPath = null; try { var newFile = HttpContext.Request.Form.Files.Last(); @@ -279,7 +284,12 @@ public async Task AddNewImage([FromForm] int fieldId, [FromForm var folder = Path.Combine(Path.GetTempPath(), "cases-temp-files"); Directory.CreateDirectory(folder); - var filePath = Path.Combine(folder, $"{DateTime.Now.Ticks}.{newFile.FileName.Split(".").Last()}"); + // Collision-free temp stem (concurrent uploads within the same tick + // would otherwise share names). Temp names never reach storage keys + // or the DB. + var stem = Guid.NewGuid().ToString("N"); + var ext = newFile.FileName.Split(".").Last(); + filePath = Path.Combine(folder, $"{stem}.{ext}"); string hash; using (var md5 = MD5.Create()) { @@ -297,72 +307,139 @@ public async Task AddNewImage([FromForm] int fieldId, [FromForm } } - var newUploadData = new Microting.eForm.Infrastructure.Data.Entities.UploadedData - { - Checksum = hash, - FileName = $"{hash}.{newFile.FileName.Split(".").Last()}", - FileLocation = filePath, - Extension = newFile.FileName.Split(".").Last() - }; - await newUploadData.Create(sdkDbContext); - newUploadData.FileName = $"{newUploadData.Id}_{newUploadData.FileName}"; - await newUploadData.Update(sdkDbContext); - - await core.PutFileToStorageSystem(filePath, newUploadData.FileName); - var fieldValue = new Microting.eForm.Infrastructure.Data.Entities.FieldValue + // Idempotency guard: the same file already attached to this + // (case, field) is a no-op success. Both "not removed" conditions + // are load-bearing — DeleteImage soft-deletes only the + // UploadedData, so a delete-then-reupload must create a new row. + var alreadyAttached = await sdkDbContext.FieldValues + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.CaseId == caseDb.Id) + .Where(x => x.FieldId == field.Id) + .Where(x => x.UploadedDataId != null) + .AnyAsync(x => x.UploadedData.WorkflowState != Constants.WorkflowStates.Removed + && x.UploadedData.Checksum == hash); + if (alreadyAttached) { - FieldId = field.Id, - CaseId = caseDb.Id, - CheckListId = field.CheckListId, - WorkerId = caseDb.WorkerId, - DoneAt = DateTime.UtcNow, - UploadedDataId = newUploadData.Id - }; + return new OperationResult(true, localizationService.GetString("ImageUpdatedSuccessfully")); + } - await fieldValue.Create(sdkDbContext); - string smallFilename = $"{newUploadData.Id}_300_{newUploadData.Checksum}{newUploadData.Extension}"; //uploadedDataObj.Id + "_300_" + uploadedDataObj.Checksum; - string bigFilename = $"{newUploadData.Id}_700_{newUploadData.Checksum}{newUploadData.Extension}";//uploadedDataObj.Id + "_700_" + urlStr.Remove(0, index); - System.IO.File.Copy(filePath, Path.Combine(Path.GetTempPath(), smallFilename)); - System.IO.File.Copy(filePath, Path.Combine(Path.GetTempPath(), bigFilename)); - string filePathResized = Path.Combine(Path.GetTempPath(), smallFilename); - using (var image = new MagickImage(filePathResized)) + // Derive the 300/700 thumbnails to temp names BEFORE opening the + // transaction: this is the failure-prone step (MagickImage), and + // failing here leaves no rows behind. The id-based storage keys + // are applied at upload time below. + smallTempPath = Path.Combine(folder, $"{stem}_300.{ext}"); + bigTempPath = Path.Combine(folder, $"{stem}_700.{ext}"); + await ResizeToWidth(filePath, smallTempPath, 300); + await ResizeToWidth(filePath, bigTempPath, 700); + + // Everything that persists rows runs inside one transaction on the + // same sdkDbContext (PnBase.Create/Update call SaveChangesAsync on + // the passed context, so they join it). The storage keys embed the + // row id, so the rows cannot be created after the uploads. + // + // The SDK's MicrotingDbContextFactory configures EnableRetryOnFailure, + // i.e. a retrying execution strategy. With such a strategy EF Core + // rejects a user-initiated BeginTransactionAsync at the first + // SaveChangesAsync ("does not support user-initiated transactions"), + // so the whole unit must run through CreateExecutionStrategy(). + // The delegate may be re-executed on a transient failure, so it + // must be re-runnable: the entities are constructed inside it + // (re-adding an already-tracked instance would misbehave), the + // change tracker is cleared on entry so a previous attempt's + // entity left tracked as Added by a failed SaveChangesAsync is not + // re-inserted, and the storage uploads are overwrite-idempotent + // (an orphaned object from an aborted attempt is harmless). + // `await using` on the transaction rolls back on dispose when + // CommitAsync was not reached. + var strategy = sdkDbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => { - decimal currentRation = image.Height / (decimal) image.Width; - int newWidth = 300; - int newHeight = (int) Math.Round((currentRation * newWidth)); + // A retried attempt must not re-insert the previous attempt's entity: a + // failed SaveChangesAsync leaves it tracked as Added. caseDb/field become + // detached, which is fine — only their scalar values are used below. + sdkDbContext.ChangeTracker.Clear(); - image.Resize((uint)newWidth, (uint)newHeight); - image.Crop((uint)newWidth, (uint)newHeight); - await image.WriteAsync(filePathResized); - image.Dispose(); - await core.PutFileToStorageSystem(Path.Combine(Path.GetTempPath(), filePathResized), smallFilename); + await using var tx = await sdkDbContext.Database.BeginTransactionAsync(); - } - System.IO.File.Delete(filePathResized); - filePathResized = Path.Combine(Path.GetTempPath(), bigFilename); - using (var image = new MagickImage(filePathResized)) - { - decimal currentRation = image.Height / (decimal) image.Width; - int newWidth = 700; - int newHeight = (int) Math.Round((currentRation * newWidth)); - - image.Resize((uint)newWidth, (uint)newHeight); - image.Crop((uint)newWidth, (uint)newHeight); - await image.WriteAsync(filePathResized); - image.Dispose(); - await core.PutFileToStorageSystem(Path.Combine(Path.GetTempPath(), filePathResized), bigFilename); - } - System.IO.File.Delete(filePathResized); + var newUploadData = new Microting.eForm.Infrastructure.Data.Entities.UploadedData + { + Checksum = hash, + FileName = $"{hash}.{ext}", + FileLocation = filePath, + // Stored WITH the leading dot, matching the SDK device path and + // the plugin upload path, so the derived thumbnail names + // {id}_300_{md5}{Extension} / {id}_700_{md5}{Extension} match + // the get-image/{fileName}.{ext} route. + Extension = $".{ext}" + }; + await newUploadData.Create(sdkDbContext); + newUploadData.FileName = $"{newUploadData.Id}_{newUploadData.FileName}"; + await newUploadData.Update(sdkDbContext); + + var fieldValue = new Microting.eForm.Infrastructure.Data.Entities.FieldValue + { + FieldId = field.Id, + CaseId = caseDb.Id, + CheckListId = field.CheckListId, + WorkerId = caseDb.WorkerId, + DoneAt = DateTime.UtcNow, + UploadedDataId = newUploadData.Id + }; + await fieldValue.Create(sdkDbContext); + + string smallFilename = $"{newUploadData.Id}_300_{newUploadData.Checksum}{newUploadData.Extension}"; + string bigFilename = $"{newUploadData.Id}_700_{newUploadData.Checksum}{newUploadData.Extension}"; + await core.PutFileToStorageSystem(filePath, newUploadData.FileName); + await core.PutFileToStorageSystem(smallTempPath, smallFilename); + await core.PutFileToStorageSystem(bigTempPath, bigFilename); + + await tx.CommitAsync(); + }); return new OperationResult(true, localizationService.GetString("ImageUpdatedSuccessfully")); } catch (Exception e) { SentrySdk.CaptureException(e); - logger.LogError(e.Message); - logger.LogTrace(e.StackTrace); + logger.LogError(e, "AddNewImage failed for case {CaseId} field {FieldId}", caseId, fieldId); return new OperationResult(false, localizationService.GetString("ErrorWhileUpdateImage")); } + finally + { + TryDeleteTempFile(filePath); + TryDeleteTempFile(smallTempPath); + TryDeleteTempFile(bigTempPath); + } + } + + private static async Task ResizeToWidth(string sourcePath, string targetPath, int newWidth) + { + using var image = new MagickImage(sourcePath); + decimal currentRation = image.Height / (decimal) image.Width; + int newHeight = (int) Math.Round((currentRation * newWidth)); + + image.Resize((uint)newWidth, (uint)newHeight); + image.Crop((uint)newWidth, (uint)newHeight); + await image.WriteAsync(targetPath); + } + + private void TryDeleteTempFile(string path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + try + { + if (System.IO.File.Exists(path)) + { + System.IO.File.Delete(path); + } + } + catch (Exception e) + { + logger.LogWarning(e, "Could not delete temp file {Path}", path); + } } [HttpGet] diff --git a/eFormAPI/eFormAPI.Web/Infrastructure/Helpers/FirstUserHelper.cs b/eFormAPI/eFormAPI.Web/Infrastructure/Helpers/FirstUserHelper.cs new file mode 100644 index 0000000000..a8991666a6 --- /dev/null +++ b/eFormAPI/eFormAPI.Web/Infrastructure/Helpers/FirstUserHelper.cs @@ -0,0 +1,41 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2021 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +namespace eFormAPI.Web.Infrastructure.Helpers; + +using System.Threading.Tasks; +using Microting.eFormApi.BasePn.Abstractions; + +internal static class FirstUserHelper +{ + /// + /// The first user is the account with the lowest AspNetUsers Id. A caller without a + /// user id is never the first user, even when the users table is empty. + /// + public static async Task IsFirstUserAsync(this IUserService userService) + { + var userId = userService.UserId; + return userId > 0 && userId == await userService.GetFirstUserIdInDb(); + } +} diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.bg.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.bg.resx index 20a338cb15..9f67c4af02 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.bg.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.bg.resx @@ -78,6 +78,9 @@ Потребителят на устройството „{0}“ е изтрит успешно + + Само първият потребител може да изтрива потребители на устройства + Потребителят на устройството е актуализиран успешно diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.cs.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.cs.resx index 0eaf635506..a6c7188dc6 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.cs.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.cs.resx @@ -78,6 +78,9 @@ Uživatel zařízení „{0}“ byl úspěšně smazán + + Pouze první uživatel může mazat uživatele zařízení + Uživatel zařízení byl úspěšně aktualizován diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.da.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.da.resx index e84f276346..1378149646 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.da.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.da.resx @@ -137,6 +137,9 @@ Medarbejder "{0}" slettet + + Kun den første bruger kan slette mobilbrugere + Medarbejder opdateret @@ -689,6 +692,12 @@ Nyt billede tilføjet + + Fejl under upload af billede + + + Billedet blev ikke fundet + Mapper diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.de.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.de.resx index 4bf3d07e14..236d41f4b5 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.de.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.de.resx @@ -137,6 +137,9 @@ Gerätebenutzer "{0}" wurde erfolgreich gelöscht + + Nur der erste Benutzer kann Gerätebenutzer löschen + Gerätebenutzer erfolgreich aktualisiert diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.el.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.el.resx index eb8399b0f3..ad62bfd1d2 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.el.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.el.resx @@ -78,6 +78,9 @@ Ο χρήστης συσκευής "{0}" διαγράφηκε με επιτυχία + + Μόνο ο πρώτος χρήστης μπορεί να διαγράψει χρήστες συσκευής + Ο χρήστης συσκευής ενημερώθηκε με επιτυχία diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.es.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.es.resx index 4ef4b9d6a8..5f9d68fbb0 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.es.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.es.resx @@ -78,6 +78,9 @@ Usuario del dispositivo "{0}" eliminado correctamente + + Solo el primer usuario puede eliminar usuarios del dispositivo + Usuario del dispositivo actualizado exitosamente diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.et.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.et.resx index 7a862fd314..81061e5c10 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.et.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.et.resx @@ -78,6 +78,9 @@ Seadme kasutaja "{0}" kustutamine õnnestus + + Ainult esimene kasutaja saab seadme kasutajaid kustutada + Seadme kasutaja värskendamine õnnestus diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.fi.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.fi.resx index 92b5e5e66f..8d6727b361 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.fi.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.fi.resx @@ -78,6 +78,9 @@ Laitteen käyttäjän "{0}" poistaminen onnistui + + Vain ensimmäinen käyttäjä voi poistaa laitteen käyttäjiä + Laitteen käyttäjän päivitys onnistui diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.fr.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.fr.resx index ee914917cd..12602e9895 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.fr.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.fr.resx @@ -78,6 +78,9 @@ L'utilisateur de l'appareil "{0}" a été supprimé avec succès + + Seul le premier utilisateur peut supprimer des utilisateurs de l'appareil + L'utilisateur de l'appareil a été mis à jour avec succès diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.hr.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.hr.resx index b1a1f6eaf0..288b675d28 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.hr.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.hr.resx @@ -78,6 +78,9 @@ Korisnik uređaja "{0}" uspješno je izbrisan + + Samo prvi korisnik može brisati korisnike uređaja + Korisnik uređaja je uspješno ažuriran diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.hu.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.hu.resx index b27757181b..a2cfb1fbbc 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.hu.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.hu.resx @@ -78,6 +78,9 @@ „{0}” eszközfelhasználó sikeresen törölve + + Csak az első felhasználó törölhet eszközfelhasználókat + Az eszközfelhasználó sikeresen frissítve diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.is.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.is.resx index 27e6ab88c3..9cc73cec3c 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.is.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.is.resx @@ -78,6 +78,9 @@ Tækjanotandanum "{0}" var eytt + + Aðeins fyrsti notandinn getur eytt tækjanotendum + Tækjanotandi uppfærður diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.it.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.it.resx index 6c8e4a1188..5587bdb9f8 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.it.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.it.resx @@ -78,6 +78,9 @@ L'utente del dispositivo "{0}" è stato eliminato correttamente + + Solo il primo utente può eliminare gli utenti del dispositivo + Utente del dispositivo aggiornato correttamente diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.lt.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.lt.resx index ab920c1fa4..16b8644805 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.lt.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.lt.resx @@ -78,6 +78,9 @@ Įrenginio naudotojas „{0}“ sėkmingai ištrintas + + Tik pirmasis naudotojas gali ištrinti įrenginio naudotojus + Įrenginio naudotojas sėkmingai atnaujintas diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.lv.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.lv.resx index c18384514a..13a8456a0e 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.lv.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.lv.resx @@ -78,6 +78,9 @@ Ierīces lietotājs “{0}” ir veiksmīgi izdzēsts + + Tikai pirmais lietotājs var dzēst ierīces lietotājus + Ierīces lietotājs ir veiksmīgi atjaunināts diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.nl.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.nl.resx index e102273c06..fc1ed06ad3 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.nl.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.nl.resx @@ -78,6 +78,9 @@ Apparaatgebruiker "{0}" is succesvol verwijderd + + Alleen de eerste gebruiker kan apparaatgebruikers verwijderen + Apparaatgebruiker succesvol bijgewerkt diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.no.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.no.resx index 3ad77411d7..69d2e864c8 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.no.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.no.resx @@ -78,6 +78,9 @@ Enhetsbruker «{0}» ble slettet + + Bare den første brukeren kan slette enhetsbrukere + Enhetsbruker er oppdatert diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.pl.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.pl.resx index 05b7f56a44..99dda9f26d 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.pl.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.pl.resx @@ -78,6 +78,9 @@ Użytkownik urządzenia „{0}” został pomyślnie usunięty + + Tylko pierwszy użytkownik może usuwać użytkowników urządzenia + Użytkownik urządzenia został pomyślnie zaktualizowany diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.pt.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.pt.resx index e7d4919250..4f4c64a2e8 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.pt.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.pt.resx @@ -78,6 +78,9 @@ Usuário do dispositivo "{0}" excluído com sucesso + + Apenas o primeiro usuário pode excluir usuários do dispositivo + Usuário do dispositivo atualizado com sucesso diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.resx index 80835030d3..66d5dc1859 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.resx @@ -195,6 +195,9 @@ Device User "{0}" deleted successfully + + Only the first user can delete device users + Device User updated successfully @@ -747,6 +750,12 @@ Image updated successfully + + Error while uploading image + + + Image not found + Folders diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.ro.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.ro.resx index 66d877aa92..fea06f1fe7 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.ro.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.ro.resx @@ -78,6 +78,9 @@ Utilizatorul dispozitivului „{0}” a fost șters cu succes + + Doar primul utilizator poate șterge utilizatorii dispozitivului + Utilizatorul dispozitivului a fost actualizat cu succes diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sk.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sk.resx index e6bd31eb17..7997c8be36 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sk.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sk.resx @@ -78,6 +78,9 @@ Používateľ zariadenia „{0}“ bol úspešne odstránený + + Iba prvý používateľ môže odstraňovať používateľov zariadenia + Používateľ zariadenia bol úspešne aktualizovaný diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sl.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sl.resx index efb7e8f27e..55f3b0b134 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sl.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sl.resx @@ -78,6 +78,9 @@ Uporabnik naprave »{0}« je bil uspešno izbrisan + + Samo prvi uporabnik lahko briše uporabnike naprave + Uporabnik naprave je uspešno posodobljen diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sv.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sv.resx index 3a013210d6..7f5e4b47ef 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.sv.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.sv.resx @@ -78,6 +78,9 @@ Enhetsanvändaren "{0}" har tagits bort + + Endast den första användaren kan ta bort enhetsanvändare + Enhetsanvändaren har uppdaterats diff --git a/eFormAPI/eFormAPI.Web/Resources/SharedResource.uk.resx b/eFormAPI/eFormAPI.Web/Resources/SharedResource.uk.resx index 96ad0ed272..01e19bb0a5 100644 --- a/eFormAPI/eFormAPI.Web/Resources/SharedResource.uk.resx +++ b/eFormAPI/eFormAPI.Web/Resources/SharedResource.uk.resx @@ -137,6 +137,9 @@ Пристрій користувача "{0}" успішно видалений + + Лише перший користувач може видаляти користувачів пристрою + Пристрій користувача успішно оновлено diff --git a/eFormAPI/eFormAPI.Web/Services/AuthService.cs b/eFormAPI/eFormAPI.Web/Services/AuthService.cs index 311ac5a12a..254e080443 100644 --- a/eFormAPI/eFormAPI.Web/Services/AuthService.cs +++ b/eFormAPI/eFormAPI.Web/Services/AuthService.cs @@ -185,6 +185,8 @@ public async Task> RefreshToken() $"Role for user {userService.UserId} not found"); } + var firstUserIdInDb = await userService.GetFirstUserIdInDb(); + return new OperationDataResult(true, new EformAuthorizeResult { Id = user.Id, @@ -194,6 +196,7 @@ public async Task> RefreshToken() ExpiresIn = token.expireIn, FirstName = user.FirstName, LastName = user.LastName, + IsFirstUser = user.Id == firstUserIdInDb, AvatarUrl = user.ProfilePictureSnapshot != null ? $"api/images/login-page-images?fileName={user.ProfilePictureSnapshot}" : $"https://www.gravatar.com/avatar/{user.EmailSha256}?s=32&d=identicon" diff --git a/eFormAPI/eFormAPI.Web/Services/DeviceUsersService.cs b/eFormAPI/eFormAPI.Web/Services/DeviceUsersService.cs index 9f163ee9d5..738e41509f 100644 --- a/eFormAPI/eFormAPI.Web/Services/DeviceUsersService.cs +++ b/eFormAPI/eFormAPI.Web/Services/DeviceUsersService.cs @@ -34,6 +34,7 @@ namespace eFormAPI.Web.Services; using System.Threading.Tasks; using Abstractions; using Abstractions.Advanced; +using Infrastructure.Helpers; using Infrastructure.Models; using Infrastructure.Models.DeviceUsers; using Microsoft.EntityFrameworkCore; @@ -45,6 +46,7 @@ namespace eFormAPI.Web.Services; public class DeviceUsersService( ILocalizationService localizationService, IEFormCoreService coreHelper, + IUserService userService, ILogger logger) : IDeviceUsersService { @@ -272,6 +274,13 @@ public async Task Update(DeviceUserModel deviceUserModel) public async Task Delete(int id) { + // Only the first user may delete a device user; everyone else is refused before the SDK. + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, + localizationService.GetString("OnlyTheFirstUserCanDeleteWorkers")); + } + try { var core = await coreHelper.GetCore(); diff --git a/eFormAPI/eFormAPI.Web/Services/SitesService.cs b/eFormAPI/eFormAPI.Web/Services/SitesService.cs index e5bca473c6..f6955016c6 100644 --- a/eFormAPI/eFormAPI.Web/Services/SitesService.cs +++ b/eFormAPI/eFormAPI.Web/Services/SitesService.cs @@ -34,6 +34,7 @@ namespace eFormAPI.Web.Services; using System.Threading.Tasks; using Abstractions; using Abstractions.Advanced; +using Infrastructure.Helpers; using Infrastructure.Models.Sites; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -45,6 +46,7 @@ namespace eFormAPI.Web.Services; public class SitesService( IEFormCoreService coreHelper, ILocalizationService localizationService, + IUserService userService, ILogger logger) : ISitesService { @@ -240,6 +242,13 @@ public async Task Update(SiteUpdateModel updateModel) public async Task Delete(int id) { + // Only the first user may delete a site (a device user); everyone else is refused before the SDK. + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, + localizationService.GetString("OnlyTheFirstUserCanDeleteWorkers")); + } + try { var core = await coreHelper.GetCore(); diff --git a/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj b/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj index 9b91e30608..3783b9362e 100644 --- a/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj +++ b/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj @@ -59,7 +59,7 @@ - + diff --git a/eform-client/TESTING.md b/eform-client/TESTING.md index 9665892281..f3eaee0503 100644 --- a/eform-client/TESTING.md +++ b/eform-client/TESTING.md @@ -318,7 +318,8 @@ ng test --include='**/component-name.component.spec.ts' ## Continuous Integration -Tests are automatically run in GitHub Actions workflows using Jest: +Tests are automatically run in GitHub Actions workflows using Jest. The `test-angular-unit` job in +`.github/workflows/dotnet-core-pr.yml` and `.github/workflows/dotnet-core-master.yml` runs: ```bash # Run tests in CI/CD mode @@ -327,8 +328,15 @@ npm run test:ci Jest is configured with: - Code coverage reporting -- CI-specific optimizations (--ci flag) -- Maximum of 2 workers for parallel execution +- CI-specific optimizations (the `--ci` flag). Jest auto-detects GitHub Actions, so the flag is + redundant there, but it keeps the script correct if it is ever run somewhere without CI + detection. +- Jest's default worker count. There is no explicit `--maxWorkers` cap, so Jest scales to + whatever runner it gets (3 workers on the current 4-vCPU `ubuntu-latest` runner). Capping at 2 + measured no faster on this suite, so there is no cap left to maintain. + +These jobs are not configured as required status checks, so a failing Jest run does not by itself +block a merge. ## Contributing diff --git a/eform-client/package.json b/eform-client/package.json index 36b82767f0..bd11d3fa83 100644 --- a/eform-client/package.json +++ b/eform-client/package.json @@ -13,7 +13,7 @@ "start-port": "ng serve --port 4300 --proxy-config proxy.config.json", "build": "ng build", "sentrysourcemap": "yarn sentry:sourcemaps", - "test:ci": "jest --ci --coverage --maxWorkers=2", + "test:ci": "jest --ci --coverage", "test:unit": "jest --coverage", "test:local_unit": "jest --watch", "test:watch": "jest --watch", @@ -36,9 +36,9 @@ "@angular-material-extensions/password-strength": "^16.0.0", "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.16", + "@angular/common": "20.3.27", "@angular/compiler": "20.3.16", - "@angular/core": "20.3.17", + "@angular/core": "20.3.27", "@angular/forms": "20.3.16", "@angular/localize": "20.3.16", "@angular/material": "20.2.14", diff --git a/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.html b/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.html index 8c589c935a..742ca5b475 100644 --- a/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.html +++ b/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.html @@ -6,7 +6,7 @@ (click)="openAddImage()" > add - Add new image + {{ 'Add new image' | translate }}
diff --git a/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.ts b/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.ts index eac5818954..bdab8fff53 100644 --- a/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.ts +++ b/eform-client/src/app/common/modules/eform-cases/components/case-edit/case-elements/element-picture/element-picture.component.ts @@ -168,15 +168,43 @@ export class ElementPictureComponent implements OnChanges, OnDestroy { } } + /** + * Uploads the picked file. The host's buttons and the dialog's Save are + * locked for the whole request so a fast double-click, or a click after an + * error, can never re-POST the same File while one is in flight; the lock is + * released on failure so the user can retry, and on success the dialog + * closes and the host reloads its pictures. + */ addPicture(newImage: File, modalId: string) { + if (this.buttonsLocked) { + return; + } const fieldId = this.fieldId; + const dialogRef = this.dialog.getDialogById(modalId) as MatDialogRef | undefined; + const dialogComponent = dialogRef?.componentInstance; + const release = () => { + this.buttonsLocked = false; + if (dialogComponent) { + dialogComponent.saving = false; + } + }; + this.buttonsLocked = true; + if (dialogComponent) { + dialogComponent.saving = true; + } this.addImageSub$ = this.imageService .addNewImage(fieldId, this.resolvedCaseId, newImage) - .subscribe(data => { - if (data && data.success) { - this.dialog.getDialogById(modalId).close(); - this.pictureUpdated.emit();// fetch the new image - } + .subscribe({ + next: data => { + if (data && data.success) { + this.buttonsLocked = false; + dialogRef?.close(); + this.pictureUpdated.emit();// fetch the new image + } else { + release(); + } + }, + error: () => release(), }); } @@ -229,19 +257,19 @@ export class ElementPictureComponent implements OnChanges, OnDestroy {
- +
`, standalone: false }) @@ -250,9 +278,19 @@ export class AddPictureDialogComponent { addedPicture: EventEmitter = new EventEmitter(); image: File; + /** + * True while the host is uploading the picked file. Set and cleared by the + * host (ElementPictureComponent.addPicture); disables Save so the same File + * cannot be submitted twice, and is cleared again on failure so the user can + * retry. + */ + saving = false; onAddPicture() { - this.addedPicture.emit(this.image) + if (!this.image || this.saving) { + return; + } + this.addedPicture.emit(this.image); } diff --git a/eform-client/src/app/modules/advanced/components/sites/sites/sites.component.html b/eform-client/src/app/modules/advanced/components/sites/sites/sites.component.html index 80bb7fc305..50f79319e0 100644 --- a/eform-client/src/app/modules/advanced/components/sites/sites/sites.component.html +++ b/eform-client/src/app/modules/advanced/components/sites/sites/sites.component.html @@ -43,7 +43,7 @@ -
+
@@ -52,7 +52,7 @@ @@ -100,7 +100,7 @@