diff --git a/backend/CPS.ComplexCases.API.Tests/Unit/Functions/CreateNetappConnectionTests.cs b/backend/CPS.ComplexCases.API.Tests/Unit/Functions/CreateNetappConnectionTests.cs index c61672431..146f750fe 100644 --- a/backend/CPS.ComplexCases.API.Tests/Unit/Functions/CreateNetappConnectionTests.cs +++ b/backend/CPS.ComplexCases.API.Tests/Unit/Functions/CreateNetappConnectionTests.cs @@ -12,6 +12,7 @@ using CPS.ComplexCases.Common.Helpers; using CPS.ComplexCases.Common.Models; using CPS.ComplexCases.Common.Services; +using CPS.ComplexCases.Data.Entities; using CPS.ComplexCases.Data.Models.Requests; using CPS.ComplexCases.NetApp.Client; using CPS.ComplexCases.NetApp.Factories; @@ -171,6 +172,10 @@ public async Task Run_ValidRequestWithPermission_CreatesConnectionAndReturnsOk() .Setup(x => x.ListFoldersInBucketAsync(netAppArg)) .ReturnsAsync(netAppResponse); + _caseMetadataServiceMock + .Setup(x => x.GetCaseMetadataForNetAppFolderPathsAsync(It.IsAny>())) + .ReturnsAsync(Enumerable.Empty()); + _caseMetadataServiceMock .Setup(x => x.CreateNetAppConnectionAsync(netAppConnectionRequest)) .Returns(Task.CompletedTask); @@ -204,6 +209,133 @@ public async Task Run_ValidRequestWithPermission_CreatesConnectionAndReturnsOk() _testUsername, null), Times.Once); } + [Fact] + public async Task Run_WhenFolderPathAlreadyConnected_ReturnsConflict() + { + // Arrange + var netAppConnectionRequest = _fixture.Create(); + var netAppArg = _fixture.Create(); + var netAppResponse = _fixture.Create(); + var existingCaseId = _fixture.Create(); + var folderPath = netAppConnectionRequest.NetAppFolderPath; + var legacyPath = $"{_testBucketName}:{folderPath}"; + + _requestValidatorMock + .Setup(x => x.GetJsonBody(It.IsAny())) + .ReturnsAsync(new ValidatableRequest + { + IsValid = true, + Value = netAppConnectionRequest + }); + + _netAppArgFactoryMock + .Setup(x => x.CreateListFoldersInBucketArg(_testBearerToken, _testBucketName, netAppConnectionRequest.OperationName, null, 1, null)) + .Returns(netAppArg); + + _netAppClientMock + .Setup(x => x.ListFoldersInBucketAsync(netAppArg)) + .ReturnsAsync(netAppResponse); + + _caseMetadataServiceMock + .Setup(x => x.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => + paths.Contains(folderPath) && paths.Contains(legacyPath)))) + .ReturnsAsync([ + new CaseMetadata + { + CaseId = existingCaseId, + NetappFolderPath = folderPath + } + ]); + + var request = HttpRequestStubHelper.CreateHttpRequestFor(netAppConnectionRequest); + var functionContext = FunctionContextStubHelper.CreateFunctionContextStub(_testCorrelationId, _testCmsAuthValues, _testUsername, _testBearerToken); + + // Act + var result = await _function.Run(request, functionContext); + + // Assert + var conflict = Assert.IsType(result); + Assert.Equal( + $"Folder path '{folderPath}' is already connected to another case.", + conflict.Value); + + _caseMetadataServiceMock.Verify(x => x.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => + paths.Contains(folderPath) && paths.Contains(legacyPath))), Times.Once); + _caseMetadataServiceMock.Verify(x => x.CreateNetAppConnectionAsync(It.IsAny()), Times.Never); + _activityLogServiceMock.Verify(x => x.CreateActivityLogAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + null), Times.Never); + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains(folderPath) && + v.ToString()!.Contains(existingCaseId.ToString()) && + v.ToString()!.Contains(netAppConnectionRequest.CaseId.ToString())), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Run_WhenLegacyBucketPathAlreadyConnected_ReturnsConflict() + { + // Arrange + var netAppConnectionRequest = _fixture.Create(); + var netAppArg = _fixture.Create(); + var netAppResponse = _fixture.Create(); + var existingCaseId = _fixture.Create(); + var folderPath = netAppConnectionRequest.NetAppFolderPath; + var legacyPath = $"{_testBucketName}:{folderPath}"; + + _requestValidatorMock + .Setup(x => x.GetJsonBody(It.IsAny())) + .ReturnsAsync(new ValidatableRequest + { + IsValid = true, + Value = netAppConnectionRequest + }); + + _netAppArgFactoryMock + .Setup(x => x.CreateListFoldersInBucketArg(_testBearerToken, _testBucketName, netAppConnectionRequest.OperationName, null, 1, null)) + .Returns(netAppArg); + + _netAppClientMock + .Setup(x => x.ListFoldersInBucketAsync(netAppArg)) + .ReturnsAsync(netAppResponse); + + _caseMetadataServiceMock + .Setup(x => x.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => + paths.Contains(folderPath) && paths.Contains(legacyPath)))) + .ReturnsAsync([ + new CaseMetadata + { + CaseId = existingCaseId, + NetappFolderPath = legacyPath + } + ]); + + var request = HttpRequestStubHelper.CreateHttpRequestFor(netAppConnectionRequest); + var functionContext = FunctionContextStubHelper.CreateFunctionContextStub(_testCorrelationId, _testCmsAuthValues, _testUsername, _testBearerToken); + + // Act + var result = await _function.Run(request, functionContext); + + // Assert + var conflict = Assert.IsType(result); + Assert.Equal( + $"Folder path '{folderPath}' is already connected to another case.", + conflict.Value); + + _caseMetadataServiceMock.Verify(x => x.CreateNetAppConnectionAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task Run_NetAppArgFactoryCalledWithCorrectParameters() { @@ -340,6 +472,10 @@ public async Task Run_WhenActivityLogThrows_StillReturnsOk() .Setup(x => x.ListFoldersInBucketAsync(netAppArg)) .ReturnsAsync(netAppResponse); + _caseMetadataServiceMock + .Setup(x => x.GetCaseMetadataForNetAppFolderPathsAsync(It.IsAny>())) + .ReturnsAsync(Enumerable.Empty()); + _caseMetadataServiceMock .Setup(x => x.CreateNetAppConnectionAsync(netAppConnectionRequest)) .Returns(Task.CompletedTask); diff --git a/backend/CPS.ComplexCases.API.Tests/Unit/Services/CaseEnrichmentServiceTests.cs b/backend/CPS.ComplexCases.API.Tests/Unit/Services/CaseEnrichmentServiceTests.cs index cf6deebd0..6f5dd9030 100644 --- a/backend/CPS.ComplexCases.API.Tests/Unit/Services/CaseEnrichmentServiceTests.cs +++ b/backend/CPS.ComplexCases.API.Tests/Unit/Services/CaseEnrichmentServiceTests.cs @@ -418,7 +418,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenFoldersProvided_Enric var folders = CreateSampleNetAppFolders(3); var folderPaths = folders.Data.FolderData .Where(d => d.Path != null) - .Select(d => $"{folders.Data.BucketName}:{d.Path}") + .Select(d => d.Path!) .ToList(); var metadata = folderPaths.Select(path => new CaseMetadata @@ -430,7 +430,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenFoldersProvided_Enric _caseMetadataServiceMock .Setup(s => s.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => - paths.All(p => folderPaths.Contains(p)) && paths.Count() == folderPaths.Count))) + folderPaths.All(p => paths.Contains(p))))) .ReturnsAsync(metadata); // Act @@ -441,8 +441,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenFoldersProvided_Enric foreach (var folderResponse in result.Data.Folders) { - var expectedPath = $"{folders.Data.BucketName}:{folderResponse.FolderPath}"; - var expectedMetadata = metadata.First(m => m.NetappFolderPath == expectedPath); + var expectedMetadata = metadata.First(m => m.NetappFolderPath == folderResponse.FolderPath); Assert.Equal(expectedMetadata.CaseId, folderResponse.CaseId); } @@ -451,7 +450,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenFoldersProvided_Enric _caseMetadataServiceMock.Verify( s => s.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => - paths.All(p => folderPaths.Contains(p)) && paths.Count() == folderPaths.Count)), + folderPaths.All(p => paths.Contains(p)))), Times.Once); } @@ -462,7 +461,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenMetadataNotFoundForSo var folders = CreateSampleNetAppFolders(3); var folderPaths = folders.Data.FolderData .Where(d => d.Path != null) - .Select(d => $"{folders.Data.BucketName}:{d.Path}") + .Select(d => d.Path!) .ToList(); var metadata = new List @@ -494,6 +493,35 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenMetadataNotFoundForSo } } + [Fact] + public async Task EnrichNetAppFoldersWithMetadataAsync_WhenMetadataStoredAsBucketPath_StillEnriches() + { + // Arrange + var folders = CreateSampleNetAppFolders(1); + var folderPath = folders.Data.FolderData.First().Path!; + var legacyPath = $"{folders.Data.BucketName}:{folderPath}"; + var metadata = new List + { + new CaseMetadata + { + CaseId = _fixture.Create(), + NetappFolderPath = legacyPath + } + }; + + _caseMetadataServiceMock + .Setup(s => s.GetCaseMetadataForNetAppFolderPathsAsync(It.Is>(paths => + paths.Contains(folderPath) && paths.Contains(legacyPath)))) + .ReturnsAsync(metadata); + + // Act + var result = await _service.EnrichNetAppFoldersWithMetadataAsync(folders); + + // Assert + Assert.Equal(metadata.First().CaseId, result.Data.Folders.Single().CaseId); + Assert.Equal(folderPath, result.Data.Folders.Single().FolderPath); + } + [Fact] public async Task EnrichNetAppFoldersWithMetadataAsync_WhenNoFoldersProvided_ReturnsEmptyCollection() { @@ -608,7 +636,7 @@ public async Task EnrichNetAppFoldersWithMetadataAsync_WhenMetadataHasNullNetapp var folders = CreateSampleNetAppFolders(2); var folderPaths = folders.Data.FolderData .Where(d => d.Path != null) - .Select(d => $"{folders.Data.BucketName}:{d.Path}") + .Select(d => d.Path!) .ToList(); var metadata = new List diff --git a/backend/CPS.ComplexCases.API/Functions/CreateNetAppConnection.cs b/backend/CPS.ComplexCases.API/Functions/CreateNetAppConnection.cs index 3ad1cfbe6..44fe60080 100644 --- a/backend/CPS.ComplexCases.API/Functions/CreateNetAppConnection.cs +++ b/backend/CPS.ComplexCases.API/Functions/CreateNetAppConnection.cs @@ -47,6 +47,7 @@ public class CreateNetAppConnection(ILogger logger, [OpenApiResponseWithBody(statusCode: HttpStatusCode.BadRequest, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.BadRequest)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.Unauthorized, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.Unauthorized)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.Forbidden, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.Forbidden)] + [OpenApiResponseWithBody(statusCode: HttpStatusCode.Conflict, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.Conflict)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.InternalServerError, contentType: ContentType.TextPlain, typeof(string), Description = ApiResponseDescriptions.InternalServerError)] public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/netapp/connections")] HttpRequest req, FunctionContext functionContext) { @@ -71,6 +72,20 @@ public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, return new UnauthorizedResult(); } + var folderPath = netAppConnectionRequest.Value.NetAppFolderPath; + var bucketName = securityGroups.First().BucketName; + var lookupPaths = new[] { folderPath, $"{bucketName}:{folderPath}" }; + var existingConnections = await _caseMetadataService.GetCaseMetadataForNetAppFolderPathsAsync(lookupPaths); + var existingConnection = existingConnections.FirstOrDefault(); + + if (existingConnection != null) + { + _logger.LogWarning( + "Duplicate NetApp connection attempt: folder path '{FolderPath}' is already connected to case {ExistingCaseId}. Requested by case {RequestedCaseId}.", + folderPath, existingConnection.CaseId, netAppConnectionRequest.Value.CaseId); + return new ConflictObjectResult($"Folder path '{folderPath}' is already connected to another case."); + } + await _caseMetadataService.CreateNetAppConnectionAsync(netAppConnectionRequest.Value); try diff --git a/backend/CPS.ComplexCases.API/Services/CaseEnrichmentService.cs b/backend/CPS.ComplexCases.API/Services/CaseEnrichmentService.cs index 002aacf0f..0bc9b2f73 100644 --- a/backend/CPS.ComplexCases.API/Services/CaseEnrichmentService.cs +++ b/backend/CPS.ComplexCases.API/Services/CaseEnrichmentService.cs @@ -1,9 +1,10 @@ using Microsoft.Extensions.Logging; using CPS.ComplexCases.API.Domain.Response; +using CPS.ComplexCases.Common.Services; +using CPS.ComplexCases.Data.Entities; using CPS.ComplexCases.DDEI.Models.Dto; using CPS.ComplexCases.Egress.Models.Dto; using CPS.ComplexCases.NetApp.Models.Dto; -using CPS.ComplexCases.Common.Services; namespace CPS.ComplexCases.API.Services; @@ -107,11 +108,18 @@ public async Task EnrichNetAppFoldersWithMetadataAsyn try { - var folderPaths = folders.Data.FolderData.Where(d => d.Path != null) - .Select(d => $"{folders.Data.BucketName}:{d.Path}") - .ToList(); - - var metadata = await _caseMetadataService.GetCaseMetadataForNetAppFolderPathsAsync(folderPaths); + var folderPaths = folders.Data.FolderData + .Where(d => d.Path != null) + .Select(d => d.Path!) + .ToList(); + + // Look up plain paths (as stored on connect) and bucket:path variants for any legacy rows + var lookupPaths = folderPaths + .SelectMany(path => new[] { path, $"{folders.Data.BucketName}:{path}" }) + .Distinct() + .ToList(); + + var metadata = await _caseMetadataService.GetCaseMetadataForNetAppFolderPathsAsync(lookupPaths); var metadataLookup = metadata .Where(m => m.NetappFolderPath != null) .ToDictionary(m => m.NetappFolderPath!); @@ -119,10 +127,10 @@ public async Task EnrichNetAppFoldersWithMetadataAsyn // Enrich data with metadata response.Data = new ListNetAppObjectsDataResponse { - Folders = folderPaths.Select(folder => new ListNetAppFoldersDataResponse + Folders = folderPaths.Select(folderPath => new ListNetAppFoldersDataResponse { - FolderPath = folder[(folder.LastIndexOf(':') + 1)..] ?? string.Empty, - CaseId = metadataLookup.TryGetValue(folder, out var caseMetadata) ? caseMetadata.CaseId : null + FolderPath = folderPath, + CaseId = ResolveNetAppFolderCaseId(folderPath, folders.Data.BucketName, metadataLookup) }).ToList(), Files = new List(), RootPath = folders.Data.RootPath @@ -137,6 +145,24 @@ public async Task EnrichNetAppFoldersWithMetadataAsyn } } + private static int? ResolveNetAppFolderCaseId( + string folderPath, + string bucketName, + IReadOnlyDictionary metadataLookup) + { + if (metadataLookup.TryGetValue(folderPath, out var caseMetadata)) + { + return caseMetadata.CaseId; + } + + if (metadataLookup.TryGetValue($"{bucketName}:{folderPath}", out caseMetadata)) + { + return caseMetadata.CaseId; + } + + return null; + } + private static CaseWithMetadataResponse MapCaseToResponse(CaseDto caseDto) { return new CaseWithMetadataResponse