btcpayserver-plugin-builder/PluginBuilder.Tests/ServerTester.cs
thgO.O 04a64d9178
Handle local loopback plugin downloads
Add an explicit local artifact download proxy flag for development and tests.

Keep the public download endpoint as a redirect while routing enabled loopback artifacts through the internal proxy.
2026-04-26 12:45:37 -03:00

270 lines
9.2 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Npgsql;
using PluginBuilder.Events;
using PluginBuilder.Services;
using PluginBuilder.Util.Extensions;
namespace PluginBuilder.Tests;
public class ServerTester : IAsyncDisposable
{
public const string RepoUrl = "https://github.com/NicolasDorier/btcpayserver";
public const string GitRef = "plugins/collection2";
public const string PluginDir = "Plugins/BTCPayServer.Plugins.RockstarStylist";
public const string BuildCfg = "Release";
public const string PluginSlug = "rockstar-stylist";
private const string StorageConnectionString =
"BlobEndpoint=http://127.0.0.1:32827/satoshi;AccountName=satoshi;AccountKey=Rxb41pUHRe+ibX5XS311tjXpjvu7mVi2xYJvtmq1j2jlUpN+fY/gkzyBMjqwzgj42geXGdYSbPEcu5i5wjSjPw==";
private readonly List<IAsyncDisposable> disposables = new();
private string? _dbname;
private string? _serverConnString;
private WebApplication? _WebApp;
public ServerTester(string testFolder, XUnitLogger logs)
{
TestFolder = testFolder;
Logs = logs;
}
public int Port { get; private set; }
public string TestFolder { get; }
public XUnitLogger Logs { get; }
public Action<IServiceCollection>? ConfigureServices { get; set; }
public Action<WebApplication>? ConfigureApplication { get; set; }
public WebApplication WebApp => _WebApp ?? throw new InvalidOperationException("Webapp not initialized");
public bool ReuseDatabase { get; set; } = true;
public bool CheatMode { get; set; }
public bool EnableLocalArtifactDownloadProxy { get; set; }
public async ValueTask DisposeAsync()
{
if (_WebApp != null)
{
await _WebApp.StopAsync();
await _WebApp.DisposeAsync();
_WebApp = null;
}
// If we are not reusing the database, drop the test database to keep environments clean
if (!ReuseDatabase && !string.IsNullOrEmpty(_dbname) && !string.IsNullOrEmpty(_serverConnString))
try
{
await DropDatabaseAsync();
Logs.LogInformation("Dropped test database {db}", _dbname);
}
catch (Exception ex)
{
Logs.LogInformation("Could not drop test database {db}: {message}", _dbname, ex.Message);
}
finally
{
_dbname = null;
_serverConnString = null;
}
foreach (var d in disposables)
await d.DisposeAsync();
}
public T GetService<T>() where T : notnull
{
return WebApp.Services.GetRequiredService<T>();
}
public async Task Start()
{
var baseName = TestFolder.ToLowerInvariant();
var dbName = ReuseDatabase
? baseName
: $"{baseName}_{Guid.NewGuid():N}".ToLowerInvariant();
Logs.LogInformation("DbName: {dbName}", dbName);
var connStr = "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=61932;Database=" + dbName;
// Track the database used for this test run to allow cleanup
_dbname = dbName;
var csb = new NpgsqlConnectionStringBuilder(connStr) { Database = "postgres" };
_serverConnString = csb.ToString();
Program host = new();
var projectDir = FindPluginBuilderDirectory();
var webappBuilder = host.CreateWebApplicationBuilder(new WebApplicationOptions
{
ContentRootPath = projectDir,
WebRootPath = Path.Combine(projectDir, "wwwroot"),
Args = [
"--urls=http://127.0.0.1:0",
$"--postgres={connStr}",
$"--storage_connection_string={StorageConnectionString}",
$"--cheat_mode={CheatMode.ToString().ToLowerInvariant()}",
$"--enable_local_artifact_download_proxy={EnableLocalArtifactDownloadProxy.ToString().ToLowerInvariant()}",
]
});
webappBuilder.Services.AddHttpClient();
webappBuilder.Logging.AddFilter(typeof(ProcessRunner).FullName, LogLevel.Error);
webappBuilder.Logging.AddProvider(Logs);
ConfigureServices?.Invoke(webappBuilder.Services);
var webapp = webappBuilder.Build();
host.Configure(webapp);
ConfigureApplication?.Invoke(webapp);
disposables.Add(webapp);
await webapp.StartAsync();
_WebApp = webapp;
// Extract the actual port Kestrel bound to
var address = webapp.Urls.First();
Port = new Uri(address).Port;
Logs.LogInformation("Server started on port {Port}", Port);
await using var conn = await GetService<DBConnectionFactory>().Open();
await conn.ReloadTypesAsync();
}
public HttpClient CreateHttpClient()
{
var client = GetService<IHttpClientFactory>().CreateClient();
client.BaseAddress = new Uri(WebApp.Urls.First(), UriKind.Absolute);
return client;
}
private string FindPluginBuilderDirectory()
{
var solutionDirectory = TryGetSolutionDirectoryInfo();
return Path.Combine(solutionDirectory.FullName, "PluginBuilder");
}
private DirectoryInfo TryGetSolutionDirectoryInfo()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !directory.GetFiles("btcpayserver-plugin-builder.sln").Any())
directory = directory.Parent;
if (directory == null)
throw new InvalidOperationException("Could not find the solution directory.");
return directory;
}
public async Task<FullBuildId> CreateAndBuildPluginAsync(
string userId,
string slug = PluginSlug,
string gitRef = GitRef,
string pluginDir = PluginDir)
{
await using var conn = await GetService<DBConnectionFactory>().Open();
var buildService = GetService<BuildService>();
await conn.NewPlugin(slug, userId);
var buildId = await conn.NewBuild(slug, new PluginBuildParameters(RepoUrl)
{
GitRef = gitRef,
PluginDirectory = pluginDir
});
var fullBuildId = new FullBuildId(slug, buildId);
await buildService.Build(fullBuildId);
return fullBuildId;
}
public async Task<string> CreateFakeUserAsync(string? email = null, string? password = "123456", bool confirmEmail = true, bool githubVerified = true)
{
using var scope = WebApp.Services.CreateScope();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
email ??= $"u{Guid.NewGuid():N}@a.com";
password ??= "123456";
var user = new IdentityUser
{
UserName = email,
Email = email,
EmailConfirmed = confirmEmail
};
var res = await userMgr.CreateAsync(user, password);
if (!res.Succeeded)
throw new InvalidOperationException("Failed to create test user: " + string.Join(", ", res.Errors.Select(e => e.Description)));
if (!githubVerified)
return user.Id;
await using var conn = await GetService<DBConnectionFactory>().Open();
await conn.VerifyGithubAccount(user.Id, "https://gist.github.com/dummy/123");
return user.Id;
}
private async Task DropDatabaseAsync()
{
if (string.IsNullOrEmpty(_serverConnString) || string.IsNullOrEmpty(_dbname))
return;
await using var conn = new NpgsqlConnection(_serverConnString);
await conn.OpenAsync();
// Terminate all remaining connections to the target DB (including idle ones)
await using (var terminate = new NpgsqlCommand(
"select pg_terminate_backend(pid) from pg_stat_activity where datname = @db and pid <> pg_backend_pid();",
conn))
{
terminate.Parameters.AddWithValue("db", _dbname);
await terminate.ExecuteNonQueryAsync();
}
var safeDb = _dbname.Replace("\"", "\"\"");
var dropSql = $"DROP DATABASE IF EXISTS \"{safeDb}\";";
await using (var drop = new NpgsqlCommand(dropSql, conn))
{
await drop.ExecuteNonQueryAsync();
}
}
public async Task<BuildStates> WaitForBuildToFinishAsync(
FullBuildId id,
TimeSpan? timeout = null)
{
timeout ??= TimeSpan.FromMinutes(5);
var agg = GetService<EventAggregator>();
var tcs = new TaskCompletionSource<BuildStates>(TaskCreationOptions.RunContinuationsAsynchronously);
IDisposable? sub = agg.Subscribe<BuildChanged>(e =>
{
if (!e.FullBuildId.Equals(id))
return;
var state = BuildStatesExtensions.FromEventName(e.EventName);
if (state.IsTerminal())
tcs.TrySetResult(state);
});
using var cts = new CancellationTokenSource(timeout.Value);
using var _ = cts.Token.Register(() => tcs.TrySetCanceled(cts.Token));
try
{
return await tcs.Task;
}
finally
{
sub?.Dispose();
}
}
}