using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Plugins; using Jiaowu.Plugin.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Jiaowu.Api.Tests; public sealed class PluginPackageTests { [Fact] public void Signed_package_is_validated_and_tampering_is_rejected() { using var fixture = new PackageFixture(); var package = fixture.CreatePackage(); var signature = fixture.Sign(package); var validated = fixture.Validator.Validate(package, signature); Assert.Equal("sample.hello", validated.Manifest.Id); package[^1] ^= 0x01; Assert.Throws(() => fixture.Validator.Validate(package, signature)); } [Fact] public void Package_with_incompatible_host_api_is_rejected() { using var fixture = new PackageFixture(); var package = fixture.CreatePackage(hostApiVersion: "999"); var exception = Assert.Throws(() => fixture.Validator.Validate(package, fixture.Sign(package))); Assert.Contains("Host API", exception.Message); } [Fact] public async Task Staged_package_can_be_marked_for_restart_activation() { using var fixture = new PackageFixture(); var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; await using var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var service = new PluginPackageService( db, new BuiltInPluginCatalog(), fixture.Validator, fixture.Options, fixture.Environment); var package = fixture.CreatePackage(); var staged = await service.StageAsync( package, fixture.Sign(package), "admin-user"); var pending = await service.RequestActivationAsync(staged.Id); Assert.NotNull(pending); Assert.Equal(PluginPackageStatus.PendingActivation, pending.Status); Assert.Single(Directory.GetFiles( Path.Combine(fixture.Root, "active"), "*.json")); } [Fact] public async Task Plugin_migration_is_transactionally_recorded_once() { var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; await using var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var runner = new PluginMigrationRunner( db, [new TestMigration()], NullLogger.Instance); await runner.ApplyAsync(); await runner.ApplyAsync(); Assert.Single(await db.PluginMigrationHistory.ToListAsync()); var tableExists = await db.Database.SqlQueryRaw( "SELECT COUNT(*) AS Value FROM sqlite_master WHERE type = 'table' AND name = 'PluginTestData'") .SingleAsync(); Assert.Equal(1, tableExists); } [Fact] public async Task Activated_signed_package_is_loaded_on_next_startup() { using var fixture = new PackageFixture(); var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; await using var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var service = new PluginPackageService( db, new BuiltInPluginCatalog(), fixture.Validator, fixture.Options, fixture.Environment); var package = fixture.CreatePackage(useRealAssembly: true); var staged = await service.StageAsync(package, fixture.Sign(package), "admin"); await service.RequestActivationAsync(staged.Id); var services = new ServiceCollection(); var result = PluginStartupLoader.LoadAndConfigure( services, new ConfigurationBuilder().Build(), fixture.Environment, fixture.Options); var loaded = Assert.Single(result.State.Plugins); Assert.True(loaded.Loaded, loaded.Error); Assert.Equal("sample.hello", loaded.PluginId); Assert.Single(result.Assemblies); } private sealed class TestMigration : IPluginDatabaseMigration { public string PluginId => "sample.hello"; public string MigrationId => "001"; public IReadOnlyList GetStatements(PluginDatabaseProvider provider) => ["CREATE TABLE PluginTestData (Id TEXT NOT NULL PRIMARY KEY);"]; } private sealed class PackageFixture : IDisposable { private readonly RSA rsa = RSA.Create(3072); public PackageFixture() { Root = Path.Combine(Path.GetTempPath(), $"jiaowu-plugin-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(Root); var publicKeyPath = Path.Combine(Root, "publisher.pem"); File.WriteAllText(publicKeyPath, rsa.ExportRSAPublicKeyPem()); Options = new PluginPlatformOptions { StoragePath = Root, TrustedPublishers = [ new TrustedPluginPublisher { Id = "school-it", PublicKeyPath = publicKeyPath } ] }; Environment = new TestEnvironment { ContentRootPath = Root }; Validator = new PluginPackageValidator(Options, Environment); } public string Root { get; } public PluginPlatformOptions Options { get; } public TestEnvironment Environment { get; } public PluginPackageValidator Validator { get; } public byte[] CreatePackage( string hostApiVersion = PluginHostContract.ApiVersion, bool useRealAssembly = false) { var manifest = new PluginManifest { Id = "sample.hello", Name = "示例插件", Description = "签名包测试", Version = "1.0.0", PublisherId = "school-it", HostApiVersion = hostApiVersion, BackendAssembly = "backend/Sample.dll", ModuleType = useRealAssembly ? "Jiaowu.SamplePlugin.SamplePluginModule" : "Sample.PluginModule", Capabilities = ["sample.read"] }; using var buffer = new MemoryStream(); using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) { var manifestEntry = archive.CreateEntry("plugin.json"); using (var writer = new StreamWriter(manifestEntry.Open())) writer.Write(JsonSerializer.Serialize(manifest)); var assemblyEntry = archive.CreateEntry("backend/Sample.dll"); using var assembly = assemblyEntry.Open(); if (useRealAssembly) { var bytes = File.ReadAllBytes( typeof(Jiaowu.SamplePlugin.SamplePluginModule).Assembly.Location); assembly.Write(bytes); } else { assembly.Write([0x4D, 0x5A, 0x00, 0x00]); } } return buffer.ToArray(); } public byte[] Sign(byte[] package) => rsa.SignData( package, HashAlgorithmName.SHA256, RSASignaturePadding.Pss); public void Dispose() { rsa.Dispose(); if (Directory.Exists(Root)) Directory.Delete(Root, recursive: true); } } private sealed class TestEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Development; public string ApplicationName { get; set; } = "Jiaowu.Api.Tests"; public string ContentRootPath { get; set; } = ""; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } }