diff --git a/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs b/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs index 9c638f4395..ee5131c1ff 100644 --- a/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs +++ b/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs @@ -63,6 +63,9 @@ namespace Emby.Server.Implementations.SocketSharp if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XRealIP), out ip)) { ip = Request.HttpContext.Connection.RemoteIpAddress; + + // Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests) + ip ??= IPAddress.Loopback; } } @@ -90,7 +93,10 @@ namespace Emby.Server.Implementations.SocketSharp public IQueryCollection QueryString => Request.Query; - public bool IsLocal => Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress); + public bool IsLocal => + (Request.HttpContext.Connection.LocalIpAddress == null + && Request.HttpContext.Connection.RemoteIpAddress == null) + || Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress); public string HttpMethod => Request.Method; diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 9635cc6ec2..8093efebe2 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -161,23 +161,7 @@ namespace Jellyfin.Server ApplicationHost.LogEnvironmentInfo(_logger, appPaths); - // Make sure we have all the code pages we can get - // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // Increase the max http request limit - // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. - ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); - - // Disable the "Expect: 100-Continue" header by default - // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c - ServicePointManager.Expect100Continue = false; - - Batteries_V2.Init(); - if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK) - { - _logger.LogWarning("Failed to enable shared cache for SQLite"); - } + PerformStaticInitialization(); var appHost = new CoreAppHost( appPaths, @@ -205,7 +189,7 @@ namespace Jellyfin.Server ServiceCollection serviceCollection = new ServiceCollection(); appHost.Init(serviceCollection); - var webHost = CreateWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); + var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = webHost.Services; @@ -250,14 +234,49 @@ namespace Jellyfin.Server } } - private static IWebHostBuilder CreateWebHostBuilder( + /// + /// Call static initialization methods for the application. + /// + public static void PerformStaticInitialization() + { + // Make sure we have all the code pages we can get + // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // Increase the max http request limit + // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. + ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); + + // Disable the "Expect: 100-Continue" header by default + // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c + ServicePointManager.Expect100Continue = false; + + Batteries_V2.Init(); + if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK) + { + _logger.LogWarning("Failed to enable shared cache for SQLite"); + } + } + + /// + /// Configure the web host builder. + /// + /// The builder to configure. + /// The application host. + /// The application service collection. + /// The command line options passed to the application. + /// The application configuration. + /// The application paths. + /// The configured web host builder. + public static IWebHostBuilder ConfigureWebHostBuilder( + this IWebHostBuilder builder, ApplicationHost appHost, IServiceCollection serviceCollection, StartupOptions commandLineOpts, IConfiguration startupConfig, IApplicationPaths appPaths) { - return new WebHostBuilder() + return builder .UseKestrel((builderContext, options) => { var addresses = appHost.ServerConfigurationManager @@ -496,7 +515,9 @@ namespace Jellyfin.Server /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist /// already. /// - private static async Task InitLoggingConfigFile(IApplicationPaths appPaths) + /// The application paths. + /// A task representing the creation of the configuration file, or a completed task if the file already exists. + public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) { // Do nothing if the config file already exists string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault); @@ -516,7 +537,13 @@ namespace Jellyfin.Server await resource.CopyToAsync(dst).ConfigureAwait(false); } - private static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths) + /// + /// Create the application configuration. + /// + /// The command line options passed to the program. + /// The application paths. + /// The application configuration. + public static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths) { return new ConfigurationBuilder() .ConfigureAppConfiguration(commandLineOpts, appPaths) diff --git a/MediaBrowser.sln b/MediaBrowser.sln index 49859f0330..6a22fd02f5 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -47,24 +47,29 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}" + ProjectSection(SolutionItems) = preProject + tests\jellyfin-tests.ruleset = tests\jellyfin-tests.ruleset + EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Tests", "tests\Jellyfin.MediaEncoding.Tests\Jellyfin.MediaEncoding.Tests.csproj", "{28464062-0939-4AA7-9F7B-24DDDA61A7C0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MediaEncoding.Tests", "tests\Jellyfin.MediaEncoding.Tests\Jellyfin.MediaEncoding.Tests.csproj", "{28464062-0939-4AA7-9F7B-24DDDA61A7C0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Naming.Tests", "tests\Jellyfin.Naming.Tests\Jellyfin.Naming.Tests.csproj", "{3998657B-1CCC-49DD-A19F-275DC8495F57}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Naming.Tests", "tests\Jellyfin.Naming.Tests\Jellyfin.Naming.Tests.csproj", "{3998657B-1CCC-49DD-A19F-275DC8495F57}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Api.Tests", "tests\Jellyfin.Api.Tests\Jellyfin.Api.Tests.csproj", "{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api.Tests", "tests\Jellyfin.Api.Tests\Jellyfin.Api.Tests.csproj", "{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Implementations.Tests", "tests\Jellyfin.Server.Implementations.Tests\Jellyfin.Server.Implementations.Tests.csproj", "{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server.Implementations.Tests", "tests\Jellyfin.Server.Implementations.Tests\Jellyfin.Server.Implementations.Tests.csproj", "{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Controller.Tests", "tests\Jellyfin.Controller.Tests\Jellyfin.Controller.Tests.csproj", "{462584F7-5023-4019-9EAC-B98CA458C0A0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Controller.Tests", "tests\Jellyfin.Controller.Tests\Jellyfin.Controller.Tests.csproj", "{462584F7-5023-4019-9EAC-B98CA458C0A0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Data", "Jellyfin.Data\Jellyfin.Data.csproj", "{F03299F2-469F-40EF-A655-3766F97A5702}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Api.Tests", "tests\MediaBrowser.Api.Tests\MediaBrowser.Api.Tests.csproj", "{7C93C84F-105C-48E5-A878-406FA0A5B296}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,10 +120,6 @@ Global {713F42B5-878E-499D-A878-E4C652B1D5E8}.Debug|Any CPU.Build.0 = Debug|Any CPU {713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.Build.0 = Release|Any CPU - {88AE38DF-19D7-406F-A6A9-09527719A21E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {88AE38DF-19D7-406F-A6A9-09527719A21E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {88AE38DF-19D7-406F-A6A9-09527719A21E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {88AE38DF-19D7-406F-A6A9-09527719A21E}.Release|Any CPU.Build.0 = Release|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.Build.0 = Debug|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -183,6 +184,10 @@ Global {F03299F2-469F-40EF-A655-3766F97A5702}.Debug|Any CPU.Build.0 = Debug|Any CPU {F03299F2-469F-40EF-A655-3766F97A5702}.Release|Any CPU.ActiveCfg = Release|Any CPU {F03299F2-469F-40EF-A655-3766F97A5702}.Release|Any CPU.Build.0 = Release|Any CPU + {7C93C84F-105C-48E5-A878-406FA0A5B296}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C93C84F-105C-48E5-A878-406FA0A5B296}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C93C84F-105C-48E5-A878-406FA0A5B296}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C93C84F-105C-48E5-A878-406FA0A5B296}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -215,5 +220,6 @@ Global {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {7C93C84F-105C-48E5-A878-406FA0A5B296} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection EndGlobal diff --git a/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs new file mode 100644 index 0000000000..34698fe251 --- /dev/null +++ b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Model.Branding; +using Xunit; + +namespace MediaBrowser.Api.Tests +{ + public sealed class BrandingServiceTests : IClassFixture + { + private readonly JellyfinApplicationFactory _factory; + + public BrandingServiceTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetConfiguration_ReturnsCorrectResponse() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Branding/Configuration"); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + var responseBody = await response.Content.ReadAsStreamAsync(); + _ = await JsonSerializer.DeserializeAsync(responseBody); + } + + [Theory] + [InlineData("/Branding/Css")] + [InlineData("/Branding/Css.css")] + public async Task GetCss_ReturnsCorrectResponse(string url) + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync(url); + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal("text/css", response.Content.Headers.ContentType.ToString()); + } + } +} diff --git a/tests/MediaBrowser.Api.Tests/JellyfinApplicationFactory.cs b/tests/MediaBrowser.Api.Tests/JellyfinApplicationFactory.cs new file mode 100644 index 0000000000..c39ed07de3 --- /dev/null +++ b/tests/MediaBrowser.Api.Tests/JellyfinApplicationFactory.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using Emby.Server.Implementations; +using Emby.Server.Implementations.IO; +using Emby.Server.Implementations.Networking; +using Jellyfin.Drawing.Skia; +using Jellyfin.Server; +using MediaBrowser.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Extensions.Logging; + +namespace MediaBrowser.Api.Tests +{ + /// + /// Factory for bootstrapping the Jellyfin application in memory for functional end to end tests. + /// + public class JellyfinApplicationFactory : WebApplicationFactory + { + private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); + private static readonly ConcurrentBag _disposableComponents = new ConcurrentBag(); + + /// + /// Initializes a new instance of the class. + /// + public JellyfinApplicationFactory() + { + // Perform static initialization that only needs to happen once per test-run + Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); + Program.PerformStaticInitialization(); + } + + /// + protected override IWebHostBuilder CreateWebHostBuilder() + { + return new WebHostBuilder(); + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Specify the startup command line options + var commandLineOpts = new StartupOptions + { + NoWebClient = true, + NoAutoRunWebApp = true + }; + + // Use a temporary directory for the application paths + var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + Directory.CreateDirectory(Path.Combine(webHostPathRoot, "logs")); + Directory.CreateDirectory(Path.Combine(webHostPathRoot, "config")); + Directory.CreateDirectory(Path.Combine(webHostPathRoot, "cache")); + Directory.CreateDirectory(Path.Combine(webHostPathRoot, "jellyfin-web")); + var appPaths = new ServerApplicationPaths( + webHostPathRoot, + Path.Combine(webHostPathRoot, "logs"), + Path.Combine(webHostPathRoot, "config"), + Path.Combine(webHostPathRoot, "cache"), + Path.Combine(webHostPathRoot, "jellyfin-web")); + + // Create the logging config file + // TODO: We shouldn't need to do this since we are only logging to console + Program.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); + + // Create a copy of the application configuration to use for startup + var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths); + + ILoggerFactory loggerFactory = new SerilogLoggerFactory(); + _disposableComponents.Add(loggerFactory); + + // Create the app host and initialize it + var appHost = new CoreAppHost( + appPaths, + loggerFactory, + commandLineOpts, + new ManagedFileSystem(loggerFactory.CreateLogger(), appPaths), + new NetworkManager(loggerFactory.CreateLogger())); + _disposableComponents.Add(appHost); + var serviceCollection = new ServiceCollection(); + appHost.Init(serviceCollection); + + // Configure the web host builder + Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths); + } + + /// + protected override TestServer CreateServer(IWebHostBuilder builder) + { + // Create the test server using the base implementation + var testServer = base.CreateServer(builder); + + // Finish initializing the app host + var appHost = (CoreAppHost)testServer.Services.GetRequiredService(); + appHost.ServiceProvider = testServer.Services; + appHost.InitializeServices().GetAwaiter().GetResult(); + appHost.RunStartupTasksAsync().GetAwaiter().GetResult(); + + return testServer; + } + + /// + protected override void Dispose(bool disposing) + { + foreach (var disposable in _disposableComponents) + { + disposable.Dispose(); + } + + _disposableComponents.Clear(); + + base.Dispose(disposing); + } + } +} diff --git a/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj new file mode 100644 index 0000000000..f30e486900 --- /dev/null +++ b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj @@ -0,0 +1,33 @@ + + + + netcoreapp3.1 + false + true + enable + + + + + + + + + + + + + + + + + + + + + + + ../jellyfin-tests.ruleset + + + diff --git a/tests/jellyfin-tests.ruleset b/tests/jellyfin-tests.ruleset new file mode 100644 index 0000000000..5a113e955e --- /dev/null +++ b/tests/jellyfin-tests.ruleset @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + +