主要成果:
- 新增独立插件 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
234 lines
8.5 KiB
C#
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();
|
|
}
|
|
}
|