feat(10-01): create BrandingService with magic byte validation and auto-compression
- Add IBrandingService interface with ImportLogoAsync, Save/Clear/GetMspLogoAsync - Add BrandingService: PNG/JPEG magic byte detection, rejects unsupported formats with descriptive error, auto-compresses files over 512 KB using WPF PresentationCore imaging - Add BrandingServiceTests: 9 tests covering validation, rejection, compression, CRUD - Deviation: used WPF BitmapEncoder/TransformedBitmap instead of System.Drawing.Bitmap (System.Drawing.Common not available without new NuGet package; WPF PresentationCore is in the existing stack per architectural decisions)
This commit is contained in:
223
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
Normal file
223
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.IO;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class BrandingServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempRepoFile;
|
||||||
|
private readonly List<string> _tempFiles = new();
|
||||||
|
|
||||||
|
public BrandingServiceTests()
|
||||||
|
{
|
||||||
|
_tempRepoFile = Path.GetTempFileName();
|
||||||
|
File.Delete(_tempRepoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tempRepoFile)) File.Delete(_tempRepoFile);
|
||||||
|
if (File.Exists(_tempRepoFile + ".tmp")) File.Delete(_tempRepoFile + ".tmp");
|
||||||
|
foreach (var f in _tempFiles)
|
||||||
|
{
|
||||||
|
if (File.Exists(f)) File.Delete(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrandingRepository CreateRepository() => new(_tempRepoFile);
|
||||||
|
private BrandingService CreateService() => new(CreateRepository());
|
||||||
|
|
||||||
|
private string WriteTempFile(byte[] bytes)
|
||||||
|
{
|
||||||
|
var path = Path.GetTempFileName();
|
||||||
|
File.WriteAllBytes(path, bytes);
|
||||||
|
_tempFiles.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal valid 1x1 PNG bytes
|
||||||
|
private static byte[] MinimalPngBytes()
|
||||||
|
{
|
||||||
|
// Full 1x1 transparent PNG (67 bytes)
|
||||||
|
return new byte[]
|
||||||
|
{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||||
|
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||||
|
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||||
|
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||||
|
0x08, 0x02, // bit depth = 8, color type = RGB
|
||||||
|
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||||
|
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x0C, // IDAT length
|
||||||
|
0x49, 0x44, 0x41, 0x54, // IDAT
|
||||||
|
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data
|
||||||
|
0xE2, 0x21, 0xBC, 0x33, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x00, // IEND length
|
||||||
|
0x49, 0x45, 0x4E, 0x44, // IEND
|
||||||
|
0xAE, 0x42, 0x60, 0x82 // CRC
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal valid JPEG bytes (SOI + APP0 JFIF header + EOI)
|
||||||
|
private static byte[] MinimalJpegBytes()
|
||||||
|
{
|
||||||
|
return new byte[]
|
||||||
|
{
|
||||||
|
0xFF, 0xD8, // SOI
|
||||||
|
0xFF, 0xE0, // APP0 marker
|
||||||
|
0x00, 0x10, // length = 16
|
||||||
|
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
||||||
|
0x01, 0x01, // version 1.1
|
||||||
|
0x00, // aspect ratio units = 0
|
||||||
|
0x00, 0x01, 0x00, 0x01, // X/Y density = 1
|
||||||
|
0x00, 0x00, // thumbnail size
|
||||||
|
0xFF, 0xD9 // EOI
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_ValidPng_ReturnsPngLogoData()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var pngBytes = MinimalPngBytes();
|
||||||
|
var path = WriteTempFile(pngBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal("image/png", result.MimeType);
|
||||||
|
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_ValidJpeg_ReturnsJpegLogoData()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var jpegBytes = MinimalJpegBytes();
|
||||||
|
var path = WriteTempFile(jpegBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal("image/jpeg", result.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_BmpFile_ThrowsInvalidDataExceptionMentioningPngAndJpg()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
// BMP magic bytes: 0x42 0x4D
|
||||||
|
var bmpBytes = new byte[] { 0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||||
|
var path = WriteTempFile(bmpBytes);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
|
||||||
|
Assert.Contains("PNG", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("JPG", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_EmptyFile_ThrowsInvalidDataException()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var path = WriteTempFile(Array.Empty<byte>());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_FileUnder512KB_ReturnOriginalBytesUnmodified()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var pngBytes = MinimalPngBytes();
|
||||||
|
var path = WriteTempFile(pngBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
// Create a large PNG image in memory (400x400 random pixels)
|
||||||
|
var largePngPath = Path.GetTempFileName();
|
||||||
|
_tempFiles.Add(largePngPath);
|
||||||
|
|
||||||
|
using (var bmp = new Bitmap(400, 400))
|
||||||
|
{
|
||||||
|
var rng = new Random(42);
|
||||||
|
for (int y = 0; y < 400; y++)
|
||||||
|
for (int x = 0; x < 400; x++)
|
||||||
|
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
|
||||||
|
bmp.Save(largePngPath, ImageFormat.Png);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSize = new FileInfo(largePngPath).Length;
|
||||||
|
// PNG with random pixels should exceed 512 KB
|
||||||
|
// If not, we'll pad it
|
||||||
|
if (fileSize <= 512 * 1024)
|
||||||
|
{
|
||||||
|
// Generate a bigger image to be sure
|
||||||
|
using var bmp = new Bitmap(800, 800);
|
||||||
|
var rng = new Random(42);
|
||||||
|
for (int y = 0; y < 800; y++)
|
||||||
|
for (int x = 0; x < 800; x++)
|
||||||
|
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
|
||||||
|
bmp.Save(largePngPath, ImageFormat.Png);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize = new FileInfo(largePngPath).Length;
|
||||||
|
Assert.True(fileSize > 512 * 1024, $"Test setup: PNG file must be > 512 KB but was {fileSize} bytes");
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(largePngPath);
|
||||||
|
|
||||||
|
var decodedBytes = Convert.FromBase64String(result.Base64);
|
||||||
|
Assert.True(decodedBytes.Length <= 512 * 1024,
|
||||||
|
$"Compressed result must be <= 512 KB but was {decodedBytes.Length} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveMspLogoAsync_PersistsLogoInRepository()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var service = new BrandingService(repo);
|
||||||
|
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
|
||||||
|
|
||||||
|
await service.SaveMspLogoAsync(logo);
|
||||||
|
|
||||||
|
var loaded = await repo.LoadAsync();
|
||||||
|
Assert.NotNull(loaded.MspLogo);
|
||||||
|
Assert.Equal("abc123==", loaded.MspLogo.Base64);
|
||||||
|
Assert.Equal("image/png", loaded.MspLogo.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearMspLogoAsync_SetsMspLogoToNull()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var service = new BrandingService(repo);
|
||||||
|
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
|
||||||
|
await service.SaveMspLogoAsync(logo);
|
||||||
|
|
||||||
|
await service.ClearMspLogoAsync();
|
||||||
|
|
||||||
|
var loaded = await repo.LoadAsync();
|
||||||
|
Assert.Null(loaded.MspLogo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMspLogoAsync_WhenNoLogoConfigured_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var result = await service.GetMspLogoAsync();
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
SharepointToolbox/Services/BrandingService.cs
Normal file
152
SharepointToolbox/Services/BrandingService.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class BrandingService : IBrandingService
|
||||||
|
{
|
||||||
|
private const int MaxSizeBytes = 512 * 1024; // 512 KB
|
||||||
|
|
||||||
|
// PNG signature: first 4 bytes
|
||||||
|
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
// JPEG signature: first 3 bytes
|
||||||
|
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private readonly BrandingRepository _repository;
|
||||||
|
|
||||||
|
public BrandingService(BrandingRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a file, validates that it is PNG or JPEG via magic bytes, auto-compresses if over 512 KB,
|
||||||
|
/// and returns a LogoData record. Does NOT persist anything — the caller decides where to store it.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
|
|
||||||
|
var mimeType = DetectMimeType(bytes);
|
||||||
|
|
||||||
|
if (bytes.Length > MaxSizeBytes)
|
||||||
|
{
|
||||||
|
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LogoData
|
||||||
|
{
|
||||||
|
Base64 = Convert.ToBase64String(bytes),
|
||||||
|
MimeType = mimeType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveMspLogoAsync(LogoData logo)
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
settings.MspLogo = logo;
|
||||||
|
await _repository.SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearMspLogoAsync()
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
settings.MspLogo = null;
|
||||||
|
await _repository.SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LogoData?> GetMspLogoAsync()
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
return settings.MspLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static string DetectMimeType(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
throw new InvalidDataException("File is empty. Only PNG and JPG files are accepted.");
|
||||||
|
|
||||||
|
if (bytes.Length >= 4 && bytes[0] == PngMagic[0] && bytes[1] == PngMagic[1]
|
||||||
|
&& bytes[2] == PngMagic[2] && bytes[3] == PngMagic[3])
|
||||||
|
return "image/png";
|
||||||
|
|
||||||
|
if (bytes.Length >= 3 && bytes[0] == JpegMagic[0] && bytes[1] == JpegMagic[1]
|
||||||
|
&& bytes[2] == JpegMagic[2])
|
||||||
|
return "image/jpeg";
|
||||||
|
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"File format is not PNG or JPG. Only PNG and JPG are accepted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compresses image bytes using WPF imaging (PresentationCore) to fit within <paramref name="maxBytes"/>.
|
||||||
|
/// Resizes proportionally to max 300x300 at quality 75 first pass; if still too large, 200x200 at quality 50.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CompressToLimit(byte[] bytes, string mimeType, int maxBytes)
|
||||||
|
{
|
||||||
|
// First pass: resize to 300x300 max, quality 75
|
||||||
|
var compressed = ResizeAndEncode(bytes, mimeType, 300, 75);
|
||||||
|
if (compressed.Length <= maxBytes)
|
||||||
|
return compressed;
|
||||||
|
|
||||||
|
// Second pass: resize to 200x200 max, quality 50
|
||||||
|
compressed = ResizeAndEncode(bytes, mimeType, 200, 50);
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ResizeAndEncode(byte[] originalBytes, string mimeType, int maxDimension, int quality)
|
||||||
|
{
|
||||||
|
// Decode source image using WPF BitmapDecoder
|
||||||
|
using var inputStream = new MemoryStream(originalBytes);
|
||||||
|
var decoder = BitmapDecoder.Create(
|
||||||
|
inputStream,
|
||||||
|
BitmapCreateOptions.PreservePixelFormat,
|
||||||
|
BitmapCacheOption.OnLoad);
|
||||||
|
|
||||||
|
var frame = decoder.Frames[0];
|
||||||
|
|
||||||
|
// Calculate target dimensions (proportional scaling)
|
||||||
|
double srcWidth = frame.PixelWidth;
|
||||||
|
double srcHeight = frame.PixelHeight;
|
||||||
|
double scale = Math.Min((double)maxDimension / srcWidth, (double)maxDimension / srcHeight);
|
||||||
|
|
||||||
|
// Only scale down, never up
|
||||||
|
if (scale >= 1.0)
|
||||||
|
scale = 1.0;
|
||||||
|
|
||||||
|
int targetWidth = Math.Max(1, (int)(srcWidth * scale));
|
||||||
|
int targetHeight = Math.Max(1, (int)(srcHeight * scale));
|
||||||
|
|
||||||
|
// Scale the bitmap using TransformedBitmap
|
||||||
|
var scaledBitmap = new TransformedBitmap(
|
||||||
|
frame,
|
||||||
|
new ScaleTransform(scale, scale));
|
||||||
|
|
||||||
|
// Encode to target format
|
||||||
|
using var outputStream = new MemoryStream();
|
||||||
|
BitmapEncoder encoder = mimeType == "image/png"
|
||||||
|
? new PngBitmapEncoder()
|
||||||
|
: CreateJpegEncoder(quality);
|
||||||
|
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(scaledBitmap));
|
||||||
|
encoder.Save(outputStream);
|
||||||
|
|
||||||
|
return outputStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BitmapEncoder CreateJpegEncoder(int quality)
|
||||||
|
{
|
||||||
|
return new JpegBitmapEncoder
|
||||||
|
{
|
||||||
|
QualityLevel = quality
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SharepointToolbox/Services/IBrandingService.cs
Normal file
11
SharepointToolbox/Services/IBrandingService.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public interface IBrandingService
|
||||||
|
{
|
||||||
|
Task<LogoData> ImportLogoAsync(string filePath);
|
||||||
|
Task SaveMspLogoAsync(LogoData logo);
|
||||||
|
Task ClearMspLogoAsync();
|
||||||
|
Task<LogoData?> GetMspLogoAsync();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user