feat: protect feedback screenshots
This commit is contained in:
@@ -31,6 +31,7 @@ public class AppDbContext(
|
||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
||||
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
@@ -6,4 +6,5 @@ internal static class ContainerNames
|
||||
public const string Clients = "clients";
|
||||
public const string Workspaces = "workspaces";
|
||||
public const string Creators = "creators";
|
||||
public const string Feedback = "feedback";
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public static class SubDirectoryNames
|
||||
public const string Profile = "profile";
|
||||
public const string Contents = "contents";
|
||||
public const string Albums = "albums";
|
||||
public const string FeedbackScreenshots = "screenshots";
|
||||
}
|
||||
|
||||
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackScreenshots : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackScreenshots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
|
||||
column: x => x.FeedbackReportId,
|
||||
principalTable: "FeedbackReports",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackScreenshots_FeedbackReportId",
|
||||
table: "FeedbackScreenshots",
|
||||
column: "FeedbackReportId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackScreenshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,6 +672,51 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("FeedbackReports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BlobContainerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("FeedbackReportId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FeedbackReportId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("FeedbackScreenshots", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1081,6 +1126,17 @@ namespace Socialize.Api.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||
.WithOne("Screenshot")
|
||||
.HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FeedbackReport");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||
@@ -1094,6 +1150,8 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
|
||||
{
|
||||
b.Navigation("Screenshot");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -19,6 +19,14 @@ public record FeedbackMetadataDto(
|
||||
int? ViewportHeight,
|
||||
string? AppVersion);
|
||||
|
||||
public record FeedbackScreenshotDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string DownloadPath,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record FeedbackReportDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
@@ -29,6 +37,7 @@ public record FeedbackReportDto(
|
||||
string ReporterEmail,
|
||||
FeedbackMetadataDto Metadata,
|
||||
FeedbackContextDto Context,
|
||||
FeedbackScreenshotDto? Screenshot,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastActivityAt,
|
||||
@@ -62,6 +71,15 @@ public static class FeedbackDtoMapper
|
||||
report.ProjectName,
|
||||
report.ContentItemId,
|
||||
report.ContentItemTitle),
|
||||
report.Screenshot is null
|
||||
? null
|
||||
: new FeedbackScreenshotDto(
|
||||
report.Screenshot.Id,
|
||||
report.Screenshot.FileName,
|
||||
report.Screenshot.ContentType,
|
||||
report.Screenshot.SizeBytes,
|
||||
$"/api/feedback/{report.Id}/screenshot",
|
||||
report.Screenshot.CreatedAt),
|
||||
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
|
||||
report.CreatedAt,
|
||||
report.LastActivityAt,
|
||||
|
||||
@@ -45,6 +45,22 @@ public static class FeedbackModelConfiguration
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FeedbackScreenshot>(screenshot =>
|
||||
{
|
||||
screenshot.ToTable("FeedbackScreenshots");
|
||||
screenshot.HasKey(x => x.Id);
|
||||
screenshot.Property(x => x.FileName).HasMaxLength(256).IsRequired();
|
||||
screenshot.Property(x => x.ContentType).HasMaxLength(128).IsRequired();
|
||||
screenshot.Property(x => x.BlobContainerName).HasMaxLength(128).IsRequired();
|
||||
screenshot.Property(x => x.BlobName).HasMaxLength(512).IsRequired();
|
||||
screenshot.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
screenshot.HasIndex(x => x.FeedbackReportId).IsUnique();
|
||||
screenshot.HasOne(x => x.FeedbackReport)
|
||||
.WithOne(x => x.Screenshot)
|
||||
.HasForeignKey<FeedbackScreenshot>(x => x.FeedbackReportId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ public class FeedbackReport
|
||||
public Guid? CancelledByUserId { get; set; }
|
||||
public string? CancellationReason { get; set; }
|
||||
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
||||
public FeedbackScreenshot? Screenshot { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackScreenshot
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FeedbackReportId { get; set; }
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public string BlobContainerName { get; set; } = string.Empty;
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public FeedbackReport? FeedbackReport { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record AttachMyFeedbackScreenshotRequest(IFormFile File);
|
||||
|
||||
public class AttachMyFeedbackScreenshotRequestValidator
|
||||
: Validator<AttachMyFeedbackScreenshotRequest>
|
||||
{
|
||||
public AttachMyFeedbackScreenshotRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File).NotNull().NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class AttachMyFeedbackScreenshotHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<AttachMyFeedbackScreenshotRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/my-feedback/{id}/screenshot");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(AttachMyFeedbackScreenshotRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||
ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.Screenshot is not null)
|
||||
{
|
||||
AddError("A screenshot is already attached to this feedback report.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackScreenshotRules.IsAllowedSize(request.File.Length))
|
||||
{
|
||||
AddError(
|
||||
request => request.File,
|
||||
$"The screenshot must be greater than 0 bytes and no larger than {FeedbackScreenshotRules.MaxScreenshotBytes} bytes.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackScreenshotRules.IsAllowedContentType(request.File.ContentType))
|
||||
{
|
||||
AddError(request => request.File, "The screenshot must be a PNG or JPEG image.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid screenshotId = Guid.NewGuid();
|
||||
string extension = FeedbackScreenshotRules.GetFileExtension(request.File.ContentType);
|
||||
string blobName = $"{SubDirectoryNames.FeedbackScreenshots}/{report.Id}/{screenshotId}{extension}";
|
||||
|
||||
try
|
||||
{
|
||||
await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Feedback,
|
||||
blobName,
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
AddError(request => request.File, "The screenshot file is invalid or unsupported.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
report.Screenshot = new FeedbackScreenshot
|
||||
{
|
||||
Id = screenshotId,
|
||||
FeedbackReportId = report.Id,
|
||||
FileName = NormalizeFileName(request.File.FileName, extension),
|
||||
ContentType = request.File.ContentType,
|
||||
SizeBytes = request.File.Length,
|
||||
BlobContainerName = ContainerNames.Feedback,
|
||||
BlobName = blobName,
|
||||
CreatedAt = now,
|
||||
};
|
||||
report.LastActivityAt = now;
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
|
||||
private static string NormalizeFileName(string? fileName, string extension)
|
||||
{
|
||||
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return $"feedback-screenshot{extension}";
|
||||
}
|
||||
|
||||
return normalized.Length > 256 ? normalized[..256] : normalized;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext)
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||
ct);
|
||||
|
||||
@@ -21,6 +21,7 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.Where(candidate => candidate.Id == id)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetFeedbackScreenshotHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage)
|
||||
: EndpointWithoutRequest<Stream>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/{id}/screenshot");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null || report.Screenshot is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackAccessRules.CanAccessScreenshot(report, userId, User.IsInRole(KnownRoles.Developer)))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
MemoryStream stream;
|
||||
try
|
||||
{
|
||||
stream = await blobStorage.DownloadFileAsync(
|
||||
report.Screenshot.BlobContainerName,
|
||||
report.Screenshot.BlobName,
|
||||
ct);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendStreamAsync(
|
||||
stream,
|
||||
fileName: report.Screenshot.FileName,
|
||||
fileLengthBytes: report.Screenshot.SizeBytes,
|
||||
contentType: report.Screenshot.ContentType,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
@@ -20,6 +20,7 @@ public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
{
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.Include(report => report.Screenshot)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -20,6 +20,7 @@ public class ListMyFeedbackHandler(AppDbContext dbContext)
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.Include(report => report.Screenshot)
|
||||
.Where(report => report.ReporterUserId == reporterUserId)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
|
||||
@@ -39,6 +39,7 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
|
||||
@@ -13,4 +13,9 @@ public static class FeedbackAccessRules
|
||||
{
|
||||
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
||||
}
|
||||
|
||||
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
|
||||
{
|
||||
return isDeveloper || CanReporterAccess(report, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackScreenshotRules
|
||||
{
|
||||
public const long MaxScreenshotBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes =
|
||||
[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
];
|
||||
|
||||
public static bool IsAllowedContentType(string? contentType)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(contentType) &&
|
||||
AllowedContentTypes.Contains(contentType.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsAllowedSize(long sizeBytes)
|
||||
{
|
||||
return sizeBytes is > 0 and <= MaxScreenshotBytes;
|
||||
}
|
||||
|
||||
public static string GetFileExtension(string contentType)
|
||||
{
|
||||
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)
|
||||
? ".png"
|
||||
: ".jpg";
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,79 @@ public class FeedbackRulesTests
|
||||
Assert.False(otherUserAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAccessScreenshot_allows_report_owner()
|
||||
{
|
||||
Guid reporterUserId = Guid.NewGuid();
|
||||
FeedbackReport report = new() { ReporterUserId = reporterUserId };
|
||||
|
||||
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, reporterUserId, isDeveloper: false);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAccessScreenshot_allows_developer()
|
||||
{
|
||||
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
|
||||
|
||||
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: true);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAccessScreenshot_rejects_unrelated_non_developer()
|
||||
{
|
||||
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
|
||||
|
||||
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: false);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("image/png")]
|
||||
[InlineData("image/jpeg")]
|
||||
[InlineData("image/jpg")]
|
||||
public void Screenshot_content_type_allows_png_and_jpeg(string contentType)
|
||||
{
|
||||
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("text/html")]
|
||||
[InlineData("application/pdf")]
|
||||
[InlineData("")]
|
||||
public void Screenshot_content_type_rejects_non_images(string contentType)
|
||||
{
|
||||
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes)]
|
||||
public void Screenshot_size_allows_non_empty_files_up_to_limit(long sizeBytes)
|
||||
{
|
||||
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes + 1)]
|
||||
public void Screenshot_size_rejects_empty_and_oversized_files(long sizeBytes)
|
||||
{
|
||||
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTags_trims_deduplicates_and_orders()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user