mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
236 lines
10 KiB
C#
236 lines
10 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using allstarr.Controllers;
|
|
using allstarr.Core.Extensions;
|
|
using allstarr.Core.Operations;
|
|
using allstarr.Core.Storage;
|
|
using allstarr.Models.Settings;
|
|
using allstarr.Services.Admin;
|
|
using allstarr.Services.Common;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class ExtensionControllerControlPlaneTests : IAsyncLifetime
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "allstarr-extension-controller", Guid.NewGuid().ToString("N"));
|
|
private PostgresTestDatabase _database = null!;
|
|
private ExtensionControlPlaneService _service = null!;
|
|
private ExtensionManager _manager = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_database = await PostgresTestDatabase.CreateAsync();
|
|
var factory = new DbFactory(_database.Options);
|
|
await using var db = await factory.CreateDbContextAsync();
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Extensions:Directory"] = Path.Combine(_root, "extensions"),
|
|
["Admin:TrustedSubnets"] = "192.168.1.0/24"
|
|
}).Build();
|
|
_service = new ExtensionControlPlaneService(factory, new Clock(), configuration);
|
|
var clientFactory = new Mock<IHttpClientFactory>();
|
|
clientFactory.Setup(item => item.CreateClient("ExtensionSdkV1"))
|
|
.Returns(() => new HttpClient(new RegistryResponseHandler()));
|
|
_manager = new ExtensionManager(
|
|
clientFactory.Object,
|
|
NullLogger<ExtensionManager>.Instance,
|
|
configuration,
|
|
_service);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegistryEndpoints_RequireAdministratorAndReturnSafeRecords()
|
|
{
|
|
var anonymous = Controller();
|
|
Assert.IsType<UnauthorizedObjectResult>(await anonymous.ListRegistries(default));
|
|
|
|
var ordinaryUser = Controller(Session(administrator: false));
|
|
var forbidden = Assert.IsType<ObjectResult>(await ordinaryUser.ListRegistries(default));
|
|
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
|
|
|
var administrator = Controller(Session(administrator: true));
|
|
Assert.IsType<OkObjectResult>(await administrator.AddRegistry(
|
|
new RegistryRequest { Name = "Official", RegistryUrl = "https://extensions.example.test/index.json" }, default));
|
|
var result = Assert.IsType<OkObjectResult>(await administrator.ListRegistries(default));
|
|
var serialized = System.Text.Json.JsonSerializer.Serialize(result.Value);
|
|
Assert.Contains("extensions.example.test", serialized, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("packagePath", serialized, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegistryRemovalRequiresAdministratorAndHonorsRevision()
|
|
{
|
|
var registry = await _service.AddRegistryAsync(new("Disposable", "https://extensions.example.test/disposable.json"));
|
|
var anonymous = Controller();
|
|
Assert.IsType<UnauthorizedObjectResult>(await anonymous.RemoveRegistry(registry.Id, registry.Revision, default));
|
|
|
|
var administrator = Controller(Session(administrator: true));
|
|
var removed = Assert.IsType<OkObjectResult>(await administrator.RemoveRegistry(
|
|
registry.Id, registry.Revision, default));
|
|
Assert.Contains("removed", System.Text.Json.JsonSerializer.Serialize(removed.Value), StringComparison.OrdinalIgnoreCase);
|
|
Assert.Empty(await _service.ListRegistriesAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegistryMutationErrors_MapToStableControlPlaneResponses()
|
|
{
|
|
var administrator = Controller(Session(administrator: true));
|
|
var missing = await administrator.SetRegistryEnabled(
|
|
Guid.CreateVersion7(), new RegistryStateRequest { Enabled = true }, default);
|
|
Assert.IsType<NotFoundObjectResult>(missing);
|
|
|
|
var registry = await _service.AddRegistryAsync(
|
|
new("Revisioned", "https://extensions.example.test/revisioned.json"));
|
|
var conflict = Assert.IsType<ConflictObjectResult>(await administrator.SetRegistryEnabled(
|
|
registry.Id,
|
|
new RegistryStateRequest { Enabled = false, ExpectedRevision = registry.Revision + 1 },
|
|
default));
|
|
Assert.Contains("changed before this update", System.Text.Json.JsonSerializer.Serialize(conflict.Value),
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_HasSingleDependencyInjectionConstructor()
|
|
{
|
|
Assert.Single(typeof(ExtensionController).GetConstructors());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewEndpoint_RequiresPlatformUserLinkedAdministratorSession()
|
|
{
|
|
var administrator = Controller(Session(administrator: true, allstarrUserId: null));
|
|
var result = await administrator.ReviewPermissions(Guid.CreateVersion7(), new PermissionReviewRequest(), default);
|
|
Assert.IsType<ConflictObjectResult>(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddRegistry_RejectsRepositoryPagesWithActionableApiFeedback()
|
|
{
|
|
var administrator = Controller(Session(administrator: true));
|
|
|
|
var result = Assert.IsType<BadRequestObjectResult>(await administrator.AddRegistry(
|
|
new RegistryRequest
|
|
{
|
|
Name = "SpotiFLAC",
|
|
RegistryUrl = "https://github.com/spotiflacapp/SpotiFLAC-Extension"
|
|
},
|
|
default));
|
|
var serialized = System.Text.Json.JsonSerializer.Serialize(result.Value);
|
|
|
|
Assert.Contains("GitHub project page", serialized, StringComparison.Ordinal);
|
|
Assert.Contains("raw registry.json", serialized, StringComparison.Ordinal);
|
|
Assert.Empty(await _service.ListRegistriesAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LogEndpoint_RejectsUnboundedRequests()
|
|
{
|
|
var administrator = Controller(Session(administrator: true));
|
|
var result = await administrator.ListLogs(null, null, 501, default);
|
|
Assert.IsType<BadRequestObjectResult>(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StagingEndpoints_DoNotBypassAdministratorAuthentication()
|
|
{
|
|
var controller = Controller();
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.GetStoreExtensions(default));
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.InstallExtension(new InstallRequest(), default));
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.UninstallPackage(
|
|
Guid.CreateVersion7(), new RevisionRequest(), default));
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.RevokePermissionGrants(
|
|
Guid.CreateVersion7(), new RevisionRequest(), default));
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.CancelStaging(
|
|
Guid.CreateVersion7(), new RevisionRequest(), default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Install_AllowsTrustedLanAndRejectsUntrustedRemoteClientByDefault()
|
|
{
|
|
var lan = Controller(Session(administrator: true), "192.168.1.25");
|
|
Assert.IsType<BadRequestObjectResult>(await lan.InstallExtension(new InstallRequest(), default));
|
|
|
|
var remote = Controller(Session(administrator: true), "203.0.113.25");
|
|
var denied = Assert.IsType<ObjectResult>(await remote.InstallExtension(new InstallRequest(), default));
|
|
Assert.Equal(StatusCodes.Status403Forbidden, denied.StatusCode);
|
|
Assert.Contains("outside that boundary", System.Text.Json.JsonSerializer.Serialize(denied.Value),
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private ExtensionController Controller(AdminAuthSession? session = null, string? remoteIp = null)
|
|
{
|
|
var context = new DefaultHttpContext();
|
|
if (remoteIp != null) context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp);
|
|
if (session != null)
|
|
context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = session;
|
|
return new ExtensionController(_manager, _service, null, NullLogger<ExtensionController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = context }
|
|
};
|
|
}
|
|
|
|
private static AdminAuthSession Session(bool administrator, Guid? allstarrUserId = null) => new()
|
|
{
|
|
SessionId = "fixture",
|
|
UserId = "backend-user",
|
|
UserName = "Fixture",
|
|
IsAdministrator = administrator,
|
|
AllstarrUserId = allstarrUserId,
|
|
JellyfinAccessToken = "fixture",
|
|
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
|
|
LastSeenUtc = DateTime.UtcNow
|
|
};
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _database.DisposeAsync();
|
|
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
}
|
|
|
|
private sealed class Clock : IPlatformClock
|
|
{
|
|
public DateTimeOffset UtcNow => new(2026, 7, 12, 8, 0, 0, TimeSpan.Zero);
|
|
}
|
|
|
|
private sealed class RegistryResponseHandler : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const string registry = """
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "fixture-extension",
|
|
"downloadUrl": "https://extensions.example.test/fixture.zip",
|
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(registry, Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
}
|
|
|
|
private sealed class DbFactory(DbContextOptions<AllstarrDbContext> options) : IDbContextFactory<AllstarrDbContext>
|
|
{
|
|
public AllstarrDbContext CreateDbContext() => new(options);
|
|
public Task<AllstarrDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(CreateDbContext());
|
|
}
|
|
}
|