using System.IO.Compression; using System.Reflection; using System.Security.Claims; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Tests; public sealed class AppUpdatesControllerTests { [Fact] public void Management_actions_are_restricted_to_super_admin() { var method = typeof(AppUpdatesController) .GetMethod(nameof(AppUpdatesController.UploadRelease)); var authorize = method?.GetCustomAttribute(); Assert.NotNull(authorize); Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles); } [Fact] public async Task Upload_publish_check_and_download_form_a_complete_flow() { await using var fixture = await AppUpdatesFixture.CreateAsync(); var content = CreateBundle("first"); var uploaded = await fixture.Controller.UploadRelease( UploadRequest(content, "1.0.1"), CancellationToken.None); var created = Assert.IsType(uploaded.Result); var release = Assert.IsType(created.Value); Assert.Equal(AppUpdateReleaseStatus.Draft, release.Status); Assert.Equal(64, release.Sha256.Length); var publishedAction = await fixture.Controller.PublishRelease( release.Id, CancellationToken.None); var publishedResult = Assert.IsType( publishedAction.Result); var published = Assert.IsType( publishedResult.Value); Assert.Equal(AppUpdateReleaseStatus.Published, published.Status); var checkAction = await fixture.Controller.GetLatest( "android", "1.0", "production", "1.0.0", CancellationToken.None); var checkResult = Assert.IsType(checkAction.Result); var check = Assert.IsType(checkResult.Value); Assert.True(check.Available); Assert.Equal("1.0.1", check.Version); Assert.Equal(release.Sha256, check.Sha256); Assert.Equal($"app-updates/releases/{release.Id}/bundle", check.DownloadUrl); var downloadAction = await fixture.Controller.DownloadBundle( release.Id, CancellationToken.None); var download = Assert.IsType(downloadAction); Assert.Equal(content, download.FileContents); Assert.True(download.EnableRangeProcessing); var currentAction = await fixture.Controller.GetLatest( "android", "1.0", "production", "1.0.1", CancellationToken.None); var currentResult = Assert.IsType( currentAction.Result); Assert.False(Assert.IsType( currentResult.Value).Available); } [Fact] public async Task Publishing_an_archived_release_performs_a_rollback() { await using var fixture = await AppUpdatesFixture.CreateAsync(); var first = await UploadAsync(fixture, "1.0.1", "first"); await fixture.Controller.PublishRelease(first.Id, CancellationToken.None); var second = await UploadAsync(fixture, "1.0.2", "second"); await fixture.Controller.PublishRelease(second.Id, CancellationToken.None); Assert.Equal( AppUpdateReleaseStatus.Archived, (await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status); Assert.Equal( AppUpdateReleaseStatus.Published, (await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status); await fixture.Controller.PublishRelease(first.Id, CancellationToken.None); fixture.Db.ChangeTracker.Clear(); Assert.Equal( AppUpdateReleaseStatus.Published, (await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status); Assert.Equal( AppUpdateReleaseStatus.Archived, (await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status); } [Fact] public async Task Invalid_or_nested_web_bundles_are_rejected() { await using var fixture = await AppUpdatesFixture.CreateAsync(); var invalid = await fixture.Controller.UploadRelease( UploadRequest("not a zip"u8.ToArray(), "1.0.1"), CancellationToken.None); Assert.IsType(invalid.Result); var nested = CreateBundle("nested", "dist/index.html"); var nestedResult = await fixture.Controller.UploadRelease( UploadRequest(nested, "1.0.2"), CancellationToken.None); var problem = Assert.IsType(nestedResult.Result); Assert.Contains( "根目录", Assert.IsType(problem.Value).Detail); } private static async Task UploadAsync( AppUpdatesFixture fixture, string version, string marker) { var action = await fixture.Controller.UploadRelease( UploadRequest(CreateBundle(marker), version), CancellationToken.None); return Assert.IsType( Assert.IsType(action.Result).Value); } private static AppUpdateUploadRequest UploadRequest( byte[] content, string version) { var stream = new MemoryStream(content); return new AppUpdateUploadRequest { Bundle = new FormFile( stream, 0, content.Length, "bundle", $"jiaowu-{version}.zip"), Platform = "android", Channel = "production", Version = version, NativeVersion = "1.0", ReleaseNotes = $"Release {version}" }; } private static byte[] CreateBundle( string marker, string indexPath = "index.html") { using var output = new MemoryStream(); using (var archive = new ZipArchive( output, ZipArchiveMode.Create, leaveOpen: true)) { var index = archive.CreateEntry(indexPath); using var writer = new StreamWriter(index.Open()); writer.Write($"{marker}"); } return output.ToArray(); } private sealed class AppUpdatesFixture : IAsyncDisposable { private AppUpdatesFixture( AppDbContext db, AppUpdatesController controller) { Db = db; Controller = controller; } public AppDbContext Db { get; } public AppUpdatesController Controller { get; } public static async Task CreateAsync() { var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var controller = new AppUpdatesController(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [ new Claim(ClaimTypes.Name, "root"), new Claim( ClaimTypes.Role, SystemRoles.SuperAdmin) ], "test")) } } }; return new AppUpdatesFixture(db, controller); } public async ValueTask DisposeAsync() => await Db.DisposeAsync(); } }