File: ModelBinding\Binders\FormFileModelBinderTest.cs
Web Access
Project: src\src\Mvc\Mvc.Core\test\Microsoft.AspNetCore.Mvc.Core.Test.csproj (Microsoft.AspNetCore.Mvc.Core.Test)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
 
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
 
public class FormFileModelBinderTest
{
    [Fact]
    public async Task FormFileModelBinder_SingleFile_BindSuccessful()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt")
            };
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IEnumerable<IFormFile>), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal("file", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_SingleFileAtTopLevel_BindSuccessfully_WithEmptyModelName()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt")
            };
 
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Mimic ParameterBinder overwriting ModelName on top level model. In this top-level binding case,
        // FormFileModelBinder uses FieldName from the get-go. (OriginalModelName will be checked but ignored.)
        var bindingContext = DefaultModelBindingContext.CreateBindingContext(
            new ActionContext { HttpContext = httpContext },
            Mock.Of<IValueProvider>(),
            new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
            bindingInfo: null,
            modelName: "file");
        bindingContext.ModelName = string.Empty;
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal("file", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully()
    {
        // Arrange
        const string propertyName = nameof(NestedFormFiles.Files);
        var formFiles = new FormFileCollection
            {
                GetMockFormFile($"{propertyName}", "file1.txt")
            };
 
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds.
        var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName);
        var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty(
            propertyInfo,
            propertyInfo.PropertyType);
        var bindingContext = DefaultModelBindingContext.CreateBindingContext(
            new ActionContext { HttpContext = httpContext },
            Mock.Of<IValueProvider>(),
            metadata,
            bindingInfo: null,
            modelName: "FileList");
        bindingContext.IsTopLevelObject = false;
        bindingContext.Model = new FileList();
        bindingContext.ModelName = propertyName;
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal($"{propertyName}", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully_WithShortenedModelName()
    {
        // Arrange
        const string propertyName = nameof(NestedFormFiles.Files);
        var formFiles = new FormFileCollection
            {
                GetMockFormFile($"FileList.{propertyName}", "file1.txt")
            };
 
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Mimic ParameterBinder overwriting ModelName on top level model then ComplexTypeModelBinder entering a
        // nested context for the NestedFormFiles property. In this non-top-level binding case, FormFileModelBinder
        // tries ModelName then falls back to add an (OriginalModelName + ".") prefix.
        var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName);
        var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty(
            propertyInfo,
            propertyInfo.PropertyType);
        var bindingContext = DefaultModelBindingContext.CreateBindingContext(
            new ActionContext { HttpContext = httpContext },
            Mock.Of<IValueProvider>(),
            metadata,
            bindingInfo: null,
            modelName: "FileList");
        bindingContext.IsTopLevelObject = false;
        bindingContext.Model = new FileList();
        bindingContext.ModelName = propertyName;
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal($"FileList.{propertyName}", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("[myFile]", "file1.txt")
            };
 
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds.
        var bindingContext = DefaultModelBindingContext.CreateBindingContext(
            new ActionContext { HttpContext = httpContext },
            Mock.Of<IValueProvider>(),
            new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
            bindingInfo: null,
            modelName: "FileDictionary");
        bindingContext.IsTopLevelObject = false;
        bindingContext.ModelName = "[myFile]";
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal("[myFile]", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully_WithShortenedModelName()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("FileDictionary[myFile]", "file1.txt")
            };
 
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Mimic ParameterBinder overwriting ModelName on top level model then DictionaryModelBinder entering a
        // nested context for the KeyValuePair.Value property. In this non-top-level binding case,
        // FormFileModelBinder tries ModelName then falls back to add an OriginalModelName prefix.
        var bindingContext = DefaultModelBindingContext.CreateBindingContext(
            new ActionContext { HttpContext = httpContext },
            Mock.Of<IValueProvider>(),
            new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
            bindingInfo: null,
            modelName: "FileDictionary");
        bindingContext.IsTopLevelObject = false;
        bindingContext.ModelName = "[myFile]";
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal("FileDictionary[myFile]", entry.Key);
        Assert.Null(entry.Metadata);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ExpectMultipleFiles_BindSuccessful()
    {
        // Arrange
        var formFiles = GetTwoFiles();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IEnumerable<IFormFile>), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
 
        var entry = bindingContext.ValidationState[bindingContext.Result.Model];
        Assert.False(entry.SuppressValidation);
        Assert.Equal("file", entry.Key);
        Assert.Null(entry.Metadata);
 
        var files = Assert.IsAssignableFrom<IList<IFormFile>>(bindingContext.Result.Model);
        Assert.Equal(2, files.Count);
    }
 
    [Theory]
    [InlineData(typeof(IFormFile[]))]
    [InlineData(typeof(ICollection<IFormFile>))]
    [InlineData(typeof(IList<IFormFile>))]
    [InlineData(typeof(IFormFileCollection))]
    [InlineData(typeof(List<IFormFile>))]
    [InlineData(typeof(LinkedList<IFormFile>))]
    [InlineData(typeof(FileList))]
    [InlineData(typeof(FormFileCollection))]
    public async Task FormFileModelBinder_BindsFiles_ForCollectionsItCanCreate(Type destinationType)
    {
        // Arrange
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
        var formFiles = GetTwoFiles();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(destinationType, httpContext);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.IsAssignableFrom(destinationType, bindingContext.Result.Model);
        Assert.Equal(formFiles, bindingContext.Result.Model as IEnumerable<IFormFile>);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ExpectSingleFile_BindFirstFile()
    {
        // Arrange
        var formFiles = GetTwoFiles();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var file = Assert.IsAssignableFrom<IFormFile>(bindingContext.Result.Model);
        Assert.Equal("file1.txt", file.FileName);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsFailedResult_WhenNoFilePosted()
    {
        // Arrange
        var formFiles = new FormFileCollection();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsFailedResult_WhenNamesDoNotMatch()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("different name", "file1.txt")
            };
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
    }
 
    [Theory]
    [InlineData(true, "FieldName")]
    [InlineData(false, "ModelName")]
    public async Task FormFileModelBinder_UsesFieldNameForTopLevelObject(bool isTopLevel, string expected)
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("FieldName", "file1.txt"),
                GetMockFormFile("ModelName", "file1.txt")
            };
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
 
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        bindingContext.IsTopLevelObject = isTopLevel;
        bindingContext.FieldName = "FieldName";
        bindingContext.ModelName = "ModelName";
 
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var file = Assert.IsAssignableFrom<IFormFile>(bindingContext.Result.Model);
 
        Assert.Equal(expected, file.Name);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsFailedResult_WithEmptyContentDisposition()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                new Mock<IFormFile>().Object
            };
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsFailedResult_WithNoFileNameAndZeroLength()
    {
        // Arrange
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "")
            };
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsResult_ForReadOnlyDestination()
    {
        // Arrange
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
        var formFiles = GetTwoFiles();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContextForReadOnlyArray(httpContext);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.NotNull(bindingContext.Result.Model);
    }
 
    [Fact]
    public async Task FormFileModelBinder_ReturnsFailedResult_ForCollectionsItCannotCreate()
    {
        // Arrange
        var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
        var formFiles = GetTwoFiles();
        var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
        var bindingContext = GetBindingContext(typeof(ISet<IFormFile>), httpContext);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
    }
 
    private static DefaultModelBindingContext GetBindingContextForReadOnlyArray(HttpContext httpContext)
    {
        var metadataProvider = new TestModelMetadataProvider();
        metadataProvider
            .ForProperty<ModelWithReadOnlyArray>(nameof(ModelWithReadOnlyArray.ArrayProperty))
            .BindingDetails(bd => bd.BindingSource = BindingSource.Header);
        var metadata = metadataProvider.GetMetadataForProperty(
            typeof(ModelWithReadOnlyArray),
            nameof(ModelWithReadOnlyArray.ArrayProperty));
 
        return GetBindingContext(metadata, httpContext);
    }
 
    private static DefaultModelBindingContext GetBindingContext(Type modelType, HttpContext httpContext)
    {
        var metadataProvider = new EmptyModelMetadataProvider();
        var metadata = metadataProvider.GetMetadataForType(modelType);
 
        return GetBindingContext(metadata, httpContext);
    }
 
    private static DefaultModelBindingContext GetBindingContext(
        ModelMetadata metadata,
        HttpContext httpContext)
    {
        var bindingContext = new DefaultModelBindingContext
        {
            ActionContext = new ActionContext()
            {
                HttpContext = httpContext,
            },
            ModelMetadata = metadata,
            ModelName = "file",
            ModelState = new ModelStateDictionary(),
            ValidationState = new ValidationStateDictionary(),
        };
 
        return bindingContext;
    }
 
    private static HttpContext GetMockHttpContext(IFormCollection formCollection)
    {
        var httpContext = new Mock<HttpContext>();
        httpContext.Setup(h => h.Request.ReadFormAsync(It.IsAny<CancellationToken>()))
            .Returns(Task.FromResult(formCollection));
        httpContext.Setup(h => h.Request.HasFormContentType).Returns(true);
        return httpContext.Object;
    }
 
    private static FormFileCollection GetTwoFiles()
    {
        var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt"),
                GetMockFormFile("file", "file2.txt"),
            };
 
        return formFiles;
    }
 
    private static IFormCollection GetMockFormCollection(FormFileCollection formFiles)
    {
        var formCollection = new Mock<IFormCollection>();
        formCollection.Setup(f => f.Files).Returns(formFiles);
        return formCollection.Object;
    }
 
    private static IFormFile GetMockFormFile(string modelName, string filename)
    {
        var formFile = new Mock<IFormFile>();
        formFile.Setup(f => f.Name).Returns(modelName);
        formFile.Setup(f => f.FileName).Returns(filename);
 
        return formFile.Object;
    }
 
    private class ModelWithReadOnlyArray
    {
        public IFormFile[] ArrayProperty { get; }
    }
 
    private class FileList : List<IFormFile>
    {
    }
 
    private class NestedFormFiles
    {
        public FileList Files { get; }
    }
}