diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 841236ab5..bbacc6de1 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -46,6 +46,11 @@ reviews:
languagetool:
enabled: true
level: default
+ # Biome's strict JSON parser can't read appsettings*.json (ASP.NET parses those as
+ # JSON-with-comments by design), so it warned on every review touching them. Its lint
+ # coverage is already provided by CI (oxlint/oxfmt, JSCPD, Fallow, ReSharper, CodeQL).
+ biome:
+ enabled: false
chat:
auto_reply: true
diff --git a/test/CMS/CMSContentPermissionTests.cs b/test/CMS/CMSContentPermissionTests.cs
new file mode 100644
index 000000000..c557a8326
--- /dev/null
+++ b/test/CMS/CMSContentPermissionTests.cs
@@ -0,0 +1,178 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using NSubstitute.ReturnsExtensions;
+using Viper.Areas.CMS.Constants;
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+using Viper.Models.RAPS;
+using Viper.Models.VIPER;
+using Viper.Services;
+using DataCms = Viper.Areas.CMS.Data.CMS;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for Data.CMS.GetContentBlocksAllowed, the public-display permission gate behind the
+/// CMS content endpoint (content/fn/{friendlyName}). A public block is served to anyone; a
+/// restricted block is withheld unless the viewer is a CMS admin, the block has no permissions
+/// and the viewer is logged in, or the viewer holds one of the block's permissions. Permission
+/// scenarios are driven by mocking IUserHelper.
+///
+public sealed class CMSContentPermissionTests : IDisposable
+{
+ private readonly VIPERContext _context;
+ private readonly RAPSContext _rapsContext;
+ private readonly IUserHelper _userHelper;
+ private readonly DataCms _cms;
+
+ public CMSContentPermissionTests()
+ {
+ _context = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options);
+ _rapsContext = new RAPSContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options);
+ var sanitizer = Substitute.For();
+ sanitizer.Sanitize(Arg.Any()).Returns(c => c.ArgAt(0));
+ _userHelper = Substitute.For();
+
+ _cms = new DataCms(_context, _rapsContext, sanitizer) { UserHelper = _userHelper };
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _rapsContext.Dispose();
+ }
+
+ private async Task SeedBlockAsync(bool allowPublic, params string[] permissions)
+ {
+ var block = new ContentBlock
+ {
+ Content = "secret
",
+ Title = "Block",
+ System = "Viper",
+ FriendlyName = "block-" + Guid.NewGuid().ToString("N")[..8],
+ AllowPublicAccess = allowPublic,
+ ModifiedOn = DateTime.Now,
+ ModifiedBy = "author"
+ };
+ foreach (var permission in permissions)
+ {
+ block.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = permission });
+ }
+ _context.ContentBlocks.Add(block);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ return block;
+ }
+
+ private static AaudUser User() => new() { AaudUserId = 1, LoginId = "viewer", MothraId = "m1" };
+
+ private void GrantPermissions(AaudUser user, params string[] permissions)
+ {
+ _userHelper.GetAllPermissions(_rapsContext, user)
+ .Returns(permissions.Select(p => new TblPermission { Permission = p }).ToList());
+ }
+
+ [Fact]
+ public async Task PublicBlock_ReturnedToAnonymousUser()
+ {
+ var block = await SeedBlockAsync(allowPublic: true);
+ _userHelper.GetCurrentUser().ReturnsNull();
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal(block.ContentBlockId, result[0].ContentBlockId);
+ }
+
+ [Fact]
+ public async Task RestrictedBlock_WithheldFromUnprivilegedUser()
+ {
+ var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS");
+ var user = User();
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false);
+ _userHelper.HasPermission(_rapsContext, user, "SVMSecure").Returns(true);
+ // User holds an unrelated permission, not the block's required one.
+ GrantPermissions(user, "SVMSecure.Other");
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task RestrictedBlock_ReturnedWhenUserHoldsBlockPermission()
+ {
+ var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS");
+ var user = User();
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false);
+ GrantPermissions(user, "SVMSecure.CATS");
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task RestrictedBlock_PermissionMatchIsCaseInsensitive()
+ {
+ var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS");
+ var user = User();
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false);
+ GrantPermissions(user, "svmsecure.cats");
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task CmsAdmin_SeesRestrictedBlock()
+ {
+ var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS");
+ var user = User();
+ _userHelper.GetCurrentUser().Returns(user);
+ // CMS admin override short-circuits before any block-permission match.
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(true);
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task NoPermissionBlock_ReturnedToAnyLoggedInUser()
+ {
+ // A block with no permissions is visible to any authenticated VIPER user (has "SVMSecure").
+ var block = await SeedBlockAsync(allowPublic: false);
+ var user = User();
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false);
+ _userHelper.HasPermission(_rapsContext, user, "SVMSecure").Returns(true);
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task RestrictedBlock_WithheldFromAnonymousUser()
+ {
+ var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS");
+ _userHelper.GetCurrentUser().ReturnsNull();
+
+ var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+}
diff --git a/test/CMS/CMSFilesControllerTests.cs b/test/CMS/CMSFilesControllerTests.cs
new file mode 100644
index 000000000..c1e5367f0
--- /dev/null
+++ b/test/CMS/CMSFilesControllerTests.cs
@@ -0,0 +1,488 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ReturnsExtensions;
+using NSubstitute.ExceptionExtensions;
+using Viper.Areas.CMS.Constants;
+using Viper.Areas.CMS.Controllers;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models;
+using Viper.Models.AAUD;
+
+namespace Viper.test.CMS;
+
+///
+/// Controller wiring tests for CMSFilesController: list/paging passthrough, get/upload/update
+/// delete/restore status mapping (404 on null, 400 on bad input/conflict), import + preview +
+/// bulk-encrypt empty-input guards, and admin-only permanent delete. The service behavior is
+/// covered separately in CmsFileServiceTests / CmsFileImportServiceTests.
+///
+public sealed class CMSFilesControllerTests : IDisposable
+{
+ private readonly ICmsFileService _fileService;
+ private readonly ICmsFileStorageService _storage;
+ private readonly ICmsFileAuditService _auditService;
+ private readonly ICmsFileImportService _importService;
+ private readonly RAPSContext _rapsContext;
+ private readonly IUserHelper _userHelper;
+ private readonly CMSFilesController _controller;
+
+ public CMSFilesControllerTests()
+ {
+ _fileService = Substitute.For();
+ _storage = Substitute.For();
+ _auditService = Substitute.For();
+ _importService = Substitute.For();
+ _rapsContext = new RAPSContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options);
+ _userHelper = Substitute.For();
+
+ _controller = new CMSFilesController(_fileService, _storage, _auditService, _importService, _rapsContext,
+ Substitute.For>(), _userHelper);
+ SetupControllerContext();
+ }
+
+ // Streams backing form files must outlive the controller call; disposed with the test instance.
+ private readonly List _formFileStreams = new();
+
+ public void Dispose()
+ {
+ _rapsContext.Dispose();
+ _formFileStreams.ForEach(s => s.Dispose());
+ }
+
+ private void SetupControllerContext()
+ {
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+ _controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext { RequestServices = serviceProvider }
+ };
+ }
+
+ private static CmsFileDto File(Guid? guid = null) => new()
+ {
+ FileGuid = guid ?? Guid.NewGuid(),
+ FileName = "report.pdf",
+ FriendlyName = "cats-report.pdf",
+ Folder = "cats"
+ };
+
+ private IFormFile MakeFormFile(string fileName = "report.pdf", long length = 3)
+ {
+ var stream = new MemoryStream(new byte[length]);
+ _formFileStreams.Add(stream);
+ return new FormFile(stream, 0, length, "file", fileName);
+ }
+
+ #region List
+
+ [Fact]
+ public async Task GetFiles_PassesFiltersAndPaginationThrough_AndSetsTotal()
+ {
+ // An admin sees the whole trash, so the owner restriction passed to the service is null.
+ var user = new AaudUser { AaudUserId = 1, LoginId = "admin" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true);
+ var files = new List { File() };
+ _fileService.GetFilesAsync("cats", "deleted", "budget", true, false, 2, 25, "modifiedOn", true,
+ null, Arg.Any()).Returns((files, 42));
+ var pagination = new ApiPagination { Page = 2, PerPage = 25 };
+
+ var result = await _controller.GetFiles("cats", "budget", true, false, pagination, "deleted", "modifiedOn", true, TestContext.Current.CancellationToken);
+
+ Assert.Same(files, result.Value);
+ Assert.Equal(42, pagination.TotalRecords);
+ await _fileService.Received(1).GetFilesAsync("cats", "deleted", "budget", true, false, 2, 25, "modifiedOn", true,
+ null, Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetFiles_DefaultsPageAndPerPage_WhenNoPagination()
+ {
+ var user = new AaudUser { AaudUserId = 1, LoginId = "user" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _fileService.GetFilesAsync(null, "active", null, null, null, 1, 50, "friendlyName", false,
+ "user", Arg.Any()).Returns((new List(), 0));
+
+ await _controller.GetFiles(null, null, null, null, null, ct: TestContext.Current.CancellationToken);
+
+ await _fileService.Received(1).GetFilesAsync(null, "active", null, null, null, 1, 50, "friendlyName", false,
+ "user", Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetFolders_DiskListByDefault_FilterListWhenIncludeData()
+ {
+ _storage.GetTopLevelFolders().Returns(new List { "cats" });
+ _storage.GetFilterFoldersAsync(Arg.Any()).Returns(new List { "cats", "legacy" });
+
+ var disk = await _controller.GetFolders(includeData: false, TestContext.Current.CancellationToken);
+ var filter = await _controller.GetFolders(includeData: true, TestContext.Current.CancellationToken);
+
+ Assert.Equal(new List { "cats" }, disk.Value);
+ Assert.Equal(new List { "cats", "legacy" }, filter.Value);
+ }
+
+ [Fact]
+ public async Task GetAudit_PassesFilterAndPaginationThrough_AndSetsTotal()
+ {
+ _auditService.GetAuditEntriesAsync(Arg.Any(), 3, 10, Arg.Any())
+ .Returns(new List());
+ _auditService.GetAuditEntryCountAsync(Arg.Any(), Arg.Any()).Returns(5);
+ var fileGuid = Guid.NewGuid();
+ var pagination = new ApiPagination { Page = 3, PerPage = 10 };
+
+ await _controller.GetAudit(fileGuid, "AccessFile", "loginX", null, null, "needle", pagination, TestContext.Current.CancellationToken);
+
+ Assert.Equal(5, pagination.TotalRecords);
+ await _auditService.Received(1).GetAuditEntriesAsync(
+ Arg.Is(f =>
+ f.FileGuid == fileGuid && f.Action == "AccessFile" && f.LoginId == "loginX" && f.Search == "needle"),
+ 3, 10, Arg.Any());
+ }
+
+ #endregion
+
+ #region Get / CheckName
+
+ [Fact]
+ public async Task GetFile_ReturnsFile_WhenFound()
+ {
+ var guid = Guid.NewGuid();
+ _fileService.GetFileAsync(guid, Arg.Any()).Returns(File(guid));
+
+ var result = await _controller.GetFile(guid, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Value);
+ Assert.Equal(guid, result.Value!.FileGuid);
+ }
+
+ [Fact]
+ public async Task GetFile_ReturnsNotFound_WhenMissing()
+ {
+ _fileService.GetFileAsync(Arg.Any(), Arg.Any()).ReturnsNull();
+
+ var result = await _controller.GetFile(Guid.NewGuid(), TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task CheckName_ReturnsBadRequest_OnArgumentException()
+ {
+ _fileService.CheckNameAsync("nope", "report.pdf", Arg.Any())
+ .Throws(new ArgumentException("Invalid folder."));
+
+ var result = await _controller.CheckName("nope", "report.pdf", TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ #endregion
+
+ #region Upload / Update
+
+ [Fact]
+ public async Task UploadFile_ReturnsBadRequest_WhenNoFile()
+ {
+ var result = await _controller.UploadFile(new CmsFileCreateRequest { Folder = "cats" }, null, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ await _fileService.DidNotReceive().CreateFileAsync(Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task UploadFile_ReturnsFile_OnSuccess()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats" };
+ var dto = File();
+ _fileService.CreateFileAsync(request, Arg.Any(), Arg.Any()).Returns(dto);
+
+ var result = await _controller.UploadFile(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ Assert.Same(dto, result.Value);
+ }
+
+ [Fact]
+ public async Task UploadFile_ReturnsBadRequest_OnConflict()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats" };
+ _fileService.CreateFileAsync(request, Arg.Any(), Arg.Any())
+ .Throws(new InvalidOperationException("A file with that name already exists."));
+
+ var result = await _controller.UploadFile(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task UpdateFile_ReturnsNotFound_WhenServiceReturnsNull()
+ {
+ var guid = Guid.NewGuid();
+ var request = new CmsFileUpdateRequest { Description = "x" };
+ _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any()).ReturnsNull();
+
+ var result = await _controller.UpdateFile(guid, request, null, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task UpdateFile_ReturnsBadRequest_OnArgumentException()
+ {
+ var guid = Guid.NewGuid();
+ var request = new CmsFileUpdateRequest { Description = "x" };
+ _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any())
+ .Throws(new ArgumentException("Replacement must keep the same extension."));
+
+ var result = await _controller.UpdateFile(guid, request, MakeFormFile("x.png"), TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task UpdateFile_ReturnsConflict_OnStaleEdit()
+ {
+ // CmsConcurrencyException derives from InvalidOperationException; it must map to 409,
+ // not fall into the generic InvalidOperationException -> 400 handler.
+ var guid = Guid.NewGuid();
+ var request = new CmsFileUpdateRequest { Description = "x", LastModifiedOn = DateTime.Now.AddMinutes(-5) };
+ _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any())
+ .Throws(new CmsConcurrencyException("This file was modified by someone on 7/2/2026."));
+
+ var result = await _controller.UpdateFile(guid, request, null, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ #endregion
+
+ #region Delete / Restore
+
+ [Fact]
+ public async Task DeleteFile_SoftDelete_ReturnsNoContent()
+ {
+ var guid = Guid.NewGuid();
+ _fileService.SoftDeleteFileAsync(guid, Arg.Any()).Returns(true);
+
+ var result = await _controller.DeleteFile(guid, permanent: false, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task DeleteFile_SoftDelete_ReturnsNotFound_WhenMissing()
+ {
+ _fileService.SoftDeleteFileAsync(Arg.Any(), Arg.Any()).Returns(false);
+
+ var result = await _controller.DeleteFile(Guid.NewGuid(), permanent: false, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task DeleteFile_PermanentWithoutAdmin_ReturnsForbidden()
+ {
+ var user = new AaudUser { AaudUserId = 1, LoginId = "user" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false);
+
+ var result = await _controller.DeleteFile(Guid.NewGuid(), permanent: true, TestContext.Current.CancellationToken);
+
+ var objectResult = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode);
+ await _fileService.DidNotReceive().PermanentlyDeleteFileAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task DeleteFile_PermanentAsAdmin_ReturnsNoContent()
+ {
+ var guid = Guid.NewGuid();
+ var user = new AaudUser { AaudUserId = 1, LoginId = "admin" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true);
+ _fileService.PermanentlyDeleteFileAsync(guid, Arg.Any()).Returns(true);
+
+ var result = await _controller.DeleteFile(guid, permanent: true, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ await _fileService.Received(1).PermanentlyDeleteFileAsync(guid, Arg.Any());
+ }
+
+ [Fact]
+ public async Task RestoreFile_ReturnsNoContent_WhenRestored()
+ {
+ var guid = Guid.NewGuid();
+ var user = new AaudUser { AaudUserId = 1, LoginId = "admin" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true);
+ _fileService.RestoreFileAsync(guid, Arg.Any()).Returns(true);
+
+ var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task RestoreFile_AllowsNonAdmin_WhoDeletedTheFile()
+ {
+ var guid = Guid.NewGuid();
+ var user = new AaudUser { AaudUserId = 1, LoginId = "user" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false);
+ _fileService.GetFileAsync(guid, Arg.Any())
+ .Returns(new CmsFileDto { FileGuid = guid, DeletedOn = DateTime.Now, ModifiedBy = "user" });
+ _fileService.RestoreFileAsync(guid, Arg.Any()).Returns(true);
+
+ var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ await _fileService.Received(1).RestoreFileAsync(guid, Arg.Any());
+ }
+
+ [Fact]
+ public async Task RestoreFile_Forbids_WhenNonAdminDidNotDeleteIt()
+ {
+ var guid = Guid.NewGuid();
+ var user = new AaudUser { AaudUserId = 1, LoginId = "user" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false);
+ _fileService.GetFileAsync(guid, Arg.Any())
+ .Returns(new CmsFileDto { FileGuid = guid, DeletedOn = DateTime.Now, ModifiedBy = "someone-else" });
+
+ var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken);
+
+ var objectResult = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode);
+ await _fileService.DidNotReceive().RestoreFileAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetFiles_ScopesDeletedToOwner_ForNonAdmin()
+ {
+ var user = new AaudUser { AaudUserId = 1, LoginId = "user" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false);
+ _fileService.GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false,
+ "user", Arg.Any()).Returns((new List(), 0));
+
+ await _controller.GetFiles(null, null, null, null, null, "deleted", ct: TestContext.Current.CancellationToken);
+
+ // A non-admin's trash is scoped to the files they deleted (their login).
+ await _fileService.Received(1).GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false,
+ "user", Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetFiles_FailsClosed_WhenUserContextMissing()
+ {
+ _userHelper.GetCurrentUser().ReturnsNull();
+ _fileService.GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false,
+ string.Empty, Arg.Any()).Returns((new List(), 0));
+
+ await _controller.GetFiles(null, null, null, null, null, "deleted", ct: TestContext.Current.CancellationToken);
+
+ // A missing user context must scope the trash to nothing, not fall through to the
+ // admin-level unrestricted (null) view.
+ await _fileService.Received(1).GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false,
+ string.Empty, Arg.Any());
+ }
+
+ #endregion
+
+ #region Import / Preview / Bulk-encrypt
+
+ [Fact]
+ public async Task ImportFiles_ReturnsBadRequest_WhenNoPaths()
+ {
+ var result = await _controller.ImportFiles(new CmsFileImportRequest { Folder = "cats" }, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ await _importService.DidNotReceive().ImportFilesAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task ImportFiles_PassesRequestThrough_OnSuccess()
+ {
+ var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } };
+ var results = new List { new() { FilePath = "legacy/a.pdf", Success = true } };
+ _importService.ImportFilesAsync(request, Arg.Any()).Returns(results);
+
+ var result = await _controller.ImportFiles(request, TestContext.Current.CancellationToken);
+
+ Assert.Same(results, result.Value);
+ }
+
+ [Fact]
+ public async Task ImportFiles_ReturnsBadRequest_OnInvalidOperation()
+ {
+ var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } };
+ _importService.ImportFilesAsync(request, Arg.Any())
+ .Throws(new InvalidOperationException("Legacy webroot not configured."));
+
+ var result = await _controller.ImportFiles(request, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task PreviewImport_ReturnsBadRequest_WhenNoPaths()
+ {
+ var result = await _controller.PreviewImport(new CmsFileImportRequest { Folder = "cats" }, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task PreviewImport_PassesRequestThrough()
+ {
+ var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } };
+ var preview = new List { new() { FilePath = "legacy/a.pdf", CanImport = true } };
+ _importService.PreviewImportAsync(request, Arg.Any()).Returns(preview);
+
+ var result = await _controller.PreviewImport(request, TestContext.Current.CancellationToken);
+
+ Assert.Same(preview, result.Value);
+ }
+
+ [Fact]
+ public async Task BulkEncrypt_ReturnsBadRequest_WhenNoGuids()
+ {
+ var result = await _controller.BulkEncrypt(new List(), TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ await _importService.DidNotReceive().BulkEncryptAsync(Arg.Any>(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task BulkEncrypt_ReturnsBadRequest_WhenOverBatchLimit()
+ {
+ var guids = Enumerable.Range(0, 501).Select(_ => Guid.NewGuid()).ToList();
+
+ var result = await _controller.BulkEncrypt(guids, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result.Result);
+ await _importService.DidNotReceive().BulkEncryptAsync(Arg.Any>(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task BulkEncrypt_PassesGuidsThrough()
+ {
+ var guids = new List { Guid.NewGuid() };
+ var results = new List { new() { FileGuid = guids[0], Success = true } };
+ _importService.BulkEncryptAsync(guids, Arg.Any()).Returns(results);
+
+ var result = await _controller.BulkEncrypt(guids, TestContext.Current.CancellationToken);
+
+ Assert.Same(results, result.Value);
+ }
+
+ #endregion
+}
diff --git a/test/CMS/CMSOptionsControllerTests.cs b/test/CMS/CMSOptionsControllerTests.cs
new file mode 100644
index 000000000..7d21e5038
--- /dev/null
+++ b/test/CMS/CMSOptionsControllerTests.cs
@@ -0,0 +1,145 @@
+using Microsoft.EntityFrameworkCore;
+using Viper.Areas.CMS.Controllers;
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+using Viper.Models.RAPS;
+
+namespace Viper.test.CMS;
+
+///
+/// Controller wiring tests for CMSOptionsController: the person search's minimum-length guard,
+/// 25-result cap, name/id matching, and last-name/first-name ordering, plus the instance-scoped
+/// permission list used by CMS tagging forms.
+///
+public sealed class CMSOptionsControllerTests : IDisposable
+{
+ private readonly RAPSContext _rapsContext;
+ private readonly AAUDContext _aaudContext;
+ private readonly CMSOptionsController _controller;
+
+ public CMSOptionsControllerTests()
+ {
+ _rapsContext = new RAPSContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options);
+ _aaudContext = new AAUDContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options);
+
+ _controller = new CMSOptionsController(_rapsContext, _aaudContext);
+ }
+
+ public void Dispose()
+ {
+ _rapsContext.Dispose();
+ _aaudContext.Dispose();
+ }
+
+ private static AaudUser MakeUser(int id, string lastName, string firstName, string? loginId = null,
+ string? mailId = null, string? iamId = "iam", int current = 1) => new()
+ {
+ AaudUserId = id,
+ ClientId = "test-client",
+ MothraId = "m" + id,
+ LoginId = loginId,
+ MailId = mailId,
+ IamId = iamId,
+ Current = current,
+ LastName = lastName,
+ FirstName = firstName,
+ DisplayLastName = lastName,
+ DisplayFirstName = firstName,
+ DisplayFullName = lastName + ", " + firstName
+ };
+
+ #region SearchPeople
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("a")]
+ public async Task SearchPeople_ReturnsEmpty_WhenSearchBelowMinimumLength(string? search)
+ {
+ _aaudContext.AaudUsers.Add(MakeUser(1, "Smith", "Amy"));
+ await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _controller.SearchPeople(search!, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Value);
+ Assert.Empty(result.Value!);
+ }
+
+ [Fact]
+ public async Task SearchPeople_CapsResultsAt25_WhenMoreThanLimitMatch()
+ {
+ for (var i = 0; i < 30; i++)
+ {
+ _aaudContext.AaudUsers.Add(MakeUser(i + 1, "Smith", $"Test{i:D2}"));
+ }
+ await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _controller.SearchPeople("Smith", TestContext.Current.CancellationToken);
+
+ Assert.Equal(25, result.Value!.Count);
+ }
+
+ [Fact]
+ public async Task SearchPeople_OrdersByDisplayLastName_ThenDisplayFirstName()
+ {
+ _aaudContext.AaudUsers.Add(MakeUser(1, "Zeta", "Amy", mailId: "orderx1"));
+ _aaudContext.AaudUsers.Add(MakeUser(2, "Alpha", "Bob", mailId: "orderx2"));
+ _aaudContext.AaudUsers.Add(MakeUser(3, "Mno", "Cara", mailId: "orderx3"));
+ _aaudContext.AaudUsers.Add(MakeUser(4, "Alpha", "Amy", mailId: "orderx4"));
+ await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _controller.SearchPeople("orderx", TestContext.Current.CancellationToken);
+
+ Assert.Equal(new[] { "Alpha, Amy", "Alpha, Bob", "Mno, Cara", "Zeta, Amy" },
+ result.Value!.Select(p => p.Name));
+ }
+
+ [Fact]
+ public async Task SearchPeople_ExcludesNonCurrentAndMissingIamId()
+ {
+ _aaudContext.AaudUsers.Add(MakeUser(1, "Smith", "Amy", current: 0));
+ _aaudContext.AaudUsers.Add(MakeUser(2, "Smith", "Bob", iamId: null));
+ await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _controller.SearchPeople("Smith", TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Value!);
+ }
+
+ [Fact]
+ public async Task SearchPeople_MatchesOnLoginIdAndMailId()
+ {
+ _aaudContext.AaudUsers.Add(MakeUser(1, "Doe", "Jane", loginId: "jdoe123", mailId: "jdoe"));
+ await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var byLogin = await _controller.SearchPeople("jdoe123", TestContext.Current.CancellationToken);
+ var byMail = await _controller.SearchPeople("jdoe", TestContext.Current.CancellationToken);
+
+ Assert.Single(byLogin.Value!);
+ Assert.Single(byMail.Value!);
+ }
+
+ #endregion
+
+ #region GetPermissions
+
+ [Fact]
+ public async Task GetPermissions_ReturnsInstancePermissions_SortedAlphabetically_ExcludingVmacsAndViperForms()
+ {
+ _rapsContext.TblPermissions.AddRange(
+ new TblPermission { PermissionId = 1, Permission = "SVMSecure.CMS.Zeta" },
+ new TblPermission { PermissionId = 2, Permission = "SVMSecure.CMS.Alpha" },
+ new TblPermission { PermissionId = 3, Permission = "VMACS.Admin" },
+ new TblPermission { PermissionId = 4, Permission = "VIPERForms.Admin" });
+ await _rapsContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _controller.GetPermissions(TestContext.Current.CancellationToken);
+
+ Assert.Equal(new[] { "SVMSecure.CMS.Alpha", "SVMSecure.CMS.Zeta" }, result.Value);
+ }
+
+ #endregion
+}
diff --git a/test/CMS/CMSUserPhotoControllerTests.cs b/test/CMS/CMSUserPhotoControllerTests.cs
new file mode 100644
index 000000000..8b93f6da4
--- /dev/null
+++ b/test/CMS/CMSUserPhotoControllerTests.cs
@@ -0,0 +1,166 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using Viper.Areas.CMS.Controllers;
+using Viper.Areas.CMS.Services;
+
+namespace Viper.test.CMS;
+
+///
+/// Controller wiring tests for CMSUserPhotoController: each by-id-type endpoint forwards only
+/// its identifier (and the altPhoto flag) to ICmsUserPhotoService and returns the bytes as an
+/// image/jpeg file response with a private cache header; conditional requests (If-Modified-Since)
+/// short-circuit to 304 per FIX 4.
+///
+public sealed class CMSUserPhotoControllerTests
+{
+ private static readonly DateTimeOffset PhotoLastModified =
+ new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
+
+ private readonly ICmsUserPhotoService _photoService;
+ private readonly CMSUserPhotoController _controller;
+
+ public CMSUserPhotoControllerTests()
+ {
+ _photoService = Substitute.For();
+ _controller = new CMSUserPhotoController(_photoService);
+
+ var serviceProvider = new ServiceCollection().BuildServiceProvider();
+ _controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext { RequestServices = serviceProvider }
+ };
+ }
+
+ private static CmsUserPhotoResult Result(byte[] bytes) => new(bytes, PhotoLastModified);
+
+ [Fact]
+ public async Task GetByMailId_ForwardsMailIdOnly()
+ {
+ var bytes = new byte[] { 1, 2, 3 };
+ _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, false, Arg.Any())
+ .Returns(Result(bytes));
+
+ var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var file = Assert.IsType(result);
+ Assert.Equal("image/jpeg", file.ContentType);
+ Assert.Equal(bytes, file.FileContents);
+ await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null, false,
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetByLoginId_ForwardsLoginIdAndAltPhotoFlag()
+ {
+ _photoService.GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any())
+ .Returns(Result(new byte[] { 9 }));
+
+ var result = await _controller.GetByLoginId("loginX", altPhoto: true, TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ await _photoService.Received(1).GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetByIamId_ForwardsIamIdOnly()
+ {
+ _photoService.GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any())
+ .Returns(Result(new byte[] { 7 }));
+
+ var result = await _controller.GetByIamId("1000123", ct: TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetByMothraId_ForwardsMothraIdOnly()
+ {
+ _photoService.GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any())
+ .Returns(Result(new byte[] { 5 }));
+
+ var result = await _controller.GetByMothraId("m-9001", ct: TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
+ await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any());
+ }
+
+ [Fact]
+ public async Task ServePhoto_SetsPrivateCacheHeader()
+ {
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 }));
+
+ await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var cacheControl = _controller.Response.Headers.CacheControl.ToString();
+ Assert.Contains("private", cacheControl);
+ Assert.Contains("stale-while-revalidate", cacheControl);
+ }
+
+ [Fact]
+ public async Task ServePhoto_SetsLastModifiedHeader()
+ {
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 }));
+
+ await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ Assert.Equal(PhotoLastModified, _controller.Response.GetTypedHeaders().LastModified);
+ }
+
+ [Fact]
+ public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastModified()
+ {
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
+ _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified;
+
+ var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var status = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status304NotModified, status.StatusCode);
+ }
+
+ [Fact]
+ public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceIsAfterLastModified()
+ {
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
+ _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(1);
+
+ var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var status = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status304NotModified, status.StatusCode);
+ }
+
+ [Fact]
+ public async Task ServePhoto_ReturnsFile_WhenIfModifiedSinceIsBeforeLastModified()
+ {
+ var bytes = new byte[] { 1, 2, 3 };
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(bytes));
+ _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(-1);
+
+ var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var file = Assert.IsType(result);
+ Assert.Equal(bytes, file.FileContents);
+ }
+
+ [Fact]
+ public async Task ServePhoto_ReturnsFile_WhenNoIfModifiedSinceHeaderSent()
+ {
+ var bytes = new byte[] { 1, 2, 3 };
+ _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any()).Returns(Result(bytes));
+
+ var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
+
+ var file = Assert.IsType(result);
+ Assert.Equal(bytes, file.FileContents);
+ }
+}
diff --git a/test/CMS/CmsDownloadRateLimitingTests.cs b/test/CMS/CmsDownloadRateLimitingTests.cs
new file mode 100644
index 000000000..4d47c538f
--- /dev/null
+++ b/test/CMS/CmsDownloadRateLimitingTests.cs
@@ -0,0 +1,88 @@
+using System.Net;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Viper.Areas.CMS.Services;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for the CMS download rate-limit partitioning: ZIP and single-file requests get
+/// separate buckets, keyed by login when authenticated and client IP otherwise.
+///
+public sealed class CmsDownloadRateLimitingTests
+{
+ private static DefaultHttpContext MakeContext(string? queryString = null, string? login = null, string? ip = null)
+ {
+ var context = new DefaultHttpContext();
+ if (queryString != null)
+ {
+ context.Request.QueryString = new QueryString(queryString);
+ }
+ if (login != null)
+ {
+ context.User = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, login) }, authenticationType: "test"));
+ }
+ if (ip != null)
+ {
+ context.Connection.RemoteIpAddress = IPAddress.Parse(ip);
+ }
+ return context;
+ }
+
+ [Theory]
+ [InlineData("?ids=abc", true)]
+ [InlineData("?ids=abc,def&fileName=x.zip", true)]
+ [InlineData("?ids=", false)]
+ [InlineData("?id=abc", false)]
+ [InlineData("?fn=somefile.pdf", false)]
+ [InlineData(null, false)]
+ public void IsZipRequest_DetectsIdsParameter(string? query, bool expected)
+ {
+ var context = MakeContext(query);
+
+ Assert.Equal(expected, CmsDownloadRateLimiting.IsZipRequest(context));
+ }
+
+ [Fact]
+ public void PartitionKey_ZipAndFileRequests_GetSeparateBuckets()
+ {
+ var zipKey = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?ids=a,b", login: "rexl"));
+ var fileKey = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl"));
+
+ Assert.NotEqual(zipKey, fileKey);
+ Assert.StartsWith("zip|", zipKey);
+ Assert.StartsWith("file|", fileKey);
+ }
+
+ [Fact]
+ public void PartitionKey_AuthenticatedUser_KeysOnLoginNotIp()
+ {
+ var sameUserIp1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl", ip: "10.0.0.1"));
+ var sameUserIp2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl", ip: "10.0.0.2"));
+ var otherUserSameIp = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "bob", ip: "10.0.0.1"));
+
+ Assert.Equal(sameUserIp1, sameUserIp2);
+ Assert.NotEqual(sameUserIp1, otherUserSameIp);
+ }
+
+ [Fact]
+ public void PartitionKey_Anonymous_KeysOnClientIp()
+ {
+ var ip1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", ip: "10.0.0.1"));
+ var ip2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", ip: "10.0.0.2"));
+
+ Assert.NotEqual(ip1, ip2);
+ Assert.Contains("10.0.0.1", ip1);
+ }
+
+ [Fact]
+ public void PartitionKey_AnonymousWithoutIp_UsesStableFallback()
+ {
+ var key1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf"));
+ var key2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=y.pdf"));
+
+ Assert.Equal("file|unknown", key1);
+ Assert.Equal(key1, key2);
+ }
+}
diff --git a/test/CMS/CmsFileCryptoTests.cs b/test/CMS/CmsFileCryptoTests.cs
new file mode 100644
index 000000000..f0ec27114
--- /dev/null
+++ b/test/CMS/CmsFileCryptoTests.cs
@@ -0,0 +1,110 @@
+using System.Security.Cryptography;
+using System.Text;
+using Viper.Areas.CMS.Services;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for the CF-compatible CMS file crypto primitives. The DB key format
+/// (UUEncode over AES-ECB) and content encryption must round-trip so files written
+/// by the new system stay readable by the legacy ColdFusion CMS and vice versa.
+///
+public sealed class CmsFileCryptoTests
+{
+ private static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
+
+ [Fact]
+ public void GenerateFileKey_IsBase64Of128BitKey()
+ {
+ var key = CmsFileCrypto.GenerateFileKey();
+
+ var bytes = Convert.FromBase64String(key);
+ Assert.Equal(16, bytes.Length);
+ }
+
+ [Fact]
+ public void DbKey_RoundTrips_ThroughEncryptAndDecrypt()
+ {
+ var masterKey = NewMasterKey();
+ var fileKey = CmsFileCrypto.GenerateFileKey();
+
+ var dbKey = CmsFileCrypto.EncryptDbKey(fileKey, masterKey);
+ var decrypted = CmsFileCrypto.DecryptDbKey(dbKey, masterKey);
+
+ Assert.Equal(fileKey, decrypted);
+ // Stored keys must be printable (UU-encoded) for the varchar column.
+ Assert.All(dbKey, c => Assert.True(c < 127));
+ }
+
+ [Fact]
+ public void DbKey_DiffersPerCall_EvenForSameFileKey()
+ {
+ // Same plaintext under different master keys must never produce the same stored key.
+ var fileKey = CmsFileCrypto.GenerateFileKey();
+
+ var dbKey1 = CmsFileCrypto.EncryptDbKey(fileKey, NewMasterKey());
+ var dbKey2 = CmsFileCrypto.EncryptDbKey(fileKey, NewMasterKey());
+
+ Assert.NotEqual(dbKey1, dbKey2);
+ }
+
+ [Fact]
+ public void FileContents_RoundTrip_ThroughEncryptAndDecrypt()
+ {
+ var fileKey = CmsFileCrypto.GenerateFileKey();
+ var contents = Encoding.UTF8.GetBytes("PDF-1.7 pretend file contents éü with some length to cross a block boundary.");
+
+ var encrypted = CmsFileCrypto.EncryptBytes(contents, fileKey);
+ var decrypted = CmsFileCrypto.DecryptBytes(encrypted, fileKey);
+
+ Assert.NotEqual(contents, encrypted);
+ Assert.Equal(contents, decrypted);
+ }
+
+ [Fact]
+ public void FullFlow_DbKeyPlusContents_MatchesLegacyDecryptPath()
+ {
+ // Mirrors CMS.DecryptFile: db key -> per-file key -> ECB decrypt of contents.
+ var masterKey = NewMasterKey();
+ var fileKey = CmsFileCrypto.GenerateFileKey();
+ var dbKey = CmsFileCrypto.EncryptDbKey(fileKey, masterKey);
+ var contents = RandomNumberGenerator.GetBytes(1024);
+
+ var encrypted = CmsFileCrypto.EncryptBytes(contents, fileKey);
+ var decrypted = CmsFileCrypto.DecryptBytes(encrypted, CmsFileCrypto.DecryptDbKey(dbKey, masterKey));
+
+ Assert.Equal(contents, decrypted);
+ }
+
+ [Fact]
+ public void ReadMasterKey_ReadsSecondLine()
+ {
+ var path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt");
+ try
+ {
+ File.WriteAllLines(path, new[] { "first line is ignored", " the-master-key ", "third" });
+
+ Assert.Equal("the-master-key", CmsFileCrypto.ReadMasterKey(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ReadMasterKey_MissingKeyLine_Throws()
+ {
+ var path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt");
+ try
+ {
+ File.WriteAllLines(path, new[] { "only one line" });
+
+ Assert.Throws(() => CmsFileCrypto.ReadMasterKey(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+}
diff --git a/test/CMS/CmsFileImportServiceTests.cs b/test/CMS/CmsFileImportServiceTests.cs
new file mode 100644
index 000000000..bbc16a42c
--- /dev/null
+++ b/test/CMS/CmsFileImportServiceTests.cs
@@ -0,0 +1,476 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
+using Viper.Classes.SQLContext;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for CmsFileImportService: importing from the legacy webroot (source containment,
+/// move semantics, oldURL tracking, default permission) and bulk encryption.
+///
+public sealed class CmsFileImportServiceTests : IDisposable
+{
+ private readonly string _webroot;
+ private readonly VIPERContext _context;
+ private readonly ICmsFileStorageService _storage;
+ private readonly ICmsFileEncryptionService _encryption;
+ private readonly ICmsFileAuditService _audit;
+ private readonly CmsFileImportService _service;
+
+ public CmsFileImportServiceTests()
+ {
+ _webroot = Path.Join(Path.GetTempPath(), "ViperCmsImportTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Join(_webroot, "cats", "docs"));
+
+ _context = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options);
+
+ _storage = Substitute.For();
+ _storage.IsValidFolder(Arg.Any()).Returns(true);
+ // Default: available name echoes the input (no rename needed).
+ _storage.GetAvailableFileName(Arg.Any(), Arg.Any())
+ .Returns(callInfo => callInfo.ArgAt(1));
+ _storage.MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ // emulate the real move so source-removal logic can be tested
+ File.Delete(callInfo.ArgAt(0));
+ return Path.Join(@"C:\FakeRoot", callInfo.ArgAt(1), Path.GetFileName(callInfo.ArgAt(2)));
+ });
+
+ _encryption = Substitute.For();
+ _encryption.GenerateKeyForDb().Returns("encrypted-db-key");
+ _audit = Substitute.For();
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["CMS:LegacyWebrootPath"] = _webroot })
+ .Build();
+
+ _service = new CmsFileImportService(_context, _storage, _encryption, _audit,
+ Substitute.For(), configuration, Substitute.For>());
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ if (Directory.Exists(_webroot))
+ {
+ Directory.Delete(_webroot, recursive: true);
+ }
+ }
+
+ private string CreateWebrootFile(string relativePath, string contents = "legacy file")
+ {
+ // Split on both separator styles: on Linux a raw @"cats\docs\x.pdf" would otherwise
+ // become one literal file name in the webroot root instead of a nested path.
+ var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var path = Path.Join(_webroot, Path.Join(segments));
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, contents);
+ return path;
+ }
+
+ [Fact]
+ public async Task Import_MovesFileAndRecordsOldUrl()
+ {
+ var source = CreateWebrootFile(@"cats\docs\manual.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "/cats/docs/manual.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.True(result.Success, result.Message);
+ Assert.Equal("cats-manual.pdf", result.FriendlyName);
+ Assert.False(File.Exists(source));
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.Equal("/cats/docs/manual.pdf", saved.OldUrl);
+ Assert.Equal("Automatically imported from Viper", saved.Description);
+ _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.ImportFile, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Import_DefaultPermission_AddsSvmSecureFolder()
+ {
+ CreateWebrootFile(@"cats\docs\manual.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/docs/manual.pdf" },
+ Folder = @"cats\docs",
+ UseDefaultPermission = true,
+ Permissions = new List { "SVMSecure.Extra" }
+ };
+
+ await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken);
+
+ var saved = await _context.Files.Include(f => f.FileToPermissions).SingleAsync(TestContext.Current.CancellationToken);
+ var permissions = saved.FileToPermissions.Select(p => p.Permission).ToList();
+ Assert.Equal(2, permissions.Count);
+ Assert.Contains("SVMSecure.Extra", permissions);
+ Assert.Contains("SVMSecure.cats", permissions);
+ }
+
+ [Fact]
+ public async Task Import_TraversalOutsideWebroot_Fails()
+ {
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { @"..\..\Windows\system.ini" },
+ Folder = "cats"
+ };
+
+ var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.Success);
+ Assert.Contains("outside", result.Message);
+ Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Import_MissingAndDisallowedFiles_ReportPerFileErrors()
+ {
+ CreateWebrootFile(@"cats\evil.bat", "nope");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/missing.pdf", "cats/evil.bat" },
+ Folder = "cats"
+ };
+
+ var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, results.Count);
+ Assert.All(results, r => Assert.False(r.Success));
+ Assert.Contains("not found", results[0].Message);
+ Assert.Contains("not allowed", results[1].Message);
+ }
+
+ [Fact]
+ public async Task Import_WithEncrypt_EncryptsBeforeMove()
+ {
+ CreateWebrootFile(@"cats\docs\secret.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/docs/secret.pdf" },
+ Folder = "cats",
+ Encrypt = true
+ };
+
+ var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.True(results[0].Success);
+ _encryption.Received(1).EncryptFileInPlace(Arg.Any(), "encrypted-db-key");
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.True(saved.Encrypted);
+ Assert.Equal("encrypted-db-key", saved.Key);
+ }
+
+ #region Preview
+
+ [Fact]
+ public async Task Preview_ValidFile_ReportsNamesWithoutMoving()
+ {
+ var source = CreateWebrootFile(@"cats\docs\manual.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "/cats/docs/manual.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.True(result.CanImport, result.Message);
+ Assert.Equal("manual.pdf", result.FileName);
+ Assert.Equal("cats-manual.pdf", result.FriendlyName);
+ Assert.Equal("/cats/docs/manual.pdf", result.OldUrl);
+ Assert.Null(result.RenamedFrom);
+ // Source must not be moved or deleted.
+ Assert.True(File.Exists(source));
+ // No DB rows must be created.
+ Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Preview_MissingFile_ReportsNotFound()
+ {
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/docs/missing.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.CanImport);
+ Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Preview_TraversalPath_ReportsOutsideWebroot()
+ {
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "../outside.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.CanImport);
+ Assert.Contains("outside", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Preview_DuplicateLines_FlagsSecondAsDuplicate()
+ {
+ CreateWebrootFile(@"cats\docs\manual.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List
+ {
+ "cats/docs/manual.pdf",
+ "cats/docs/manual.pdf"
+ },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, results.Count);
+ Assert.True(results[0].CanImport);
+ Assert.False(results[1].CanImport);
+ Assert.Contains("Duplicate", results[1].Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Preview_ExistingFriendlyName_Blocks()
+ {
+ CreateWebrootFile(@"cats\docs\manual.pdf");
+
+ // Seed a Files row whose FriendlyName matches what the preview would compute.
+ _context.Files.Add(new Viper.Models.VIPER.File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = @"C:\FakeRoot\cats\manual.pdf",
+ Folder = "cats",
+ FriendlyName = "cats-manual.pdf",
+ Description = "existing",
+ ModifiedBy = "test",
+ ModifiedOn = DateTime.Now
+ });
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/docs/manual.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.CanImport);
+ Assert.Contains("cats-manual.pdf", result.Message);
+ }
+
+ [Fact]
+ public async Task Preview_InvalidPathCharacters_ReportsInvalidPath()
+ {
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/bad\0name.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.CanImport);
+ Assert.Contains("not valid", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Preview_DisallowedFileType_ReportsNotAllowed()
+ {
+ CreateWebrootFile(@"cats\evil.bat", "nope");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/evil.bat" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.CanImport);
+ Assert.Contains("not allowed", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Preview_DiskConflict_ReportsAutoRename()
+ {
+ CreateWebrootFile(@"cats\docs\manual.pdf");
+ _storage.GetAvailableFileName("cats", "manual.pdf").Returns("manual_0.pdf");
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List { "cats/docs/manual.pdf" },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.True(result.CanImport, result.Message);
+ Assert.Equal("manual_0.pdf", result.FileName);
+ Assert.Equal("manual.pdf", result.RenamedFrom);
+ Assert.Equal("cats-manual_0.pdf", result.FriendlyName);
+ }
+
+ [Fact]
+ public async Task Preview_TwoLinesSameBasename_SecondPreviewsImportRenamedName()
+ {
+ CreateWebrootFile(@"cats\docs\manual.pdf");
+ // A second file in a different sub-path that produces the same final name after rename.
+ Directory.CreateDirectory(Path.Join(_webroot, "cats", "other"));
+ CreateWebrootFile(@"cats\other\manual.pdf");
+
+ // Disk/DB alone leaves the name free, so both lines resolve to "manual.pdf" on their own.
+ _storage.GetAvailableFileName("cats", "manual.pdf").Returns("manual.pdf");
+ // But once the first line reserves "manual.pdf", the import would rename the second; the
+ // preview must reserve names across the batch and show that same renamed name up front.
+ _storage.GetAvailableFileName("cats", "manual.pdf",
+ Arg.Is?>(s => s != null && s.Contains("manual.pdf"))).Returns("manual_0.pdf");
+
+ var request = new CmsFileImportRequest
+ {
+ FilePaths = new List
+ {
+ "cats/docs/manual.pdf",
+ "cats/other/manual.pdf"
+ },
+ Folder = "cats"
+ };
+
+ var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, results.Count);
+ // First entry: keeps the name, no message.
+ Assert.True(results[0].CanImport);
+ Assert.Equal("manual.pdf", results[0].FileName);
+ Assert.Null(results[0].Message);
+ // Second entry: previews the exact renamed name the import will assign (not the colliding
+ // name), with a non-blocking message explaining the rename.
+ Assert.True(results[1].CanImport);
+ Assert.Equal("manual_0.pdf", results[1].FileName);
+ Assert.Equal("manual.pdf", results[1].RenamedFrom);
+ Assert.Equal("cats-manual_0.pdf", results[1].FriendlyName);
+ Assert.NotNull(results[1].Message);
+ Assert.Contains("renamed", results[1].Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ #endregion
+
+ [Fact]
+ public async Task BulkEncrypt_EncryptsUnencryptedAndSkipsOthers()
+ {
+ var plain = new Viper.Models.VIPER.File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = @"C:\FakeRoot\cats\plain.pdf",
+ Folder = "cats",
+ FriendlyName = "cats-plain.pdf",
+ Description = "",
+ ModifiedBy = "test",
+ ModifiedOn = DateTime.Now
+ };
+ var already = new Viper.Models.VIPER.File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = @"C:\FakeRoot\cats\already.pdf",
+ Folder = "cats",
+ FriendlyName = "cats-already.pdf",
+ Description = "",
+ Encrypted = true,
+ Key = "existing-key",
+ ModifiedBy = "test",
+ ModifiedOn = DateTime.Now
+ };
+ _context.Files.AddRange(plain, already);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ _storage.ManagedFileExists(plain.FilePath).Returns(true);
+
+ var results = await _service.BulkEncryptAsync(
+ new List { plain.FileGuid, already.FileGuid, Guid.NewGuid() }, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, results.Count);
+ Assert.True(results[0].Success);
+ Assert.False(results[1].Success);
+ Assert.Contains("Already encrypted", results[1].Message);
+ Assert.False(results[2].Success);
+ _encryption.Received(1).EncryptFileInPlace(plain.FilePath, "encrypted-db-key");
+ var saved = await _context.Files.SingleAsync(f => f.FileGuid == plain.FileGuid, TestContext.Current.CancellationToken);
+ Assert.True(saved.Encrypted);
+ }
+
+ [Fact]
+ public async Task BulkEncrypt_NonDatabaseSaveFailure_DecryptsFileBack()
+ {
+ // Named in-memory stores are shared, so the seeding context and the failing
+ // context (whose interceptor throws a non-DB exception on save) see the same data.
+ var storeName = "VIPER_" + Guid.NewGuid();
+ var plain = new Viper.Models.VIPER.File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = @"C:\FakeRoot\cats\plain.pdf",
+ Folder = "cats",
+ FriendlyName = "cats-plain.pdf",
+ Description = "",
+ ModifiedBy = "test",
+ ModifiedOn = DateTime.Now
+ };
+ await using (var seedContext = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(storeName).Options))
+ {
+ seedContext.Files.Add(plain);
+ await seedContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await using var failingContext = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(storeName)
+ .AddInterceptors(new ThrowingSaveInterceptor())
+ .Options);
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["CMS:LegacyWebrootPath"] = _webroot })
+ .Build();
+ var service = new CmsFileImportService(failingContext, _storage, _encryption, _audit,
+ Substitute.For(), configuration, Substitute.For>());
+ _storage.ManagedFileExists(plain.FilePath).Returns(true);
+
+ var results = await service.BulkEncryptAsync(new List { plain.FileGuid }, TestContext.Current.CancellationToken);
+
+ var result = Assert.Single(results);
+ Assert.False(result.Success);
+ Assert.Contains("simulated save failure", result.Message);
+ _encryption.Received(1).DecryptFileInPlace(plain.FilePath, "encrypted-db-key");
+ }
+
+ private sealed class ThrowingSaveInterceptor : SaveChangesInterceptor
+ {
+ public override ValueTask> SavingChangesAsync(DbContextEventData eventData,
+ InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ throw new InvalidOperationException("simulated save failure");
+ }
+ }
+}
diff --git a/test/CMS/CmsFileResponseTests.cs b/test/CMS/CmsFileResponseTests.cs
new file mode 100644
index 000000000..b199b3dc5
--- /dev/null
+++ b/test/CMS/CmsFileResponseTests.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Http;
+using Viper.Areas.CMS.Constants;
+using Viper.Areas.CMS.Services;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for the /CMS/Files download-response hardening: X-Content-Type-Options: nosniff on every
+/// file response, and forcing inline-unsafe types (html/svg) to download instead of rendering in
+/// the app origin (stored-XSS guard). Legacy .html files stay downloadable, just never inline.
+///
+public sealed class CmsFileResponseTests
+{
+ [Fact]
+ public void SetNoSniff_SetsHeader()
+ {
+ var response = new DefaultHttpContext().Response;
+
+ CmsFileResponse.SetNoSniff(response);
+
+ Assert.Equal("nosniff", response.Headers["X-Content-Type-Options"]);
+ }
+
+ [Theory]
+ [InlineData("page.html")]
+ [InlineData("PAGE.HTM")]
+ [InlineData("doc.xhtml")]
+ [InlineData("logo.svg")]
+ [InlineData(@"S:\Files\cats\evil.HTML")]
+ public void DispositionType_InlineUnsafe_ForcesAttachment(string fileName)
+ {
+ Assert.Equal("attachment", CmsFileResponse.DispositionType(fileName));
+ Assert.True(CmsFileTypes.ShouldForceAttachment(fileName));
+ }
+
+ [Theory]
+ [InlineData("report.pdf")]
+ [InlineData("image.png")]
+ [InlineData("notes.txt")]
+ [InlineData("sheet.xlsx")]
+ [InlineData("noextension")]
+ public void DispositionType_SafeTypes_StayInline(string fileName)
+ {
+ Assert.Equal("inline", CmsFileResponse.DispositionType(fileName));
+ Assert.False(CmsFileTypes.ShouldForceAttachment(fileName));
+ }
+}
diff --git a/test/CMS/CmsFileServiceTests.cs b/test/CMS/CmsFileServiceTests.cs
new file mode 100644
index 000000000..601e19e06
--- /dev/null
+++ b/test/CMS/CmsFileServiceTests.cs
@@ -0,0 +1,1171 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models.VIPER;
+using File = Viper.Models.VIPER.File;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for CmsFileService CRUD orchestration: friendly-name generation, permission and
+/// person deltas, soft delete/restore, and audit calls. Storage and encryption are mocked;
+/// the database is EF in-memory.
+///
+public sealed class CmsFileServiceTests : IDisposable
+{
+ private readonly VIPERContext _context;
+ private readonly AAUDContext _aaudContext;
+ private readonly ICmsFileStorageService _storage;
+ private readonly ICmsFileEncryptionService _encryption;
+ private readonly ICmsFileAuditService _audit;
+ private readonly IUserHelper _userHelper;
+ private readonly CmsFileService _service;
+ private readonly List _extraContexts = new();
+ private readonly List _formFileStreams = new();
+
+ public CmsFileServiceTests()
+ {
+ _context = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options);
+ _aaudContext = new AAUDContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options);
+
+ _storage = Substitute.For();
+ _storage.RootFolder.Returns(@"C:\FakeRoot");
+ _storage.IsValidFolder(Arg.Any()).Returns(true);
+ _storage.SaveToTempAsync(Arg.Any(), Arg.Any())
+ .Returns(_ => Task.FromResult(@"C:\FakeTemp\" + Guid.NewGuid().ToString("N")));
+ _storage.MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo => Path.Join(@"C:\FakeRoot", callInfo.ArgAt(1), Path.GetFileName(callInfo.ArgAt(2))));
+
+ _encryption = Substitute.For();
+ _encryption.GenerateKeyForDb().Returns("encrypted-db-key");
+
+ _audit = Substitute.For();
+ _userHelper = Substitute.For();
+
+ _service = new CmsFileService(_context, _aaudContext, _storage, _encryption, _audit, _userHelper,
+ Substitute.For>());
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _aaudContext.Dispose();
+ foreach (var ctx in _extraContexts)
+ {
+ ctx.Dispose();
+ }
+ foreach (var stream in _formFileStreams)
+ {
+ stream.Dispose();
+ }
+ }
+
+ private IFormFile MakeFormFile(string fileName = "report.pdf")
+ {
+ // Tracked so the backing stream is disposed in Dispose rather than left to the GC.
+ var stream = new MemoryStream(new byte[] { 1, 2, 3 });
+ _formFileStreams.Add(stream);
+ return new FormFile(stream, 0, stream.Length, "file", fileName);
+ }
+
+ ///
+ /// Writes a real temp file to stand in for a backup the service returns from BackupManagedFile.
+ /// A real path lets the cleanup branch (System.IO.File.Exists/Delete) actually execute rather
+ /// than short-circuiting on a fake path that never exists. Callers delete it in a finally.
+ ///
+ private static string MakeRealBackupFile()
+ {
+ string path = Path.Join(Path.GetTempPath(), "Viper-CMS-Test-" + Guid.NewGuid().ToString("N"));
+ System.IO.File.WriteAllBytes(path, new byte[] { 9, 9, 9 });
+ return path;
+ }
+
+ // Shared seed timestamp so update requests can present a matching LastModifiedOn
+ // (UpdateFileAsync rejects stale or missing values with 409/400 semantics).
+ private readonly DateTime _seededModifiedOn = DateTime.Now.AddDays(-1);
+
+ private async Task SeedFileAsync(Action? customize = null)
+ {
+ var file = new File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = @"C:\FakeRoot\cats\seeded.pdf",
+ Folder = "cats",
+ FriendlyName = "cats-seeded.pdf",
+ Description = "seeded",
+ ModifiedBy = "test",
+ ModifiedOn = _seededModifiedOn
+ };
+ customize?.Invoke(file);
+ _context.Files.Add(file);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ return file;
+ }
+
+ ///
+ /// Seeds a file into a shared named store, then returns a service backed by a second context
+ /// on that store whose interceptor throws on save. Lets the rollback path be exercised without
+ /// a real database. Reuses the mocked storage/encryption so their calls can be asserted.
+ ///
+ private async Task<(CmsFileService Service, Guid FileGuid, string FilePath)> BuildServiceOverFailingSaveAsync(
+ Action? customize = null)
+ {
+ const string filePath = @"C:\FakeRoot\cats\plain.pdf";
+ var storeName = "VIPER_" + Guid.NewGuid();
+ var file = new File
+ {
+ FileGuid = Guid.NewGuid(),
+ FilePath = filePath,
+ Folder = "cats",
+ FriendlyName = "cats-plain.pdf",
+ Description = "seeded",
+ ModifiedBy = "test",
+ ModifiedOn = _seededModifiedOn
+ };
+ customize?.Invoke(file);
+ await using (var seedContext = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(storeName).Options))
+ {
+ seedContext.Files.Add(file);
+ await seedContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var failingContext = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(storeName)
+ .AddInterceptors(new ThrowingSaveInterceptor())
+ .Options);
+ _extraContexts.Add(failingContext);
+ var service = new CmsFileService(failingContext, _aaudContext, _storage, _encryption, _audit, _userHelper,
+ Substitute.For>());
+ return (service, file.FileGuid, filePath);
+ }
+
+ private sealed class ThrowingSaveInterceptor : SaveChangesInterceptor
+ {
+ public override ValueTask> SavingChangesAsync(DbContextEventData eventData,
+ InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ throw new InvalidOperationException("simulated save failure");
+ }
+ }
+
+ ///
+ /// Throws only when the save being intercepted deletes the given file, so a purge run's other
+ /// per-file saves succeed normally. Lets PurgeDeletedFilesAsync's per-file isolation be tested
+ /// without a database that can produce a real transient failure.
+ ///
+ private sealed class SelectiveThrowingSaveInterceptor : SaveChangesInterceptor
+ {
+ private readonly Guid _failingFileGuid;
+
+ public SelectiveThrowingSaveInterceptor(Guid failingFileGuid)
+ {
+ _failingFileGuid = failingFileGuid;
+ }
+
+ public override ValueTask> SavingChangesAsync(DbContextEventData eventData,
+ InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ bool targetsFailingFile = eventData.Context?.ChangeTracker.Entries()
+ .Any(e => e.State == EntityState.Deleted && e.Entity.FileGuid == _failingFileGuid) ?? false;
+ if (targetsFailingFile)
+ {
+ throw new InvalidOperationException("simulated transient save failure");
+ }
+ return ValueTask.FromResult(result);
+ }
+ }
+
+ #region Create
+
+ [Fact]
+ public async Task CreateFile_BuildsFriendlyNameFromFolderAndFileName()
+ {
+ var request = new CmsFileCreateRequest { Folder = @"cats\photos" };
+
+ var dto = await _service.CreateFileAsync(request, MakeFormFile("pic.jpg"), TestContext.Current.CancellationToken);
+
+ Assert.Equal("cats-photos-pic.jpg", dto.FriendlyName);
+ Assert.Equal("pic.jpg", dto.FileName);
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(@"cats\photos", saved.Folder);
+ Assert.False(saved.Encrypted);
+ Assert.Null(saved.Key);
+ }
+
+ [Fact]
+ public async Task CreateFile_NormalizesForwardSlashFolderToCanonicalForm()
+ {
+ // IsValidFolder accepts both separator styles, but records store the canonical
+ // '\' form so the list filter's "folder\" prefix match finds subfolder rows.
+ var request = new CmsFileCreateRequest { Folder = "cats/photos" };
+
+ await _service.CreateFileAsync(request, MakeFormFile("pic.jpg"), TestContext.Current.CancellationToken);
+
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(@"cats\photos", saved.Folder);
+ }
+
+ [Fact]
+ public async Task CreateFile_SavesPermissionsAndPeople_Deduplicated()
+ {
+ var request = new CmsFileCreateRequest
+ {
+ Folder = "cats",
+ Permissions = new List { "SVMSecure.CATS", "svmsecure.cats", " SVMSecure.Admin " },
+ IamIds = new List { "1000123", "1000123", "1000456" }
+ };
+
+ var dto = await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, dto.Permissions.Count);
+ Assert.Contains("SVMSecure.Admin", dto.Permissions);
+ Assert.Equal(2, dto.People.Count);
+ }
+
+ [Fact]
+ public async Task CreateFile_WithEncrypt_GeneratesKeyAndEncryptsTemp()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats", Encrypt = true };
+
+ var dto = await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ Assert.True(dto.Encrypted);
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.Equal("encrypted-db-key", saved.Key);
+ _encryption.Received(1).EncryptFileInPlace(Arg.Any(), "encrypted-db-key");
+ }
+
+ [Fact]
+ public async Task CreateFile_AuditsAddAndUpload()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats" };
+
+ await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.AddFile, Arg.Any());
+ _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.UploadFile, "NewFile");
+ }
+
+ [Fact]
+ public async Task CreateFile_DuplicateFriendlyName_DeletesMovedFileAndThrows()
+ {
+ await SeedFileAsync(f => f.FriendlyName = "cats-report.pdf");
+ var request = new CmsFileCreateRequest { Folder = "cats" };
+
+ await Assert.ThrowsAsync(
+ () => _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken));
+
+ _storage.Received(1).DeleteManagedFile(Path.Join(@"C:\FakeRoot", "cats", "report.pdf"));
+ }
+
+ [Fact]
+ public async Task CreateFile_InvalidFolder_Throws()
+ {
+ _storage.IsValidFolder("badfolder").Returns(false);
+ var request = new CmsFileCreateRequest { Folder = "badfolder" };
+
+ await Assert.ThrowsAsync(
+ () => _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task CreateFile_DisallowedExtension_Throws()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats" };
+
+ await Assert.ThrowsAsync(
+ () => _service.CreateFileAsync(request, MakeFormFile("evil.bat"), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task CreateFile_FileNameOverride_StoresUnderOverrideName()
+ {
+ var request = new CmsFileCreateRequest { Folder = "cats", FileName = "custom.pdf" };
+
+ var dto = await _service.CreateFileAsync(request, MakeFormFile("orig.pdf"), TestContext.Current.CancellationToken);
+
+ // MoveIntoPlace should have been called with the override name, not the upload name.
+ _storage.Received(1).MoveIntoPlace(Arg.Any(), "cats", "custom.pdf", Arg.Any());
+ Assert.Equal("custom.pdf", dto.FileName);
+ Assert.Equal("cats-custom.pdf", dto.FriendlyName);
+ }
+
+ [Fact]
+ public async Task CreateFile_OverwriteDiskOnly_ReplacesInPlace()
+ {
+ const string targetPath = @"C:\FakeRoot\cats\report.pdf";
+ _storage.FileNameInUse("cats", "report.pdf").Returns(true);
+ _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath);
+
+ var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true };
+
+ await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken);
+
+ _storage.Received(1).ReplaceInPlace(Arg.Any(), targetPath);
+ _storage.DidNotReceive().MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(targetPath, saved.FilePath);
+ }
+
+ [Fact]
+ public async Task CreateFile_OverwriteManagedRecord_Throws()
+ {
+ const string targetPath = @"C:\FakeRoot\cats\report.pdf";
+ _storage.FileNameInUse("cats", "report.pdf").Returns(true);
+ _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath);
+
+ // Seed a Files row whose FilePath matches the target path.
+ await SeedFileAsync(f =>
+ {
+ f.FilePath = targetPath;
+ f.FriendlyName = "cats-report.pdf";
+ });
+
+ var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true };
+
+ await Assert.ThrowsAsync