Skip to content

Commit a070867

Browse files
committed
PM-31923 adding renew and delete endpoints
1 parent 00584c1 commit a070867

2 files changed

Lines changed: 365 additions & 0 deletions

File tree

src/Api/Dirt/Controllers/OrganizationReportsController.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,99 @@ public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsyn
297297
return Ok(summaryDataList.Select(s => new OrganizationReportSummaryDataResponseModel(s)));
298298
}
299299

300+
/// <summary>
301+
/// Deletes an organization report and its associated file from storage.
302+
/// Removes the database record first, then cleans up any stored files.
303+
/// </summary>
304+
/// <param name="organizationId">The unique identifier of the organization.</param>
305+
/// <param name="reportId">The unique identifier of the report to delete.</param>
306+
[HttpDelete("{organizationId}/{reportId}")]
307+
public async Task DeleteOrganizationReportAsync(Guid organizationId, Guid reportId)
308+
{
309+
if (organizationId == Guid.Empty)
310+
{
311+
throw new BadRequestException("OrganizationId is required.");
312+
}
313+
314+
if (reportId == Guid.Empty)
315+
{
316+
throw new BadRequestException("ReportId is required.");
317+
}
318+
319+
await AuthorizeAsync(organizationId);
320+
321+
var report = await _organizationReportRepo.GetByIdAsync(reportId);
322+
if (report == null)
323+
{
324+
throw new NotFoundException();
325+
}
326+
327+
if (report.OrganizationId != organizationId)
328+
{
329+
throw new BadRequestException("Invalid report ID");
330+
}
331+
332+
var fileData = report.GetReportFile();
333+
334+
await _organizationReportRepo.DeleteAsync(report);
335+
336+
if (fileData != null && !string.IsNullOrEmpty(fileData.Id))
337+
{
338+
await _storageService.DeleteReportFilesAsync(report, fileData.Id);
339+
}
340+
}
341+
342+
/// <summary>
343+
/// Renews the file upload URL for an organization report that has not yet been validated.
344+
/// Returns a fresh presigned upload URL for the report file, allowing the client to retry
345+
/// an upload after the original URL has expired. Requires the Access Intelligence V2 feature flag.
346+
/// </summary>
347+
/// <param name="organizationId">The unique identifier of the organization.</param>
348+
/// <param name="reportId">The unique identifier of the report with the pending file upload.</param>
349+
/// <param name="reportFileId">The identifier of the report file entry to renew the upload URL for.</param>
350+
/// <returns>An <see cref="OrganizationReportFileResponseModel"/> with the renewed upload URL.</returns>
351+
[RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)]
352+
[HttpGet("{organizationId}/{reportId}/file/renew")]
353+
public async Task<OrganizationReportFileResponseModel> RenewFileUploadUrlAsync(
354+
Guid organizationId, Guid reportId, [FromQuery] string reportFileId)
355+
{
356+
if (organizationId == Guid.Empty)
357+
{
358+
throw new BadRequestException("OrganizationId is required.");
359+
}
360+
361+
if (reportId == Guid.Empty)
362+
{
363+
throw new BadRequestException("ReportId is required.");
364+
}
365+
366+
await AuthorizeAsync(organizationId);
367+
368+
var report = await _organizationReportRepo.GetByIdAsync(reportId);
369+
if (report == null)
370+
{
371+
throw new NotFoundException();
372+
}
373+
374+
if (report.OrganizationId != organizationId)
375+
{
376+
throw new BadRequestException("Invalid report ID");
377+
}
378+
379+
var fileData = report.GetReportFile();
380+
if (fileData == null || fileData.Id != reportFileId || fileData.Validated)
381+
{
382+
throw new NotFoundException();
383+
}
384+
385+
return new OrganizationReportFileResponseModel
386+
{
387+
ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData),
388+
ReportResponse = new OrganizationReportResponseModel(report),
389+
FileUploadType = _storageService.FileUploadType
390+
};
391+
}
392+
300393
/// <summary>
301394
/// Handles Azure Event Grid webhook notifications for blob storage events.
302395
/// When a <c>Microsoft.Storage.BlobCreated</c> event is received, validates the uploaded

test/Api.Test/Dirt/OrganizationReportsControllerTests.cs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
99
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
1010
using Bit.Core.Dirt.Reports.Services;
11+
using Bit.Core.Dirt.Repositories;
1112
using Bit.Core.Enums;
1213
using Bit.Core.Exceptions;
1314
using Bit.Core.Models.Data.Organizations;
@@ -384,6 +385,277 @@ await sutProvider.GetDependency<IGetOrganizationReportQuery>()
384385
.GetOrganizationReportAsync(Arg.Any<Guid>());
385386
}
386387

388+
// DeleteOrganizationReportAsync
389+
390+
[Theory, BitAutoData]
391+
public async Task DeleteOrganizationReportAsync_WithFile_DeletesDbThenStorage(
392+
SutProvider<OrganizationReportsController> sutProvider,
393+
Guid orgId,
394+
OrganizationReport report)
395+
{
396+
// Arrange
397+
var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true };
398+
report.OrganizationId = orgId;
399+
report.SetReportFile(reportFile);
400+
401+
SetupAuthorization(sutProvider, orgId);
402+
403+
sutProvider.GetDependency<IOrganizationReportRepository>()
404+
.GetByIdAsync(report.Id)
405+
.Returns(report);
406+
407+
// Act
408+
await sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id);
409+
410+
// Assert
411+
await sutProvider.GetDependency<IOrganizationReportRepository>()
412+
.Received(1)
413+
.DeleteAsync(report);
414+
415+
await sutProvider.GetDependency<IOrganizationReportStorageService>()
416+
.Received(1)
417+
.DeleteReportFilesAsync(report, "file-id");
418+
}
419+
420+
[Theory, BitAutoData]
421+
public async Task DeleteOrganizationReportAsync_WithNoFile_DeletesDbOnly(
422+
SutProvider<OrganizationReportsController> sutProvider,
423+
Guid orgId,
424+
OrganizationReport report)
425+
{
426+
// Arrange
427+
report.OrganizationId = orgId;
428+
report.ReportFile = null;
429+
430+
SetupAuthorization(sutProvider, orgId);
431+
432+
sutProvider.GetDependency<IOrganizationReportRepository>()
433+
.GetByIdAsync(report.Id)
434+
.Returns(report);
435+
436+
// Act
437+
await sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id);
438+
439+
// Assert
440+
await sutProvider.GetDependency<IOrganizationReportRepository>()
441+
.Received(1)
442+
.DeleteAsync(report);
443+
444+
await sutProvider.GetDependency<IOrganizationReportStorageService>()
445+
.DidNotReceive()
446+
.DeleteReportFilesAsync(Arg.Any<OrganizationReport>(), Arg.Any<string>());
447+
}
448+
449+
[Theory, BitAutoData]
450+
public async Task DeleteOrganizationReportAsync_ReportNotFound_ThrowsNotFoundException(
451+
SutProvider<OrganizationReportsController> sutProvider,
452+
Guid orgId,
453+
Guid reportId)
454+
{
455+
// Arrange
456+
SetupAuthorization(sutProvider, orgId);
457+
458+
sutProvider.GetDependency<IOrganizationReportRepository>()
459+
.GetByIdAsync(reportId)
460+
.Returns((OrganizationReport)null);
461+
462+
// Act & Assert
463+
await Assert.ThrowsAsync<NotFoundException>(() =>
464+
sutProvider.Sut.DeleteOrganizationReportAsync(orgId, reportId));
465+
466+
await sutProvider.GetDependency<IOrganizationReportRepository>()
467+
.DidNotReceive()
468+
.DeleteAsync(Arg.Any<OrganizationReport>());
469+
}
470+
471+
[Theory, BitAutoData]
472+
public async Task DeleteOrganizationReportAsync_OrgMismatch_ThrowsBadRequestException(
473+
SutProvider<OrganizationReportsController> sutProvider,
474+
Guid orgId,
475+
OrganizationReport report)
476+
{
477+
// Arrange
478+
report.OrganizationId = Guid.NewGuid();
479+
480+
SetupAuthorization(sutProvider, orgId);
481+
482+
sutProvider.GetDependency<IOrganizationReportRepository>()
483+
.GetByIdAsync(report.Id)
484+
.Returns(report);
485+
486+
// Act & Assert
487+
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
488+
sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id));
489+
490+
Assert.Equal("Invalid report ID", exception.Message);
491+
492+
await sutProvider.GetDependency<IOrganizationReportRepository>()
493+
.DidNotReceive()
494+
.DeleteAsync(Arg.Any<OrganizationReport>());
495+
}
496+
497+
[Theory, BitAutoData]
498+
public async Task DeleteOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException(
499+
SutProvider<OrganizationReportsController> sutProvider,
500+
Guid orgId,
501+
Guid reportId)
502+
{
503+
// Arrange
504+
sutProvider.GetDependency<ICurrentContext>()
505+
.AccessReports(orgId)
506+
.Returns(false);
507+
508+
// Act & Assert
509+
await Assert.ThrowsAsync<NotFoundException>(() =>
510+
sutProvider.Sut.DeleteOrganizationReportAsync(orgId, reportId));
511+
512+
await sutProvider.GetDependency<IOrganizationReportRepository>()
513+
.DidNotReceive()
514+
.DeleteAsync(Arg.Any<OrganizationReport>());
515+
}
516+
517+
// RenewFileUploadUrlAsync
518+
519+
[Theory, BitAutoData]
520+
public async Task RenewFileUploadUrlAsync_WithUnvalidatedFile_ReturnsRenewedUrl(
521+
SutProvider<OrganizationReportsController> sutProvider,
522+
Guid orgId,
523+
OrganizationReport report,
524+
string uploadUrl)
525+
{
526+
// Arrange
527+
var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false };
528+
report.OrganizationId = orgId;
529+
report.SetReportFile(reportFile);
530+
531+
SetupV2Authorization(sutProvider, orgId);
532+
533+
sutProvider.GetDependency<IOrganizationReportRepository>()
534+
.GetByIdAsync(report.Id)
535+
.Returns(report);
536+
537+
sutProvider.GetDependency<IOrganizationReportStorageService>()
538+
.GetReportFileUploadUrlAsync(report, Arg.Any<ReportFile>())
539+
.Returns(uploadUrl);
540+
541+
sutProvider.GetDependency<IOrganizationReportStorageService>()
542+
.FileUploadType
543+
.Returns(FileUploadType.Azure);
544+
545+
// Act
546+
var result = await sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id");
547+
548+
// Assert
549+
Assert.Equal(uploadUrl, result.ReportFileUploadUrl);
550+
Assert.Equal(FileUploadType.Azure, result.FileUploadType);
551+
Assert.NotNull(result.ReportResponse);
552+
}
553+
554+
[Theory, BitAutoData]
555+
public async Task RenewFileUploadUrlAsync_ReportNotFound_ThrowsNotFoundException(
556+
SutProvider<OrganizationReportsController> sutProvider,
557+
Guid orgId,
558+
Guid reportId)
559+
{
560+
// Arrange
561+
SetupV2Authorization(sutProvider, orgId);
562+
563+
sutProvider.GetDependency<IOrganizationReportRepository>()
564+
.GetByIdAsync(reportId)
565+
.Returns((OrganizationReport)null);
566+
567+
// Act & Assert
568+
await Assert.ThrowsAsync<NotFoundException>(() =>
569+
sutProvider.Sut.RenewFileUploadUrlAsync(orgId, reportId, "file-id"));
570+
}
571+
572+
[Theory, BitAutoData]
573+
public async Task RenewFileUploadUrlAsync_OrgMismatch_ThrowsBadRequestException(
574+
SutProvider<OrganizationReportsController> sutProvider,
575+
Guid orgId,
576+
OrganizationReport report)
577+
{
578+
// Arrange
579+
report.OrganizationId = Guid.NewGuid();
580+
581+
SetupV2Authorization(sutProvider, orgId);
582+
583+
sutProvider.GetDependency<IOrganizationReportRepository>()
584+
.GetByIdAsync(report.Id)
585+
.Returns(report);
586+
587+
// Act & Assert
588+
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
589+
sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id"));
590+
591+
Assert.Equal("Invalid report ID", exception.Message);
592+
}
593+
594+
[Theory, BitAutoData]
595+
public async Task RenewFileUploadUrlAsync_FileAlreadyValidated_ThrowsNotFoundException(
596+
SutProvider<OrganizationReportsController> sutProvider,
597+
Guid orgId,
598+
OrganizationReport report)
599+
{
600+
// Arrange
601+
var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true };
602+
report.OrganizationId = orgId;
603+
report.SetReportFile(reportFile);
604+
605+
SetupV2Authorization(sutProvider, orgId);
606+
607+
sutProvider.GetDependency<IOrganizationReportRepository>()
608+
.GetByIdAsync(report.Id)
609+
.Returns(report);
610+
611+
// Act & Assert
612+
await Assert.ThrowsAsync<NotFoundException>(() =>
613+
sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id"));
614+
}
615+
616+
[Theory, BitAutoData]
617+
public async Task RenewFileUploadUrlAsync_NoFileData_ThrowsNotFoundException(
618+
SutProvider<OrganizationReportsController> sutProvider,
619+
Guid orgId,
620+
OrganizationReport report)
621+
{
622+
// Arrange
623+
report.OrganizationId = orgId;
624+
report.ReportFile = null;
625+
626+
SetupV2Authorization(sutProvider, orgId);
627+
628+
sutProvider.GetDependency<IOrganizationReportRepository>()
629+
.GetByIdAsync(report.Id)
630+
.Returns(report);
631+
632+
// Act & Assert
633+
await Assert.ThrowsAsync<NotFoundException>(() =>
634+
sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id"));
635+
}
636+
637+
[Theory, BitAutoData]
638+
public async Task RenewFileUploadUrlAsync_MismatchedFileId_ThrowsNotFoundException(
639+
SutProvider<OrganizationReportsController> sutProvider,
640+
Guid orgId,
641+
OrganizationReport report)
642+
{
643+
// Arrange
644+
var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false };
645+
report.OrganizationId = orgId;
646+
report.SetReportFile(reportFile);
647+
648+
SetupV2Authorization(sutProvider, orgId);
649+
650+
sutProvider.GetDependency<IOrganizationReportRepository>()
651+
.GetByIdAsync(report.Id)
652+
.Returns(report);
653+
654+
// Act & Assert
655+
await Assert.ThrowsAsync<NotFoundException>(() =>
656+
sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "wrong-file-id"));
657+
}
658+
387659
// UpdateOrganizationReportAsync - V1 (flag off)
388660

389661
[Theory, BitAutoData]

0 commit comments

Comments
 (0)