btcpayserver-plugin-builder/PluginBuilder.Tests/BTCPayCompatibilityTests.cs

405 lines
18 KiB
C#

using System.Net;
using System.Text;
using Dapper;
using Microsoft.AspNetCore.OutputCaching;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using PluginBuilder.APIModels;
using PluginBuilder.DataModels;
using PluginBuilder.Services;
using PluginBuilder.Util.Extensions;
using Xunit;
using Xunit.Abstractions;
namespace PluginBuilder.Tests;
public class BTCPayCompatibilityTests(ITestOutputHelper logs) : UnitTestBase(logs)
{
private static readonly JsonSerializerSettings SerializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
private sealed class MinOverrideRow
{
public int[] EffectiveMin { get; init; } = [];
public bool OverrideEnabled { get; init; }
}
private sealed class MaxOverrideRow
{
public int[]? EffectiveMax { get; init; }
public bool OverrideEnabled { get; init; }
}
[Fact]
public void ParseBTCPayCondition_DerivesMinAndMax()
{
var manifest = PluginManifest.Parse(CreateManifest(">= 1.2.3 && <= 2.0.0"), strictBTCPayVersionCondition: true);
Assert.Equal("1.2.3", manifest.BTCPayMinVersion.Version);
Assert.Equal("2.0.0", manifest.BTCPayMaxVersion.Version);
}
[Fact]
public void ParseBTCPayCondition_DerivesMinWithoutMax_AndAllowsExtraSpaces()
{
var manifest = PluginManifest.Parse(CreateManifest(" >= 1.2.3.4 "), strictBTCPayVersionCondition: true);
Assert.Equal("1.2.3.4", manifest.BTCPayMinVersion.Version);
Assert.Null(manifest.BTCPayMaxVersion);
}
[Theory]
[InlineData("< 2.0.0")]
[InlineData(">= 1.2.3 && < 2.0.0")]
[InlineData(">= 1.2.3 || <= 2.0.0")]
[InlineData(">= 1.2.3 && > 2.0.0")]
public void ParseBTCPayCondition_RejectsUnsupportedSyntax(string condition)
{
Assert.Throws<FormatException>(() => PluginManifest.Parse(CreateManifest(condition), strictBTCPayVersionCondition: true));
}
[Fact]
public void ParseBTCPayCondition_RejectsDecreasingRange()
{
Assert.Throws<FormatException>(() =>
PluginManifest.Parse(CreateManifest(">= 2.0.0 && <= 1.9.9"), strictBTCPayVersionCondition: true));
}
[Fact]
public async Task ApiCompatibilityFilters_NormalizePrereleaseHostVersions_AndKeepPluginVersionRoutesStrict()
{
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var ownerId = await tester.CreateFakeUserAsync();
var pluginSlug = "btcpay-rc-" + Guid.NewGuid().ToString("N")[..8];
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId, pluginSlug);
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
var buildRow = await conn.QuerySingleAsync<(string manifest_info, string build_info)>(
"SELECT manifest_info, build_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(buildRow.manifest_info);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, PluginVersion.Parse("2.3.7"), false);
await conn.SetPluginSettings(pluginSlug, new PluginSettings
{
PluginTitle = pluginSlug,
Description = "RC compatibility test plugin",
GitRepository = ServerTester.RepoUrl
}, PluginVisibilityEnum.Listed);
var identifier = manifest.Identifier;
var outputCacheStore = tester.GetService<IOutputCacheStore>();
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var client = tester.CreateHttpClient();
await AssertJsonResponseEquals(
client,
"/api/v1/plugins?btcpayVersion=2.3.7",
"/api/v1/plugins?btcpayVersion=2.3.7-rc2");
await AssertJsonResponseEquals(
client,
$"/api/v1/plugins/{identifier}?btcpayVersion=2.3.7",
$"/api/v1/plugins/{identifier}?btcpayVersion=2.3.7-rc2");
await AssertJsonResponseEquals(
client,
"/api/v1/plugins/updates?btcpayVersion=2.3.7",
"/api/v1/plugins/updates?btcpayVersion=2.3.7-rc2",
new[] { new InstalledPluginRequest(identifier, "0.0.1") });
var invalidHostVersion = await client.GetAsync("/api/v1/plugins?btcpayVersion=2.3.x-rc1");
Assert.Equal(HttpStatusCode.BadRequest, invalidHostVersion.StatusCode);
Assert.Contains("Invalid BTCPay version", await invalidHostVersion.Content.ReadAsStringAsync(), StringComparison.Ordinal);
var invalidPluginVersion = await client.GetAsync($"/api/v1/plugins/{pluginSlug}/versions/1.0.0-rc1");
Assert.Equal(HttpStatusCode.BadRequest, invalidPluginVersion.StatusCode);
Assert.Contains("Invalid plugin version", await invalidPluginVersion.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Fact]
public async Task PublicApi_FiltersByMaxVersion()
{
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var ownerId = await tester.CreateFakeUserAsync();
var pluginSlug = "btcpay-max-" + Guid.NewGuid().ToString("N")[..8];
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId, pluginSlug);
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
var buildRow = await conn.QuerySingleAsync<(string manifest_info, string build_info)>(
"SELECT manifest_info, build_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(buildRow.manifest_info);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, PluginVersion.Parse("2.0.0.0"), false);
await conn.SetPluginSettings(pluginSlug, new PluginSettings
{
PluginTitle = pluginSlug,
Description = "Compatibility test plugin",
GitRepository = ServerTester.RepoUrl
}, PluginVisibilityEnum.Listed);
var outputCacheStore = tester.GetService<IOutputCacheStore>();
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var client = tester.CreateHttpClient();
var compatible = await client.GetPublishedVersions("1.9.9.9", false);
var maxBoundary = await client.GetPublishedVersions("2.0.0.0", false);
var incompatible = await client.GetPublishedVersions("2.0.0.1", false);
var version = Assert.Single(compatible);
Assert.Equal(pluginSlug, version.ProjectSlug);
Assert.Equal(manifest.BTCPayMinVersion?.ToString(), version.BTCPayMinVersion);
Assert.Equal("2.0.0.0", version.BTCPayMaxVersion);
var dependencyCondition = ((JArray)version.ManifestInfo["Dependencies"]!)
.OfType<JObject>()
.Single(d => string.Equals(d["Identifier"]?.ToString(), "BTCPayServer", StringComparison.Ordinal))["Condition"]!
.ToString();
Assert.Equal($">={version.BTCPayMinVersion} && <={version.BTCPayMaxVersion}", dependencyCondition);
Assert.Single(maxBoundary);
Assert.Empty(incompatible);
}
[Fact]
public async Task PublicApi_FiltersByShortMaxVersionUsingZeroPaddedComparison()
{
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var ownerId = await tester.CreateFakeUserAsync();
var pluginSlug = "btcpay-short-max-" + Guid.NewGuid().ToString("N")[..8];
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId, pluginSlug);
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
var buildRow = await conn.QuerySingleAsync<(string manifest_info, string build_info)>(
"SELECT manifest_info, build_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(buildRow.manifest_info);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, PluginVersion.Parse("2.1"), false);
await conn.SetPluginSettings(pluginSlug, new PluginSettings
{
PluginTitle = pluginSlug,
Description = "Short max compatibility test plugin",
GitRepository = ServerTester.RepoUrl
}, PluginVisibilityEnum.Listed);
var outputCacheStore = tester.GetService<IOutputCacheStore>();
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var client = tester.CreateHttpClient();
var compatible = await client.GetPublishedVersions("2.1.0.0", false);
var incompatible = await client.GetPublishedVersions("2.1.0.1", false);
var version = Assert.Single(compatible);
Assert.Equal(pluginSlug, version.ProjectSlug);
Assert.Equal("2.1", version.BTCPayMaxVersion);
Assert.Empty(incompatible);
}
[Fact]
public async Task PublicApi_SelectsLatestCompatibleVersionAcrossBTCPayRanges()
{
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var ownerId = await tester.CreateFakeUserAsync();
var pluginSlug = "btcpay-range-" + Guid.NewGuid().ToString("N")[..8];
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId, pluginSlug);
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
var buildRow = await conn.QuerySingleAsync<(string manifest_info, string build_info)>(
"SELECT manifest_info, build_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(buildRow.manifest_info);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, PluginVersion.Parse("2.0.0.0"), false);
await conn.SetPluginSettings(pluginSlug, new PluginSettings
{
PluginTitle = pluginSlug,
Description = "Compatibility split test plugin",
GitRepository = ServerTester.RepoUrl
}, PluginVisibilityEnum.Listed);
await conn.ExecuteAsync(
"""
INSERT INTO versions (plugin_slug, ver, build_id, btcpay_min_ver, btcpay_max_ver, pre_release)
VALUES (@pluginSlug, @version, @buildId, @btcpayMinVer, NULL, FALSE)
""",
new
{
pluginSlug,
version = PluginVersion.Parse("1.0.3.0").VersionParts,
buildId = fullBuildId.BuildId,
btcpayMinVer = PluginVersion.Parse("2.0.0.0").VersionParts
});
var outputCacheStore = tester.GetService<IOutputCacheStore>();
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var client = tester.CreateHttpClient();
var legacy = await client.GetPublishedVersions("1.9.9.9", false);
var modern = await client.GetPublishedVersions("2.1.0.0", false);
Assert.Equal(manifest.Version.ToString(), Assert.Single(legacy).Version);
Assert.Equal("1.0.3.0", Assert.Single(modern).Version);
}
[Fact]
public async Task MinMaxOverridesPersistAcrossRebuild()
{
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var ownerId = await tester.CreateFakeUserAsync();
var pluginSlug = "btcpay-override-" + Guid.NewGuid().ToString("N")[..8];
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId, pluginSlug);
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
var buildRow = await conn.QuerySingleAsync<(string manifest_info, string build_info)>(
"SELECT manifest_info, build_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(buildRow.manifest_info);
Assert.Equal(1, await conn.ExecuteAsync(
"""
UPDATE versions
SET btcpay_min_ver = @overrideMin,
btcpay_min_ver_override_enabled = TRUE
WHERE plugin_slug = @pluginSlug AND ver = @version
""",
new
{
pluginSlug,
version = manifest.Version.VersionParts,
overrideMin = PluginVersion.Parse("2.0.0.0").VersionParts
}));
Assert.Equal(1, await conn.ExecuteAsync(
"""
UPDATE versions
SET btcpay_max_ver = @overrideMax,
btcpay_max_ver_override_enabled = TRUE
WHERE plugin_slug = @pluginSlug AND ver = @version
""",
new
{
pluginSlug,
version = manifest.Version.VersionParts,
overrideMax = PluginVersion.Parse("2.5.0.0").VersionParts
}));
var rebuildManifestJson = JObject.Parse(buildRow.manifest_info);
((JObject)rebuildManifestJson["Dependencies"]![0]!)["Condition"] = ">= 1.5.0 && <= 3.0.0";
var rebuildManifest = PluginManifest.Parse(rebuildManifestJson.ToString());
var rebuiltBuildId = await conn.NewBuild(new PluginSlug(pluginSlug), new PluginBuildParameters(ServerTester.RepoUrl)
{
GitRef = ServerTester.GitRef,
PluginDirectory = ServerTester.PluginDir,
BuildConfig = ServerTester.BuildCfg
});
var rebuiltFullBuildId = new FullBuildId(new PluginSlug(pluginSlug), rebuiltBuildId);
await conn.UpdateBuild(rebuiltFullBuildId, BuildStates.Uploaded, JObject.Parse(buildRow.build_info), rebuildManifest);
Assert.True(await conn.SetVersionBuild(rebuiltFullBuildId, rebuildManifest.Version, rebuildManifest.BTCPayMinVersion, rebuildManifest.BTCPayMaxVersion, true));
var minRow = await conn.QuerySingleAsync<MinOverrideRow>(
"""
SELECT btcpay_min_ver AS "EffectiveMin",
btcpay_min_ver_override_enabled AS "OverrideEnabled"
FROM versions
WHERE plugin_slug = @pluginSlug AND ver = @version
LIMIT 1
""",
new
{
pluginSlug,
version = manifest.Version.VersionParts
});
var maxRow = await conn.QuerySingleAsync<MaxOverrideRow>(
"""
SELECT btcpay_max_ver AS "EffectiveMax",
btcpay_max_ver_override_enabled AS "OverrideEnabled"
FROM versions
WHERE plugin_slug = @pluginSlug AND ver = @version
LIMIT 1
""",
new
{
pluginSlug,
version = manifest.Version.VersionParts
});
Assert.Equal(new[] { 2, 0, 0, 0 }, minRow.EffectiveMin);
Assert.True(minRow.OverrideEnabled);
Assert.Equal(new[] { 2, 5, 0, 0 }, maxRow.EffectiveMax);
Assert.True(maxRow.OverrideEnabled);
await conn.SetPluginSettings(pluginSlug, new PluginSettings
{
PluginTitle = pluginSlug,
Description = "Override persistence test plugin",
GitRepository = ServerTester.RepoUrl
}, PluginVisibilityEnum.Listed);
var outputCacheStore = tester.GetService<IOutputCacheStore>();
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var client = tester.CreateHttpClient();
Assert.Empty(await client.GetPublishedVersions("1.9.9.9", true));
Assert.Single(await client.GetPublishedVersions("2.0.0.0", true));
Assert.Single(await client.GetPublishedVersions("2.5.0.0", true));
Assert.Empty(await client.GetPublishedVersions("2.5.0.1", true));
}
private static string CreateManifest(string btcpayCondition)
{
return $$"""
{
"Identifier": "BTCPayServer.Plugins.TestCompat",
"Name": "Test Compat",
"Version": "1.0.0",
"Description": "Test plugin",
"Dependencies": [
{
"Identifier": "BTCPayServer",
"Condition": "{{btcpayCondition}}"
}
]
}
""";
}
private static async Task AssertJsonResponseEquals(HttpClient client, string expectedUrl, string actualUrl, object? body = null)
{
var expected = await SendAsync(client, expectedUrl, body);
var actual = await SendAsync(client, actualUrl, body);
Assert.Equal(HttpStatusCode.OK, expected.StatusCode);
Assert.Equal(HttpStatusCode.OK, actual.StatusCode);
var expectedJson = JToken.Parse(await expected.Content.ReadAsStringAsync());
var actualJson = JToken.Parse(await actual.Content.ReadAsStringAsync());
Assert.True(JToken.DeepEquals(expectedJson, actualJson), $"Expected JSON from '{actualUrl}' to match '{expectedUrl}'.");
}
private static Task<HttpResponseMessage> SendAsync(HttpClient client, string url, object? body)
{
if (body is null)
return client.GetAsync(url);
return client.PostAsync(url, JsonBody(body));
}
private static StringContent JsonBody(object body)
{
return new StringContent(JsonConvert.SerializeObject(body, SerializerSettings), Encoding.UTF8, "application/json");
}
}