using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using RadicadeServer; using RadicadeServer.CDN; using RadicadeServer.RecNet.CDN; using RadicadeServer.Storage; using RadicadeServer.API.Studio; using RadicadeServer.RoomServer; using RadicadeServer.DB; using RadicadeServer.RecNet.Rooms; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class StudioUploadTests { [Fact] public async Task BatchPreservesOrderAndStoresActualContentsIncludingEmptyFiles() { var previous = DBConfig.CDNStorage; var directory = Path.Combine(Path.GetTempPath(), "studio-upload-" + Guid.NewGuid()); DBConfig.CDNStorage = directory; try { var payloads = new[] { new byte[] { 1, 2, 3 }, Array.Empty() }; var boundary = "test"; var body = new MemoryStream(); foreach (var payload in payloads) { var header = System.Text.Encoding.ASCII.GetBytes( $"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"asset.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"); body.Write(header); body.Write(payload); body.Write(System.Text.Encoding.ASCII.GetBytes("\r\n")); } body.Write(System.Text.Encoding.ASCII.GetBytes($"--{boundary}--\r\n")); body.Position = 0; var context = new DefaultHttpContext(); context.Request.ContentType = $"multipart/form-data; boundary={boundary}"; context.Request.Body = body; context.Request.ContentLength = body.Length; var controller = new StorageAPI { ControllerContext = new ControllerContext { HttpContext = context } }; var response = Assert.IsType(await controller.UploadUnityAssets()); var json = System.Text.Json.JsonSerializer.SerializeToElement(response.Value); Assert.Equal(2, json.GetArrayLength()); for (var i = 0; i < payloads.Length; i++) { var name = json[i].GetProperty("Filename").GetString()!; Assert.Equal(40, name.Length); Assert.Equal(payloads[i], await File.ReadAllBytesAsync(Path.Combine(directory, name))); Assert.Equal(Convert.ToBase64String(SHA256.HashData(payloads[i])), json[i].GetProperty("Hash").GetString()); Assert.IsType(new StudioAssetsCDN().Download(name)); } Assert.Equal(2, Directory.GetFiles(directory).Length); } finally { DBConfig.CDNStorage = previous; if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task UploadParsesGzipCompressedMultipartFilesLargerThanDefault16KiBLimit() { var previous = DBConfig.CDNStorage; var directory = Path.Combine(Path.GetTempPath(), "studio-upload-large-" + Guid.NewGuid()); DBConfig.CDNStorage = directory; try { const string boundary = "studio-upload-boundary"; var payload = Enumerable.Range(0, 32 * 1024).Select(i => (byte)(i % 251)).ToArray(); var multipart = System.Text.Encoding.ASCII.GetBytes( new string('p', 20 * 1024) + "\r\n" + $"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"asset.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n") .Concat(payload) .Concat(System.Text.Encoding.ASCII.GetBytes($"\r\n--{boundary}--\r\n")) .ToArray(); var body = new MemoryStream(); await using (var gzip = new System.IO.Compression.GZipStream(body, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) await gzip.WriteAsync(multipart); body.Position = 0; var context = new DefaultHttpContext(); context.Request.ContentType = $"multipart/form-data; boundary=\"{boundary}\""; context.Request.Headers["Content-Encoding"] = "gzip"; context.Request.Body = body; context.Request.ContentLength = body.Length; var controller = new StorageAPI { ControllerContext = new ControllerContext { HttpContext = context } }; var response = Assert.IsType(await controller.UploadUnityAssets()); var json = System.Text.Json.JsonSerializer.SerializeToElement(response.Value); var filename = json[0].GetProperty("Filename").GetString()!; Assert.Equal(payload, await File.ReadAllBytesAsync(Path.Combine(directory, filename))); } finally { DBConfig.CDNStorage = previous; if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task CompressedSingleFileUploadReturnsLegacyFilenameResponse() { var previous = DBConfig.CDNStorage; var directory = Path.Combine(Path.GetTempPath(), "studio-upload-single-" + Guid.NewGuid()); DBConfig.CDNStorage = directory; try { const string boundary = "legacy-upload-boundary"; var payload = Enumerable.Range(0, 4096).Select(i => (byte)(i % 253)).ToArray(); var multipart = System.Text.Encoding.ASCII.GetBytes( "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"asset.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n") .Concat(payload) .Concat(System.Text.Encoding.ASCII.GetBytes("\r\n--" + boundary + "--\r\n")) .ToArray(); var body = new MemoryStream(); await using (var gzip = new System.IO.Compression.GZipStream(body, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) await gzip.WriteAsync(multipart); body.Position = 0; var context = new DefaultHttpContext(); context.Request.ContentType = $"multipart/form-data; boundary=\"{boundary}\""; context.Request.Headers["Content-Encoding"] = "gzip"; context.Request.Body = body; context.Request.ContentLength = body.Length; var controller = new StorageAPI { ControllerContext = new ControllerContext { HttpContext = context } }; var response = await controller.Upload(); var value = Assert.IsType(response.Value); Assert.Equal(40, value.filename.Length); Assert.Equal(payload, await File.ReadAllBytesAsync(Path.Combine(directory, value.filename))); } finally { DBConfig.CDNStorage = previous; if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Fact] public async Task RegistryAndGameMetadataExposeBothWindowsBundlesAndRawAssetDownload() { var previous = DBConfig.CDNStorage; var directory = Path.Combine(Path.GetTempPath(), "studio-baked-" + Guid.NewGuid()); DBConfig.CDNStorage = directory; Directory.CreateDirectory(directory); try { const string manifestId = "0123456789abcdef0123456789abcdef01234567"; await File.WriteAllTextAsync(Path.Combine(directory, manifestId), "{\"Version\":2,\"MainSceneProjectRelativePath\":\"Assets/Room.unity\",\"Files\":[]}"); await using var sceneBundle = new MemoryStream(new byte[] { 1, 2, 3, 4 }); var sceneUpload = await StudioAssetStorage.Save(sceneBundle, CancellationToken.None); await using var rejectedBundle = new MemoryStream(new byte[] { 5, 6, 7 }); var rejectedUpload = await StudioAssetStorage.Save(rejectedBundle, CancellationToken.None); await StudioUnityAssetRegistry.AddBakedAssetAsync(manifestId, new StudioBakedUnityAsset(manifestId, 0, 1, sceneUpload.Filename, rejectedUpload.Filename)); var api = new UnityAssetsCDN { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var response = Assert.IsType(await api.GetUnityAsset(manifestId, 0, 1)); using var metadata = System.Text.Json.JsonDocument.Parse(response.Content!); Assert.Equal(sceneUpload.Filename, metadata.RootElement.GetProperty("Filename").GetString()); Assert.Equal(rejectedUpload.Filename, metadata.RootElement.GetProperty("RejectedDependenciesFilename").GetString()); Assert.IsType(new DataCDN().GetDataBlob(sceneUpload.Filename)); Assert.IsType(new DataCDN().GetDataBlob(rejectedUpload.Filename)); var studio = new StudioAssetsCDN { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; Assert.IsType(studio.Download(sceneUpload.Filename)); } finally { DBConfig.CDNStorage = previous; if (Directory.Exists(directory)) Directory.Delete(directory, true); } } [Theory] [InlineData("../secret")] [InlineData("..\\secret")] [InlineData("C:secret")] public void DownloadRejectsPaths(string filename) => Assert.IsType(new StudioAssetsCDN().Download(filename)); }