Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/PluginPackageTests.cs
T
biss 05683e502a 第二阶段已完成:统一插件平台已从“内置功能开关”扩展为可安装、签名校验、版本激活和回滚的插件系统。
主要成果:
- 新增独立插件 SDK:[PluginContracts.cs (line 11)](E:/jiaowu/src/Jiaowu.Plugin.Abstractions/PluginContracts.cs:11)
- 支持 RSA-PSS/SHA-256 签名插件包、Host API 兼容性和 ZIP 安全检查:[PluginPackageValidator.cs (line 14)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageValidator.cs:14)
- 支持暂存、待激活、重启装载、失败记录、版本回滚:[PluginPackageService.cs (line 32)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageService.cs:32)
- 插件可注册控制器、服务、数据库迁移、后台任务和事件处理器
- 外部插件 API 统一使用 /api/plugin-extensions/{pluginId},并受启用状态中间件控制
- 插件中心新增 ZIP/SIG 上传、状态展示、激活、回滚和删除功能:[PluginsView.vue (line 212)](E:/jiaowu/web/src/views/PluginsView.vue:212)
- 提供可运行示例插件:[SamplePlugin.cs (line 10)](E:/jiaowu/samples/Jiaowu.SamplePlugin/SamplePlugin.cs:10)
- 提供打包签名脚本:[package-plugin.ps1](E:/jiaowu/scripts/package-plugin.ps1)
- 完整开发文档:[plugin-sdk.md (line 1)](E:/jiaowu/docs/plugin-sdk.md:1)
- 系统版本已调整为 3.0.0-beta1
2026-10-01 10:22:46 +08:00

234 lines
8.5 KiB
C#

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<PluginPackageException>(() =>
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<PluginPackageException>(() =>
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<AppDbContext>()
.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<AppDbContext>()
.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<PluginMigrationRunner>.Instance);
await runner.ApplyAsync();
await runner.ApplyAsync();
Assert.Single(await db.PluginMigrationHistory.ToListAsync());
var tableExists = await db.Database.SqlQueryRaw<int>(
"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<AppDbContext>()
.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<string> 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();
}
}