diff --git a/Bundler.Client/package-lock.json b/Bundler.Client/package-lock.json index f6797fa..a229f89 100644 --- a/Bundler.Client/package-lock.json +++ b/Bundler.Client/package-lock.json @@ -1232,12 +1232,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1851,9 +1851,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/Bundler.Client/src/services/Bundle.ts b/Bundler.Client/src/services/Bundle.ts index ed2a3df..147331f 100644 --- a/Bundler.Client/src/services/Bundle.ts +++ b/Bundler.Client/src/services/Bundle.ts @@ -1,8 +1,22 @@ import JSZip, { JSZipObject } from "jszip"; -import Config, { ConfigMetadata, parseConfig } from "./Config"; +import Config, { Metadata, loadConfig } from "./Config"; import { BundleIcons, BundleType } from "./types"; +import mime from "mime"; + +const IconMimetypes: Record> = { + "ctr": ["image/png"], + "hac": ["image/jpeg", "image/jpg"], + "cafe": ["image/png"] +}; + +const ExpectedDimensions: Record = { + "ctr": 48, + "hac": 256, + "cafe": 128 +}; + /* ** Bundler class ** Represents a bundle of files and configuration. @@ -14,11 +28,29 @@ export default class Bundle { private config: Config | undefined; readonly ConfigName = "lovebrew.toml"; + private configContent: string | undefined; constructor(zip: File) { this.file = zip; } + private async validateIconDimensions(target: string, file: JSZipObject): Promise { + try { + const data = await file.async("blob"); + + const image = await createImageBitmap(data); + const dimensions = [image.width, image.height]; + + if (dimensions.some((dim) => dim != ExpectedDimensions[target])) { + return false; + } + + return true; + } catch (error) { + throw new Error(`Invalid icon for ${target}.`); + } + } + /** * Validates the bundle * @returns {Promise} - Whether the file is a valid bundle. @@ -28,11 +60,11 @@ export default class Bundle { const data = await this.zip.file(this.ConfigName)?.async("string"); - if (data === undefined) { - throw Error("Missing configuration file."); - } + if (data === undefined) throw Error("Missing configuration file."); + if (data.trim() === "") throw Error("Invalid configuration file."); - this.config = parseConfig(data); + this.configContent = data; + this.config = loadConfig(data); const source = this.config.build.source; if (this.zip.file(new RegExp(`^${source}/.+`)).length === 0) { @@ -58,11 +90,19 @@ export default class Bundle { const result: BundleIcons = {}; const icons = this.config.getIcons(); + if (icons === undefined) return result; + for (const [key, value] of Object.entries(icons)) { const file = this.zip.file(value); if (file === null) continue; + const mimetype = mime.getType(file.name) + + if (mimetype === null) throw new Error(`Icon for ${key} has no mimetype.`); + if (!IconMimetypes[key].includes(mimetype)) throw new Error(`Invalid ${key} icon mimetype.`); + if (!await this.validateIconDimensions(key, file)) throw new Error(`Invalid ${key} icon dimensions.`); + const blob = await file.async("blob"); result[key as keyof BundleIcons] = blob; } @@ -133,10 +173,10 @@ export default class Bundle { iconData += await this.blobToBase64(icons[key] as Blob); } - return this.config.source + iconData; + return this.configContent + iconData; } - public getMetadata(): ConfigMetadata { + public getMetadata(): Metadata { if (this.config === undefined) { throw Error("Configuration file not loaded."); } diff --git a/Bundler.Client/src/services/Bundler.ts b/Bundler.Client/src/services/Bundler.ts index ba5add4..778c764 100644 --- a/Bundler.Client/src/services/Bundler.ts +++ b/Bundler.Client/src/services/Bundler.ts @@ -1,5 +1,5 @@ import Bundle from "./Bundle"; -import { ConfigMetadata } from "./Config"; +import { Metadata } from "./Config"; import { BundleIcons, BundleCache, @@ -8,6 +8,7 @@ import { getExtension, BundleAssetCache, MediaFile, + getIconExtension, } from "./types"; import MediaConverter from "./MediaConverter"; @@ -283,13 +284,18 @@ export default class Bundler { private async sendCompile( targets: Array, icons: BundleIcons, - metadata: ConfigMetadata + metadata: Metadata ): Promise { // append the icons as FormData const body = new FormData(); - for (const [target, blob] of Object.entries(icons)) { + let key: BundleType; + + for (key in icons) { + const blob = icons[key]; if (blob === undefined) continue; - body.append(`icon-${target}`, blob as Blob); + + const basename = `icon-${key}`; + body.append(basename, blob, `${basename}.${getIconExtension(key)}`); } const url = `${import.meta.env.DEV ? process.env.BASE_URL : ""}`; diff --git a/Bundler.Client/src/services/Config.ts b/Bundler.Client/src/services/Config.ts index c350356..0f9192c 100644 --- a/Bundler.Client/src/services/Config.ts +++ b/Bundler.Client/src/services/Config.ts @@ -1,32 +1,55 @@ import toml from "toml"; import { BundleType } from "./types"; -export type ConfigIcons = { - ctr?: string; - cafe?: string; - hac?: string; +// Validation +const validateString = (value: any) => { + return (typeof value === "string" || value instanceof String) && value.trim() !== ""; +} + +const validateBoolean = (value: any) => { + return typeof value === "boolean" || value instanceof Boolean; +} + +const validateBundleTypeList = (value: any) => { + return (Array.isArray(value) && value.every((x) => x === "ctr" || x === "hac" || x === "cafe")); +} + +type Icons = { + [key in BundleType]?: string; }; -export type ConfigMetadata = { +export type Metadata = { title: string; author: string; description: string; version: string; - icons: ConfigIcons; + icons?: Icons; +}; + +const MetadataFields: Record = { + "title": validateString, + "author": validateString, + "description": validateString, + "version": validateString }; -export type ConfigBuild = { - targets: Array; +type Build = { + targets: Array; source: string; packaged?: boolean; }; +const BuildFields: Record = { + "targets": validateBundleTypeList, + "source": validateString, + "packaged": validateBoolean +} + export default class Config { - metadata!: ConfigMetadata; - build!: ConfigBuild; - public source: string = ""; + metadata!: Metadata; + build!: Build; - public getIcons(): ConfigIcons { + public getIcons(): Icons | undefined { return this.metadata.icons; } @@ -39,12 +62,35 @@ export default class Config { } } -export function parseConfig(content: string): Config { - const configData = toml.parse(content); +export function loadConfig(content: string): Config { + let parsed: undefined; - const config = new Config(); - config.source = content; + try { + parsed = toml.parse(content); + } catch (exception) { + throw new Error("Invalid config content. Unable to parse TOML."); + } - Object.assign(config, configData); - return config; -} + if (parsed === undefined) throw new Error("Invalid config content. Unable to parse TOML."); + + if (parsed["metadata"] == null || parsed["build"] == null) { + const missing = parsed["metadata"] == null ? "metadata" : "build"; + throw new Error(`Invalid config content. Missing section: '${missing}'.`); + } + + for (const field in MetadataFields) { + if (parsed["metadata"][field] == null) throw new Error(`Missing config 'metadata' field '${field}'.`); + + const value = parsed["metadata"][field]; + if (!MetadataFields[field](value)) throw new Error(`Config 'metadata' field '${field}' type is invalid.`); + } + + for (const field in BuildFields) { + if (parsed["build"][field] == null) throw new Error(`Missing config 'build' field '${field}'.`); + + const value = parsed["build"][field]; + if (!BuildFields[field](value)) throw new Error(`Config 'build' field '${field}' type is invalid.`); + } + + return Object.assign(new Config(), parsed); +} \ No newline at end of file diff --git a/Bundler.Client/src/services/MediaConverter.ts b/Bundler.Client/src/services/MediaConverter.ts index cdd8f0d..6eda57a 100644 --- a/Bundler.Client/src/services/MediaConverter.ts +++ b/Bundler.Client/src/services/MediaConverter.ts @@ -196,7 +196,7 @@ export default class MediaConverter { for (const file of fileMap) { if (!(await MediaConverter.validateFile(file))) { - throw Error(`Invalid file: ${file.filepath}`); + throw Error(`Invalid file: ${file.filepath}.`); } body.append(file.filepath, file.data); } diff --git a/Bundler.Client/src/services/types.ts b/Bundler.Client/src/services/types.ts index 08a66c6..22a4bed 100644 --- a/Bundler.Client/src/services/types.ts +++ b/Bundler.Client/src/services/types.ts @@ -6,7 +6,15 @@ const extMap: Record = { cafe: "wuhb", hac: "nro", }; + +const iconMap: Record = { + ctr: "png", + cafe: "png", + hac: "jpg", +} + export const getExtension = (type: BundleType): BundleExtension => extMap[type]; +export const getIconExtension = (type: BundleType): string => iconMap[type]; export type BundleIcons = { [key in BundleType]?: Blob; diff --git a/Bundler.QA/Backend/BundlerAPITest.cs b/Bundler.QA/Backend/BundlerAPITest.cs index 508cc94..c264dfc 100644 --- a/Bundler.QA/Backend/BundlerAPITest.cs +++ b/Bundler.QA/Backend/BundlerAPITest.cs @@ -8,6 +8,8 @@ namespace Bundler.QA.Backend { + using BundlerResponse = Dictionary; + [NonParallelizable] internal class BundlerAPITest : BaseTest { @@ -31,24 +33,6 @@ private static string ChangeExtension(string filename) }; } - private static void ValidateZipArchive(string console, string base64, string[] filenames) - { - Console.WriteLine($"Validating {console} archive..."); - - if (console == "ctr") // change extensions based on file extension (png, jpg, ttf) - filenames = Array.ConvertAll(filenames, f => ChangeExtension(f)); - - var bytes = Convert.FromBase64String(base64); - using var stream = new MemoryStream(bytes); - - using var zip = new ZipArchive(stream); - - Assert.That(zip.Entries, Has.Count.EqualTo(filenames.Length)); - - foreach (var filename in filenames) - Assert.That(zip.Entries, Has.One.Matches(e => e.Name == filename)); - } - #region Textures [TestCase("dio.jpg")] @@ -237,6 +221,147 @@ public async Task TestUploadBundleNoCompleteQuery(string title, string descripti Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } + [TestCase("ctr", "lenny.png")] + [TestCase("hac", "dio.jpg")] + [TestCase("cafe", "cat_big_both.png")] + public async Task TestUploadBundleWithCustomIconBadDimensions(string target, string filename) + { + BundlerQuery query = new() + { + Title = "SuperGame", + Description = "SuperDescription", + Author = "SuperAuthor", + Version = "0.1.0", + Targets = target + }; + + var content = new MultipartFormDataContent(); + + var fileBytes = new ByteArrayContent(Assets.GetData(filename)); + content.Add(fileBytes, $"icon-{target}", filename); + + var response = await this._client.PostAsync($"compile?{query}", content); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + + var info = await response.Content.ReadAsStringAsync(); + Assert.That(info, Is.EqualTo($"Invalid icon dimensions for {target}.")); + } + + [TestCase("ctr")] + [TestCase("hac")] + [TestCase("cafe")] + [Description("Validate that uploading a bundle with a bad icon mimetype throws an error.")] + public async Task TestUploadBundleWithCustomIconBadMimetype(string target) + { + string filename = "empty"; + + BundlerQuery query = new() + { + Title = "SuperGame", + Description = "SuperDescription", + Author = "SuperAuthor", + Version = "0.1.0", + Targets = target + }; + + var content = new MultipartFormDataContent(); + + var fileBytes = new ByteArrayContent(Assets.GetData(filename)); + content.Add(fileBytes, $"icon-{target}", filename); + + var response = await this._client.PostAsync($"compile?{query}", content); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + + var info = await response.Content.ReadAsStringAsync(); + Assert.That(info, Is.EqualTo($"Icon for {target} has no mimetype.")); + } + + [TestCase("ctr", "corrupt.png")] + [TestCase("hac", "corrupt.jpg")] + [TestCase("cafe", "corrupt.png")] + [Description("Validate that uploading a bundle with a valid icon mimetype but invalid data throws an error.")] + public async Task TestUploadBundleWithCustomIconInvalidImage(string target, string filename) + { + BundlerQuery query = new() + { + Title = "SuperGame", + Description = "SuperDescription", + Author = "SuperAuthor", + Version = "0.1.0", + Targets = target + }; + + var content = new MultipartFormDataContent(); + + var fileBytes = new ByteArrayContent(Assets.GetData(filename)); + content.Add(fileBytes, $"icon-{target}", filename); + + var response = await this._client.PostAsync($"compile?{query}", content); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + + var info = await response.Content.ReadAsStringAsync(); + Assert.That(info, Is.EqualTo($"Invalid icon format for {target}.")); + } + + [TestCase("ctr")] + [TestCase("hac")] + [TestCase("cafe")] + [Description("Validate that uploading a bundle with a bad icon mimetype throws an error.")] + public async Task TestUploadBundleWithCustomIconIncorrectMimetype(string target) + { + string filename = "yeetus.txt"; + + BundlerQuery query = new() + { + Title = "SuperGame", + Description = "SuperDescription", + Author = "SuperAuthor", + Version = "0.1.0", + Targets = target + }; + + var content = new MultipartFormDataContent(); + + var fileBytes = new ByteArrayContent(Assets.GetData(filename)); + content.Add(fileBytes, $"icon-{target}", filename); + + var response = await this._client.PostAsync($"compile?{query}", content); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + + var info = await response.Content.ReadAsStringAsync(); + Assert.That(info, Is.EqualTo($"Invalid icon format for {target}.")); + } + + [TestCase("ctr", "icon-ctr.png")] + [TestCase("hac", "icon-hac.jpg")] + public async Task TestUploadBundleWithCustomIcon(string target, string filename) + { + BundlerQuery query = new() + { + Title = "SuperGame", + Description = "SuperDescription", + Author = "SuperAuthor", + Version = "0.1.0", + Targets = target + }; + + var content = new MultipartFormDataContent(); + + var fileBytes = new ByteArrayContent(Assets.GetData(filename)); + content.Add(fileBytes, $"icon-{target}", filename); + + var response = await this._client.PostAsync($"compile?{query}", content); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var json = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + Assert.That(json, Is.Not.Null); + + var bytes = Convert.FromBase64String(json[target]); + + var expected = target == "ctr" ? Assets.GetData("icon_big.bin") : Assets.GetData(filename); + Assert.That(bytes.Intersect(expected), Is.Not.Empty); + } + #endregion } } diff --git a/Bundler.QA/Bundler.QA.csproj b/Bundler.QA/Bundler.QA.csproj index 40295bf..46233d7 100644 --- a/Bundler.QA/Bundler.QA.csproj +++ b/Bundler.QA/Bundler.QA.csproj @@ -25,6 +25,8 @@ + + @@ -57,13 +59,34 @@ PreserveNewest - + PreserveNewest - + PreserveNewest - + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + PreserveNewest @@ -90,6 +113,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest @@ -129,6 +158,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Bundler.QA/Common/BaseTest.cs b/Bundler.QA/Common/BaseTest.cs index 5415c4d..60dec2b 100644 --- a/Bundler.QA/Common/BaseTest.cs +++ b/Bundler.QA/Common/BaseTest.cs @@ -1,13 +1,82 @@ -using System.IO.Compression; +using System; +using System.IO.Compression; +using System.Text; + +using Tomlyn; namespace Bundler.QA.Common { + public sealed class BundleFile + { + /// Asset Name + public string? FileName { get; set; } + /// Asset Data (Bytes) + public byte[]? Data { get; set; } + /// Zip Entry Path + public string EntryPath { get; set; } + } + public class BaseTest { - public static void CreateBundle(string name, Span<(string name, string path)> files) + protected static string GenerateRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var random = new Random(); + var stringBuilder = new StringBuilder(length); + + for (int i = 0; i < length; i++) stringBuilder.Append(chars[random.Next(chars.Length)]); + + return stringBuilder.ToString(); + } + + protected static byte[] GenerateRandomStringBytes(int length) + { + return Encoding.UTF8.GetBytes(GenerateRandomString(length)); + } + + public static string CreateBundle(string name, Span files) { + ArgumentNullException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + using var bundle = ZipFile.Open(name, ZipArchiveMode.Create); - foreach (var file in files) bundle.CreateEntryFromFile(Assets.GetFilepath(file.name), file.path); + + foreach (var file in files) + { + ArgumentNullException.ThrowIfNull(file.EntryPath, nameof(file.EntryPath)); + + if (file.Data is null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(file.FileName, nameof(file.FileName)); + bundle.CreateEntryFromFile(Assets.GetFilepath(file.FileName), file.EntryPath); + } + else + { + var entry = bundle.CreateEntry(file.EntryPath, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(file.Data, 0, file.Data.Length); + } + } + + return Path.Join(Directory.GetCurrentDirectory(), name); + } + + public static Config LoadConfig(string filepath) + { + try + { + var toml = File.ReadAllText(filepath); + return Toml.ToModel(toml); + } + catch (FileNotFoundException) + { + Console.WriteLine($"File not found: {filepath}"); + } + catch (TomlException e) + { + Console.WriteLine($"Error parsing TOML: {e.Message}"); + } + + throw new Exception("Failed to load configuration file."); } } } diff --git a/Bundler.QA/Common/Config.cs b/Bundler.QA/Common/Config.cs new file mode 100644 index 0000000..bc32e20 --- /dev/null +++ b/Bundler.QA/Common/Config.cs @@ -0,0 +1,196 @@ +using System.Runtime.Serialization; + +using Tomlyn; + +namespace Bundler.QA.Common +{ + public enum ConfigSection + { + Metadata, + Build, + Debug + } + + public enum ConfigSectionField + { + Title, + Author, + Description, + Version, + Icons, + Targets, + Source, + Packaged + } + + public enum ConfigIconType + { + CTR, + HAC, + CAFE + } + + public sealed class Metadata + { + public string? Title { get; set; } + public string? Author { get; set; } + public string? Description { get; set; } + public string? Version { get; set; } + public Dictionary? Icons { get; set; } + + public Metadata() + => this.Icons = []; + + [IgnoreDataMember] + public dynamic? this[ConfigSectionField key] + { + get => key switch + { + ConfigSectionField.Title => this.Title, + ConfigSectionField.Author => this.Author, + ConfigSectionField.Description => this.Description, + ConfigSectionField.Version => this.Version, + ConfigSectionField.Icons => this.Icons, + _ => null + }; + + set + { + switch (key) + { + case ConfigSectionField.Title: + this.Title = value; + break; + case ConfigSectionField.Author: + this.Author = value; + break; + case ConfigSectionField.Description: + this.Description = value; + break; + case ConfigSectionField.Version: + this.Version = value; + break; + } + } + } + + public void SetIcon(ConfigIconType type, string filename) + { + if (this.Icons is null) return; + + switch (type) + { + case ConfigIconType.CTR: + this.Icons["ctr"] = filename; + break; + case ConfigIconType.HAC: + this.Icons["hac"] = filename; + break; + case ConfigIconType.CAFE: + this.Icons["cafe"] = filename; + break; + } + } + } + + public sealed class Build + { + public List? Targets { get; set; } + public string? Source { get; set; } + public bool? Packaged { get; set; } + + public Build() + => this.Targets = []; + + [IgnoreDataMember] + public dynamic? this[ConfigSectionField key] + { + get => key switch + { + ConfigSectionField.Targets => this.Targets, + ConfigSectionField.Source => this.Source, + ConfigSectionField.Packaged => this.Packaged, + _ => null + }; + + set + { + switch (key) + { + case ConfigSectionField.Targets: + this.Targets = value; + break; + case ConfigSectionField.Source: + this.Source = value; + break; + case ConfigSectionField.Packaged: + this.Packaged = value; + break; + } + } + } + } + + public sealed class Debug + { + public string? Version { get; set; } + } + + public class Config + { + public Metadata? Metadata { get; set; } + public Build? Build { get; set; } + public Debug? Debug { get; set; } + + public byte[] SetField(ConfigSectionField field, dynamic value) + { + var content = this.ToString().Trim().Split("\n"); + var fieldString = field.ToString().ToLower(); + + for (int i = 0; i < content.Length; i++) + { + if (content[i].Contains(fieldString)) + { + content[i] = $"{fieldString} = {value}"; + break; + } + } + + var updatedContent = string.Join("\n", content); + return System.Text.Encoding.UTF8.GetBytes(updatedContent); + } + + [IgnoreDataMember] + public byte[] Data => System.Text.Encoding.UTF8.GetBytes(this.ToString()); + + [IgnoreDataMember] + public dynamic? this[ConfigSection section] + { + get => section switch + { + ConfigSection.Metadata => this.Metadata, + ConfigSection.Build => this.Build, + ConfigSection.Debug => this.Debug, + _ => null + }; + + set + { + switch (section) + { + case ConfigSection.Metadata: + this.Metadata = value; + break; + case ConfigSection.Build: + this.Build = value; + break; + case ConfigSection.Debug: + this.Debug = value; + break; + } + } + } + + public override string ToString() => Toml.FromModel(this); + } +} diff --git a/Bundler.QA/Frontend/BundlerPage.cs b/Bundler.QA/Frontend/BundlerPage.cs index 175859e..25993a1 100644 --- a/Bundler.QA/Frontend/BundlerPage.cs +++ b/Bundler.QA/Frontend/BundlerPage.cs @@ -9,6 +9,7 @@ namespace Bundler.QA.Frontend internal class BundlerPage { private readonly WebDriver _webdriver; + private static string CurrentDirectory => Directory.GetCurrentDirectory(); #region Strings @@ -40,10 +41,8 @@ public void Cleanup() public void UploadFile(string name, bool isAsset = true) { - Console.WriteLine($"Uploading {name}.."); - - var file = isAsset ? Assets.GetFilepath(name) : name; - this._webdriver.Find(FileInput)?.SendKeys(file); + var filepath = (isAsset) ? Assets.GetFilepath(name) : Path.Join(CurrentDirectory, name); + this._webdriver.Find(FileInput)?.SendKeys(filepath); } public T GetIndexedDBData(string dbName, string storeName) diff --git a/Bundler.QA/Frontend/BundlerTest.cs b/Bundler.QA/Frontend/BundlerTest.cs index e53f2ae..c778652 100644 --- a/Bundler.QA/Frontend/BundlerTest.cs +++ b/Bundler.QA/Frontend/BundlerTest.cs @@ -1,6 +1,10 @@ -using Bundler.QA.Common; +using System.Reflection; -using System.IO.Compression; +using Bundler.QA.Common; +using static Bundler.QA.Common.Config; + +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; namespace Bundler.QA.Frontend { @@ -46,19 +50,29 @@ internal class BundlerTest : BaseTest private readonly string InvalidTextureFile = "Error: Texture '{0}' is invalid."; private readonly string InvalidFontFile = "Error: Font '{0}' is invalid."; - private readonly List DefaultBundleNames = ["SuperGame.3dsx", "SuperGame.nro", "SuperGame.wuhb"]; - private readonly List DefaultAssetNames = ["ctr-assets.zip", "hac-assets.zip", "cafe-assets.zip"]; + private readonly string MissingConfigSection = "Invalid config content. Missing section: '{0}'"; + private readonly string MissingConfigField = "Missing config '{0}' field '{1}'."; + private readonly string ConfigFieldTypeInvalid = "Config '{0}' field '{1}' type is invalid."; #endregion + private readonly List DefaultBundleNames = ["SuperGame.3dsx", "SuperGame.nro", "SuperGame.wuhb"]; + private readonly List DefaultAssetNames = ["ctr-assets.zip", "hac-assets.zip", "cafe-assets.zip"]; + [OneTimeSetUp] - public void Setup() + public void OneTimeSetup() => this._page = new(); [OneTimeTearDown] - public void Teardown() + public void OneTimeTeardown() => this._page.Cleanup(); + [TearDown] + public void Teardown() + { + if (Path.Exists("bundle.zip")) File.Delete("bundle.zip"); + } + #region Texture Upload [TestCase("cat_big_width.png")] @@ -115,12 +129,12 @@ public void TestUploadValidTextureFile(string filename) string convertedName = Path.GetFileNameWithoutExtension(filename) + ".t3x"; - using var zip = ZipFile.OpenRead(this._page.GetDownloadedFile()); + using var zip = new ZipFile(this._page.GetDownloadedFile()); Assert.Multiple(() => { - Assert.That(zip.Entries, Has.Count.EqualTo(2)); // Texture, log - Assert.That(zip.Entries.FirstOrDefault(x => x.Name == convertedName) is not null); + Assert.That(zip, Has.Count.EqualTo(2)); // Texture, log + Assert.That(zip.GetEntry(convertedName), Is.Not.Null); }); } @@ -146,12 +160,12 @@ public void TestUploadValidFontFile(string filename) string convertedName = Path.GetFileNameWithoutExtension(filename) + ".bcfnt"; - using var zip = ZipFile.OpenRead(this._page.GetDownloadedFile()); + using var zip = new ZipFile(this._page.GetDownloadedFile()); Assert.Multiple(() => { - Assert.That(zip.Entries, Has.Count.EqualTo(2)); // Font, log - Assert.That(zip.Entries.FirstOrDefault(x => x.Name == convertedName) is not null); + Assert.That(zip, Has.Count.EqualTo(2)); // Font, log + Assert.That(zip.GetEntry(convertedName), Is.Not.Null); }); } @@ -174,56 +188,347 @@ public void TestMacOSBundle(string filename) this._page.UploadFile(filename); this._page.AssertSuccessToast("Downloaded."); - using var zip = ZipFile.OpenRead(this._page.GetDownloadedFile()); + using var zip = new ZipFile(this._page.GetDownloadedFile()); Assert.Multiple(() => { - Assert.That(zip.Entries, Has.Count.EqualTo(3)); - Assert.That(zip.Entries.Any(x => DefaultAssetNames.Contains(x.Name))); + Assert.That(zip, Has.Count.EqualTo(3)); + + foreach (var name in DefaultAssetNames) + Assert.That(zip.GetEntry(name), Is.Not.Null); }); } [TestCase] + [Description("Test a basic bundle upload.")] public void TestBasicBundle() { + string directory = string.Empty; + try { - Span<(string, string)> files = + Span files = [ - ("main.lua", "game/main.lua"), - ("lovebrew.toml", "lovebrew.toml") + new() { FileName = "main.lua", EntryPath = "game/main.lua" }, + new() { FileName = "lovebrew.toml", EntryPath = "lovebrew.toml" } ]; - + CreateBundle("bundle.zip", files); - this._page.UploadFile($"{Directory.GetCurrentDirectory()}\\bundle.zip", false); + this._page.UploadFile("bundle.zip", false); this._page.AssertSuccessToast("Success."); - using var download = ZipFile.OpenRead(this._page.GetDownloadedFile()); + var file = this._page.GetDownloadedFile(); + + using var download = new ZipFile(file); Assert.Multiple(() => { - Assert.That(download.Entries, Has.Count.EqualTo(4)); // all binaries, logs - Assert.That(download.Entries.Any(x => DefaultBundleNames.Contains(x.Name))); + Assert.That(download, Has.Count.EqualTo(4)); // all binaries, logs foreach (var bundleName in DefaultBundleNames) { - ZipArchiveEntry? entry = download.Entries.FirstOrDefault(x => x.Name == bundleName); - Assert.That(entry, Is.Not.Null); + ZipEntry? entry = download.GetEntry(bundleName); + Assert.That(download.GetEntry(bundleName), Is.Not.Null); + + using var file = new ZipFile(download.GetInputStream(entry)); + + Assert.That(file, Has.Count.EqualTo(1)); + Assert.That(file.GetEntry("main.lua"), Is.Not.Null); - using var binary = new ZipArchive(entry!.Open()); - Assert.That(binary.Entries, Has.Count.EqualTo(1), bundleName); + if (bundleName.EndsWith(".wuhb")) continue; + + var bytes = bundleName.EndsWith(".3dsx") switch + { + true => Assets.GetData("icon_big_default.bin"), + false => Assets.GetData("icon-hac-default.jpg") + }; + + using var stream = download.GetInputStream(entry); + + var buffer = new byte[stream.Length]; + stream.Read(buffer, 0, buffer.Length); + + Assert.That(buffer.Intersect(bytes), Is.Not.Empty); } }); } catch (InvalidDataException) { Console.WriteLine("Resulting bundle download did not append game archive."); + throw; } - finally + } + + [TestCase] + [Description("Test a bundle upload without a configuration file.")] + public void TestBundleUploadWithNoConfig() + { + Span files = + [ + new() { FileName = "main.lua", EntryPath = "game/main.lua" } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast("Missing configuration file."); + } + + [TestCase] + [Description("Test a bundle upload without a game directory.")] + public void TestBundleUploadWithNoGame() + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast($"Source folder '{config?.Build?.Source}' not found"); + } + + [TestCase] + [Description("Test a bundle upload with an empty configuration file.")] + public void TestBundleUploadEmptyConfig() + { + var config = new Config(); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast("Invalid configuration file."); + } + + [TestCase(ConfigSection.Metadata)] + [TestCase(ConfigSection.Build)] + [Description("Test a bundle upload with an invalid configuration file with a missing required section.")] + public void TestBundleUploadMissingConfigSection(ConfigSection section) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[section] = null; + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast(string.Format(MissingConfigSection, section.ToString().ToLower())); + } + + [TestCase(ConfigSection.Build, ConfigSectionField.Source)] + [TestCase(ConfigSection.Build, ConfigSectionField.Targets)] + [TestCase(ConfigSection.Build, ConfigSectionField.Packaged)] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Title)] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Author)] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Description)] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Version)] + [Description("Test a bundle upload with an invalid configuration file with a missing required section key.")] + public void TestBundleUploadMissingConfigSectionKey(ConfigSection section, ConfigSectionField field) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[section]![field] = null; + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + + var (sectionName, fieldName) = (section.ToString().ToLower(), field.ToString().ToLower()); + this._page.AssertErrorToast(string.Format(MissingConfigField, sectionName, fieldName)); + } + + [TestCase(ConfigSection.Metadata, ConfigSectionField.Title, "true")] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Author, "42069")] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Description, "[]")] + [TestCase(ConfigSection.Metadata, ConfigSectionField.Version, "69")] + [TestCase(ConfigSection.Build, ConfigSectionField.Source, "false")] + [TestCase(ConfigSection.Build, ConfigSectionField.Targets, "\"KURWA\"")] + [TestCase(ConfigSection.Build, ConfigSectionField.Packaged, "3.14159")] + [Description("Test a bundle upload with an invalid configuration file with a wrong field type.")] + public void TestBundleUploadWrongConfigFieldType(ConfigSection section, ConfigSectionField field, string value) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + var content = config.SetField(field, value); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = content } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + + var (sectionName, fieldName) = (section.ToString().ToLower(), field.ToString().ToLower()); + this._page.AssertErrorToast(string.Format(ConfigFieldTypeInvalid, sectionName, fieldName)); + } + + [TestCase] + [Description("Test a bundle upload with a garbage(?) configuration file.")] + public void TestBundleUploadWithNonsenseConfig() + { + var content = GenerateRandomStringBytes(69); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = content } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast("Invalid config content. Unable to parse TOML."); + } + + [TestCase(ConfigIconType.CTR, "icon-ctr.png")] + [TestCase(ConfigIconType.HAC, "icon-hac.jpg")] + //[TestCase(ConfigIconType.CAFE, "icon-cafe.png")] + public void TestBundleUploadWithCustomIcon(ConfigIconType icon, string filename) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[ConfigSection.Metadata]!.SetIcon(icon, $"icons/{filename}"); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + new() { EntryPath = "game/main.lua", FileName = "main.lua" }, + new() { EntryPath = $"icons/{filename}", FileName = filename } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertSuccessToast("Success."); + + using var zip = new ZipFile(this._page.GetDownloadedFile()); + + Assert.Multiple(() => { - File.Delete("bundle.zip"); - } + Assert.That(zip, Has.Count.EqualTo(4)); // all binaries, logs + + foreach (var bundleName in DefaultBundleNames) + { + ZipEntry? entry = zip.GetEntry(bundleName); + Assert.That(entry, Is.Not.Null); + + var bytes = (icon == ConfigIconType.CTR) ? Assets.GetData("icon_big.bin") : Assets.GetData(filename); + + using var stream = zip.GetInputStream(entry); + + var buffer = new byte[stream.Length]; + stream.Read(buffer, 0, buffer.Length); + + Assert.That(buffer.Intersect(bytes), Is.Not.Empty); + } + }); + } + + [TestCase(ConfigIconType.CTR, "lenny.png")] + [TestCase(ConfigIconType.HAC, "dio.jpg")] + [TestCase(ConfigIconType.CAFE, "cat_big_both.png")] + [Description("Validate that uploading a bundle with incorrect icon dimensions throws an error.")] + public void TestBundleUploadWithCustomIconBadDimensions(ConfigIconType icon, string filename) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[ConfigSection.Metadata]!.SetIcon(icon, $"icons/{filename}"); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + new() { EntryPath = "game/main.lua", FileName = "main.lua" }, + new() { EntryPath = $"icons/{filename}", FileName = filename } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast($"Invalid {icon.ToString().ToLower()} icon dimensions."); + } + + [TestCase(ConfigIconType.CTR)] + [TestCase(ConfigIconType.HAC)] + [TestCase(ConfigIconType.CAFE)] + [Description("Validate that uploading a bundle with a bad icon mimetype throws an error.")] + public void TestBundleUploadWithCustomIconBadMimetype(ConfigIconType icon) + { + var filename = "empty"; + + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[ConfigSection.Metadata]!.SetIcon(icon, $"icons/{filename}"); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + new() { EntryPath = "game/main.lua", FileName = "main.lua" }, + new() { EntryPath = $"icons/{filename}", FileName = filename } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast($"Icon for {icon.ToString().ToLower()} has no mimetype."); + } + + [TestCase(ConfigIconType.CTR, "corrupt.png")] + [TestCase(ConfigIconType.HAC, "corrupt.jpg")] + [TestCase(ConfigIconType.CAFE, "corrupt.png")] + [Description("Validate that uploading a bundle with a valid icon mimetype but invalid data throws an error.")] + public void TestBundleUploadWithCustomIconInvalidImage(ConfigIconType icon, string filename) + { + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[ConfigSection.Metadata]!.SetIcon(icon, $"icons/{filename}"); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + new() { EntryPath = "game/main.lua", FileName = "main.lua" }, + new() { EntryPath = $"icons/{filename}", FileName = filename } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast($"Invalid icon for {icon.ToString().ToLower()}."); + } + + [TestCase(ConfigIconType.CTR)] + [TestCase(ConfigIconType.HAC)] + [TestCase(ConfigIconType.CAFE)] + public void TestBundleUploadWithCustomIconIncorrectMimetype(ConfigIconType icon) + { + var filename = "yeetus.txt"; + + var config = LoadConfig(Assets.GetFilepath("lovebrew.toml")); + config[ConfigSection.Metadata]!.SetIcon(icon, $"icons/{filename}"); + + Span files = + [ + new() { EntryPath = "lovebrew.toml", Data = config.Data }, + new() { EntryPath = "game/main.lua", FileName = "main.lua" }, + new() { EntryPath = $"icons/{filename}", FileName = filename } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertErrorToast($"Invalid {icon.ToString().ToLower()} icon mimetype."); } #endregion @@ -246,38 +551,27 @@ public void TestBundlerCachingSuccess() [TestCase] public void TestBundlerAssetCachingSuccess() { - try - { - Span<(string, string)> files = - [ - ("lovebrew.toml", "lovebrew.toml"), - ("Oneday.otf", "game/Oneday.otf"), - ("lenny.png", "game/lenny.png"), - ("dio.jpg", "game/dio.jpg"), - ("main.lua", "game/main.lua"), - ]; - - CreateBundle("bundle.zip", files); - - this._page.UploadFile($"{Directory.GetCurrentDirectory()}\\bundle.zip", false); - this._page.AssertSuccessToast("Success."); + Span files = + [ + new() { FileName = "lovebrew.toml", EntryPath = "lovebrew.toml" }, + new() { FileName = "Oneday.otf", EntryPath = "game/Oneday.otf" }, + new() { FileName = "lenny.png", EntryPath = "game/lenny.png" }, + new() { FileName = "dio.jpg", EntryPath = "game/dio.jpg" }, + new() { FileName = "main.lua", EntryPath = "game/main.lua" } + ]; + + CreateBundle("bundle.zip", files); + + this._page.UploadFile("bundle.zip", false); + this._page.AssertSuccessToast("Success."); - var _stores = this._page.GetIndexedDBData>("bundler", "assetCache"); - Assert.That(_stores, Has.Count.EqualTo(3)); + var _stores = this._page.GetIndexedDBData>("bundler", "assetCache"); + Assert.That(_stores, Has.Count.EqualTo(3)); - for (int index = 0; index < _stores.Count; index++) - { - var _cache = new AssetCache((Dictionary)_stores.ElementAt(index)); - Assert.That(_cache, Has.Count.EqualTo(2)); - } - } - catch (InvalidDataException) - { - Console.WriteLine("Resulting bundle download did not append game archive."); - } - finally + for (int index = 0; index < _stores.Count; index++) { - File.Delete("bundle.zip"); + var _cache = new AssetCache((Dictionary)_stores.ElementAt(index)); + Assert.That(_cache, Has.Count.EqualTo(2)); } } diff --git a/Bundler.QA/Frontend/WebDriver.cs b/Bundler.QA/Frontend/WebDriver.cs index 8675008..4544327 100644 --- a/Bundler.QA/Frontend/WebDriver.cs +++ b/Bundler.QA/Frontend/WebDriver.cs @@ -5,9 +5,6 @@ using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Edge; using System.Diagnostics; -using OpenQA.Selenium.DevTools; -using OpenQA.Selenium.DevTools.V123.IndexedDB; -using OpenQA.Selenium.DevTools.V123; namespace Bundler.QA.Frontend { @@ -47,6 +44,8 @@ public void Destroy() processes = Process.GetProcessesByName("msedgedriver"); foreach (var process in processes) process.Kill(); + + Directory.Delete(DownloadsPath, true); } catch (Exception e) { diff --git a/Bundler.QA/Resources/icon-cafe.png b/Bundler.QA/Resources/icon-cafe.png deleted file mode 100644 index d986845..0000000 Binary files a/Bundler.QA/Resources/icon-cafe.png and /dev/null differ diff --git a/Bundler.QA/Resources/icon-ctr.png b/Bundler.QA/Resources/icon-ctr.png deleted file mode 100644 index d89c362..0000000 Binary files a/Bundler.QA/Resources/icon-ctr.png and /dev/null differ diff --git a/Bundler.QA/Resources/icon-hac.jpg b/Bundler.QA/Resources/icon-hac.jpg deleted file mode 100644 index 0023475..0000000 Binary files a/Bundler.QA/Resources/icon-hac.jpg and /dev/null differ diff --git a/Bundler.QA/Resources/icons/icon-cafe-default.png b/Bundler.QA/Resources/icons/icon-cafe-default.png new file mode 100644 index 0000000..341a1b5 Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-cafe-default.png differ diff --git a/Bundler.QA/Resources/icons/icon-cafe.png b/Bundler.QA/Resources/icons/icon-cafe.png new file mode 100644 index 0000000..2c211e0 Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-cafe.png differ diff --git a/Bundler.QA/Resources/icons/icon-ctr-default.png b/Bundler.QA/Resources/icons/icon-ctr-default.png new file mode 100644 index 0000000..cc056ea Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-ctr-default.png differ diff --git a/Bundler.QA/Resources/icons/icon-ctr.png b/Bundler.QA/Resources/icons/icon-ctr.png new file mode 100644 index 0000000..8e983cb Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-ctr.png differ diff --git a/Bundler.QA/Resources/icons/icon-hac-default.jpg b/Bundler.QA/Resources/icons/icon-hac-default.jpg new file mode 100644 index 0000000..6a3cb8d Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-hac-default.jpg differ diff --git a/Bundler.QA/Resources/icons/icon-hac.jpg b/Bundler.QA/Resources/icons/icon-hac.jpg new file mode 100644 index 0000000..c97fb8e Binary files /dev/null and b/Bundler.QA/Resources/icons/icon-hac.jpg differ diff --git a/Bundler.QA/Resources/icons/icon_big.bin b/Bundler.QA/Resources/icons/icon_big.bin new file mode 100644 index 0000000..b048f8f Binary files /dev/null and b/Bundler.QA/Resources/icons/icon_big.bin differ diff --git a/Bundler.QA/Resources/icons/icon_big_default.bin b/Bundler.QA/Resources/icons/icon_big_default.bin new file mode 100644 index 0000000..aaa9701 --- /dev/null +++ b/Bundler.QA/Resources/icons/icon_big_default.bin @@ -0,0 +1 @@ +}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S���\�S�S�Y�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S��S�����������S��S�S�����������S��S�S�S�S�S��S�s�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S��s�s}S��s�s}}}}}S�S�S�S�S��s�s}S��s�s}}}}}}}}}}}}}}}}}}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�u�>�9���S�S�S�S��>�S�s�~���������5���������������S��S�S�S�S��S�s��>�S����>�;�9�9�9�U����|�����<�;�9�9�Y���9�9�����S�S�S�S�S�S�S�S�S���S�|�x�������S�S�S�S�S�S�S�S�S�S���S�S�S�S�S���;�<�����|���9�9�����9�9���Y���S�>���S�S��S�9�;�9�9���S�>���S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S��s�s}S��s�s}}}}}S�S�S�S�S��s�s}S��s�s}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�u������9�9���9�S��S�S��|�s��9�9�9�9�9�9�9�9�9�9�\�9�9�9�9�9�S�S�S�S�S��S��S�S�S�S�S���S��|�9���9�9�9�9�9���9���9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�9�\�9�9�9������|ߞ |�_�ޖ}�}}}9�9�9�9�9�����_�9���������_���_�>N}>N}}}}}>N}>N}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�u�S�S�S�S�s���w���S�S�S�S�S�|�x���S��s��>�Y���9���9�9�9�9�������9�������������������>�������������>���ކ�������_�����������������ニߎ�羆������������������������������������_�}�~f}}}}ކ�߾����}_�}���������_��������羆������ߎ��_�ߎ����_��~�����ニ������ކ��}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}�5}}}}}�}_�~^}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�|�<�>ニS��s�s}�ކ�~?�S�S�S�S�S��s�s}S��s�s}}}}}}}}}� �}�}}}}}}}}�������������������������������������������������������������������_�Ǿ���ߎ��}~^}}ϟ�}�-����������������?����n��ߎ��_�Ǿ���������������������������������������������������������������������������������ߎ�_���?����n������ߎ����_�����-}~^}}}ߎ_�����}}}}ކ�?��~}}}}}}}}}}}}}}}}}}}}Ϟ �}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S�S��s�s}S��s�s}}}}}S�S�S�S�S��s�s}S��s�s}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}} \ No newline at end of file diff --git a/Bundler.QA/Resources/icons/icon_small.bin b/Bundler.QA/Resources/icons/icon_small.bin new file mode 100644 index 0000000..43597d4 Binary files /dev/null and b/Bundler.QA/Resources/icons/icon_small.bin differ diff --git a/Bundler.QA/Resources/icons/icon_small_default.bin b/Bundler.QA/Resources/icons/icon_small_default.bin new file mode 100644 index 0000000..84e2a0b Binary files /dev/null and b/Bundler.QA/Resources/icons/icon_small_default.bin differ diff --git a/Bundler.QA/Resources/images/corrupt.jpeg b/Bundler.QA/Resources/images/corrupt.jpeg new file mode 100644 index 0000000..ff6cbf8 Binary files /dev/null and b/Bundler.QA/Resources/images/corrupt.jpeg differ diff --git a/Bundler.QA/Resources/images/corrupt.jpg b/Bundler.QA/Resources/images/corrupt.jpg new file mode 100644 index 0000000..8b20018 Binary files /dev/null and b/Bundler.QA/Resources/images/corrupt.jpg differ diff --git a/Bundler.QA/Resources/yeetus.txt b/Bundler.QA/Resources/yeetus.txt new file mode 100644 index 0000000..1fefa64 --- /dev/null +++ b/Bundler.QA/Resources/yeetus.txt @@ -0,0 +1 @@ +deletus \ No newline at end of file diff --git a/Bundler.Server/Bundler.Server.csproj b/Bundler.Server/Bundler.Server.csproj index 62f8f33..9d4c65a 100644 --- a/Bundler.Server/Bundler.Server.csproj +++ b/Bundler.Server/Bundler.Server.csproj @@ -24,6 +24,7 @@ + diff --git a/Bundler.Server/Controllers/BundlerCompilerController.cs b/Bundler.Server/Controllers/BundlerCompilerController.cs index ecbcf2e..1c58f6e 100644 --- a/Bundler.Server/Controllers/BundlerCompilerController.cs +++ b/Bundler.Server/Controllers/BundlerCompilerController.cs @@ -2,8 +2,9 @@ using Microsoft.AspNetCore.Mvc; +using SixLabors.ImageSharp; + using Bundler.Server.Models; -using System.ComponentModel.DataAnnotations; namespace Bundler.Server.Controllers { @@ -15,6 +16,7 @@ namespace Bundler.Server.Controllers public partial class BundlerCompileController : ControllerBase { private readonly Logger _logger; + private readonly Dictionary ConsoleIconDimensions = new() { {"ctr", 48 }, { "hac", 256 }, { "cafe", 128 }}; /// /// Initializes a new instance of the class. @@ -75,6 +77,7 @@ private string Compile(string directory, string console, BundlerQuery query, str private static bool ValidateIcon(string console, string mimeType) { + Console.WriteLine($"Validating icon: {console} ({mimeType})"); return console switch { "ctr" or "cafe" => mimeType == "image/png", @@ -83,6 +86,40 @@ private static bool ValidateIcon(string console, string mimeType) }; } + private (int, string) CheckIcon(string path, string target) + { + try + { + if (MimeTypes.TryGetMimeType(path, out var mimeType)) + { + using var image = Image.Load(path); + + var dimensions = ConsoleIconDimensions[target]; + int[] imageDimensions = [image.Width, image.Height]; + + if (!imageDimensions.All((dimension) => dimension == dimensions)) + return (StatusCodes.Status422UnprocessableEntity, $"Invalid icon dimensions for {target}."); + + if (!ValidateIcon(target, mimeType)) + return (StatusCodes.Status422UnprocessableEntity, $"Invalid icon mimetype for {target}."); + else + this._logger.LogInformation($"Using custom icon for {target}"); + } + else + return (StatusCodes.Status422UnprocessableEntity, $"Icon for {target} has no mimetype."); + } + catch (Exception exception) + { + var message = $"An error occurred while processing the icon for {target}."; + if (exception is ImageFormatException) + message = $"Invalid icon format for {target}."; + + return (StatusCodes.Status422UnprocessableEntity, message); + } + + return (StatusCodes.Status200OK, string.Empty); + } + /// /// Compiles the specified targets /// @@ -128,28 +165,20 @@ public IActionResult Post([FromQuery] BundlerQuery? query) /* save the custom icon, if it exists */ - IFormFile? icon; - if ((icon = files?.FirstOrDefault(x => x.Name == $"icon-{target}")) is not null) - { - iconPath = Path.Combine(directory, icon.FileName); - using var stream = new FileStream(iconPath, FileMode.Create); + IFormFile? icon = files.FirstOrDefault((x) => x.Name == $"icon-{target}"); + + if (icon is not null) + { + var customIconPath = Path.Join(directory, icon.FileName); + using var stream = new FileStream(customIconPath, FileMode.Create); icon.CopyTo(stream); + stream.Dispose(); - if (MimeTypes.TryGetMimeType(iconPath, out var mimeType)) - { - if (!ValidateIcon(target, mimeType)) - { - this._logger.LogWarning($"Invalid icon MIME type for {iconPath}. Using default."); - iconPath = originalPath; - } - else - this._logger.LogInformation($"Using custom icon for {target}"); - } + var (statusCode, message) = CheckIcon(customIconPath, target); + if (statusCode != StatusCodes.Status200OK) + return StatusCode(statusCode, message); else - { - this._logger.LogWarning($"Failed to get MIME type for {iconPath}. Using default icon."); - iconPath = originalPath; - } + iconPath = customIconPath; } var content = Compile(directory, target, query, iconPath); diff --git a/Bundler.Server/packages.lock.json b/Bundler.Server/packages.lock.json index 05f871f..ffbff4d 100644 --- a/Bundler.Server/packages.lock.json +++ b/Bundler.Server/packages.lock.json @@ -47,6 +47,12 @@ "System.Reactive": "6.0.1" } }, + "SevenZipExtractor": { + "type": "Direct", + "requested": "[1.0.17, )", + "resolved": "1.0.17", + "contentHash": "GZHyv3apcpFO1aAcPyscbDHie5doCSS28tnjJt66wl7K8Pb+8CqxaymomZZvCh4b5xw7yi+xLo8XHxVP3AboNg==" + }, "SixLabors.Fonts": { "type": "Direct", "requested": "[2.0.3, )",