Skip to content

Commit 2ccf8c7

Browse files
authored
Merge pull request #83 from btcpayserver/feat/plugin-users-view
feat: add Owners page and Ownership actions
2 parents 2dd5006 + 056483e commit 2ccf8c7

26 files changed

Lines changed: 930 additions & 117 deletions

PluginBuilder.Tests/PlaywrightTester.cs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
using System.Linq;
33
using System.Security.Cryptography;
44
using System.Threading.Tasks;
5+
using Dapper;
56
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.DependencyInjection;
68
using Microsoft.Extensions.Logging;
79
using Microsoft.Playwright;
10+
using PluginBuilder.Services;
811
using Xunit;
912

1013
namespace PluginBuilder.Tests;
@@ -14,13 +17,13 @@ public class PlaywrightTester : IAsyncDisposable
1417
public ServerTester Server { get; set; }
1518
public Uri? ServerUri;
1619
public IBrowser? Browser { get; private set; }
17-
public IPage? Page { get; set; }
20+
public IPage? Page { get; set; }
1821
XUnitLogger Logger { get; }
1922
private string? CreatedUser;
2023
public string? Password { get; private set; }
2124
public bool IsAdmin { get; private set; }
2225

23-
26+
2427
public PlaywrightTester(XUnitLogger logger, ServerTester? server = null)
2528
{
2629
Logger = logger;
@@ -46,7 +49,7 @@ public async Task StartAsync()
4649
await GoToLogin();
4750
await AssertNoError();
4851
}
49-
52+
5053
public async ValueTask DisposeAsync()
5154
{
5255
await SafeDispose(async () => await Page?.CloseAsync()!);
@@ -59,7 +62,7 @@ private static async Task SafeDispose(Func<Task> action)
5962
try { if (action != null) await action(); }
6063
catch { /* ignore */ }
6164
}
62-
65+
6366
public async Task AssertNoError()
6467
{
6568
if (Page is null)
@@ -83,7 +86,7 @@ public async Task AssertNoError()
8386
var title = await Page.TitleAsync();
8487
Assert.DoesNotContain("Error", title, StringComparison.OrdinalIgnoreCase);
8588
}
86-
89+
8790
public async Task<IResponse?> GoToUrl(string uri)
8891
{
8992
var fullUrl = new Uri(ServerUri ?? throw new InvalidOperationException(), uri).ToString();
@@ -99,16 +102,16 @@ public async Task Logout()
99102
await Page?.Locator("#Nav-Account").ClickAsync()!;
100103
await Page.Locator("#Nav-Logout").ClickAsync();
101104
}
102-
103-
105+
106+
104107
public static string GetRandomUInt256()
105108
{
106109
var bytes = new byte[32];
107110
using var rng = RandomNumberGenerator.Create();
108111
rng.GetBytes(bytes);
109112
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
110113
}
111-
114+
112115
public async Task<string> RegisterNewUser(bool isAdmin = false)
113116
{
114117
var usr = GetRandomUInt256()[(64 - 20)..] + "@a.com";
@@ -132,4 +135,19 @@ public async Task LogIn(string user, string password = "123456")
132135
await Page.ClickAsync("#LoginButton");
133136
}
134137

138+
public async Task VerifyEmailAndGithubAsync(string email)
139+
{
140+
await using var scope = Server.WebApp.Services.CreateAsyncScope();
141+
var factory = scope.ServiceProvider.GetRequiredService<DBConnectionFactory>();
142+
await using var conn = await factory.Open();
143+
144+
await conn.ExecuteAsync(
145+
"""
146+
UPDATE "AspNetUsers"
147+
SET "EmailConfirmed" = TRUE,
148+
"GithubGistUrl" = 'https://gist.github.com/test-eligibility'
149+
WHERE "Email" = @Email;
150+
""",
151+
new { Email = email });
152+
}
135153
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using System.Text.RegularExpressions;
2+
using System.Threading.Tasks;
3+
using Microsoft.Playwright;
4+
using Microsoft.Playwright.Xunit;
5+
using Xunit;
6+
using Xunit.Abstractions;
7+
8+
namespace PluginBuilder.Tests.PluginTests;
9+
10+
[Collection("Playwright Tests")]
11+
public class OwnersUITests(ITestOutputHelper output) : PageTest
12+
{
13+
private readonly XUnitLogger _log = new("OwnersUITest", output);
14+
15+
[Fact]
16+
public async Task Ownership_Flow_Works()
17+
{
18+
await using var t = new PlaywrightTester(_log);
19+
t.Server.ReuseDatabase = false;
20+
await t.StartAsync();
21+
22+
await t.GoToUrl("/register");
23+
var userA = await t.RegisterNewUser();
24+
await Expect(t.Page!.Locator("body")).ToContainTextAsync("Builds");
25+
26+
var slug = "owners-" + PlaywrightTester.GetRandomUInt256()[..8];
27+
await t.GoToUrl("/plugins/create");
28+
await Expect(t.Page).ToHaveURLAsync(new Regex("/account/details$", RegexOptions.IgnoreCase));
29+
await Expect(t.Page.Locator(".alert-warning")).ToBeVisibleAsync();
30+
31+
await t.VerifyEmailAndGithubAsync(userA);
32+
await t.GoToUrl("/plugins/create");
33+
await t.Page!.FillAsync("#PluginSlug", slug);
34+
await t.Page.ClickAsync("#Create");
35+
await t.GoToUrl($"/plugins/{slug}/owners");
36+
await t.AssertNoError();
37+
await Expect(t.Page.Locator("table tbody tr")).ToContainTextAsync(userA);
38+
await Expect(t.Page.Locator("table tbody tr")).ToContainTextAsync("Primary Owner");
39+
40+
await t.Logout();
41+
await t.GoToUrl("/register");
42+
var userB = await t.RegisterNewUser();
43+
await Expect(t.Page.Locator("body")).ToContainTextAsync("Builds");
44+
await t.Logout();
45+
46+
await t.GoToLogin();
47+
await t.LogIn(userA);
48+
await t.GoToUrl($"/plugins/{slug}/owners");
49+
50+
var addForm = t.Page.Locator("form[method='post'] >> input[name='email']");
51+
52+
await addForm.FillAsync(userB);
53+
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
54+
await Expect(t.Page.Locator(".alert-warning")).ToBeVisibleAsync();
55+
56+
await t.VerifyEmailAndGithubAsync(userB);
57+
await addForm.FillAsync(userB);
58+
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
59+
60+
var bRow = t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB });
61+
await Expect(bRow).ToBeVisibleAsync();
62+
63+
var removeBtn = bRow.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Remove" });
64+
await removeBtn.ClickAsync();
65+
var confirmBtn = t.Page.Locator("#ConfirmContinue");
66+
await Expect(confirmBtn).ToBeVisibleAsync();
67+
await confirmBtn.ClickAsync();
68+
await Expect(t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB })).ToHaveCountAsync(0);
69+
70+
await addForm.FillAsync(userB);
71+
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
72+
bRow = t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB });
73+
await Expect(bRow).ToBeVisibleAsync();
74+
var transferBtn = bRow.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Transfer Primary" });
75+
await transferBtn.ClickAsync();
76+
await Expect(t.Page.Locator("#ConfirmContinue")).ToBeVisibleAsync();
77+
await t.Page.ClickAsync("#ConfirmContinue");
78+
await Expect(bRow).ToContainTextAsync("Primary Owner");
79+
await Expect(t.Page.Locator("form[method='post'] >> input[name='email']")).ToHaveCountAsync(0);
80+
81+
var aRow = t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userA });
82+
var leaveBtn = aRow.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Leave" });
83+
await leaveBtn.ClickAsync();
84+
await Task.WhenAll(
85+
t.Page.WaitForURLAsync(url => !url.EndsWith($"/plugins/{slug}/owners")),
86+
t.Page.ClickAsync("#ConfirmContinue")
87+
);
88+
89+
await Expect(t.Page.Locator(".alert-success"))
90+
.ToContainTextAsync(new Regex("(Owner removed|You have left)", RegexOptions.IgnoreCase));
91+
92+
await t.Logout();
93+
await t.GoToLogin();
94+
await t.LogIn(userB);
95+
96+
await t.GoToUrl($"/plugins/{slug}");
97+
await t.AssertNoError();
98+
await Expect(t.Page).ToHaveURLAsync(new Regex($"/plugins/{Regex.Escape(slug)}"));
99+
100+
var createNewBuildLink = t.Page.Locator("#CreateNewBuild");
101+
await Expect(createNewBuildLink).ToBeVisibleAsync();
102+
await createNewBuildLink.ClickAsync();
103+
104+
await Expect(t.Page).ToHaveURLAsync(new Regex($"/plugins/{Regex.Escape(slug)}/create$"));
105+
106+
await Expect(t.Page.Locator("#GitRepository")).ToBeVisibleAsync();
107+
await Expect(t.Page.Locator("#GitRef")).ToBeVisibleAsync();
108+
await Expect(t.Page.Locator("#PluginDirectory")).ToBeVisibleAsync();
109+
await Expect(t.Page.Locator("#BuildConfig")).ToBeVisibleAsync();
110+
await Expect(t.Page.Locator("#Create")).ToBeVisibleAsync();
111+
112+
await t.AssertNoError();
113+
}
114+
}

PluginBuilder.Tests/PublicTests/PublicDirectoryUITests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ public class PublicDirectoryUITests(ITestOutputHelper output) : PageTest
1919
public async Task PublicDirectory_RespectsPluginVisibility()
2020
{
2121
await using var tester = new PlaywrightTester(_log);
22+
tester.Server.ReuseDatabase = false;
2223
await tester.StartAsync();
2324

2425
var conn = await tester.Server.GetService<DBConnectionFactory>().Open();
2526

2627
const string slug = "rockstar-stylist";
27-
var fullBuildId = await tester.Server.CreateAndBuildPluginAsync();
28+
var ownerId = await tester.Server.CreateFakeUserAsync();
29+
var fullBuildId = await tester.Server.CreateAndBuildPluginAsync(ownerId);
2830

2931
var manifestInfoJson = await conn.QuerySingleAsync<string>(
3032
"SELECT manifest_info FROM builds WHERE plugin_slug = @PluginSlug AND id = @BuildId",

PluginBuilder.Tests/ServerTester.cs

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
using System.Net.Http;
66
using System.Threading.Tasks;
77
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Identity;
89
using Microsoft.Extensions.DependencyInjection;
910
using Microsoft.Extensions.Logging;
11+
using PluginBuilder.Controllers.Logic;
12+
using PluginBuilder.DataModels;
1013
using PluginBuilder.Services;
1114
using PluginBuilder.Util.Extensions;
1215

@@ -18,6 +21,12 @@ public class ServerTester : IAsyncDisposable
1821
private WebApplication? _WebApp;
1922
public int Port { get; set; } = Utils.FreeTcpPort();
2023

24+
public const string RepoUrl = "https://github.com/NicolasDorier/btcpayserver";
25+
public const string GitRef = "plugins/collection2";
26+
public const string PluginDir = "Plugins/BTCPayServer.Plugins.RockstarStylist";
27+
public const string BuildCfg = "Release";
28+
public const string PluginSlug = "rockstar-stylist";
29+
2130

2231
public ServerTester(string testFolder, XUnitLogger logs)
2332
{
@@ -43,7 +52,7 @@ public async ValueTask DisposeAsync()
4352
await _WebApp.DisposeAsync();
4453
_WebApp = null;
4554
}
46-
55+
4756
foreach (var d in disposables) await d.DisposeAsync();
4857
}
4958

@@ -65,7 +74,7 @@ public async Task Start()
6574
var projectDir = FindPluginBuilderDirectory();
6675
var webappBuilder = host.CreateWebApplicationBuilder(new WebApplicationOptions
6776
{
68-
ContentRootPath = projectDir,
77+
ContentRootPath = projectDir,
6978
WebRootPath = Path.Combine(projectDir, "wwwroot"),
7079
Args = [$"--urls=http://127.0.0.1:{Port}"]
7180
});
@@ -82,6 +91,9 @@ public async Task Start()
8291

8392
await using var conn = await GetService<DBConnectionFactory>().Open();
8493
await conn.ReloadTypesAsync();
94+
await conn.SettingsSetAsync(SettingsKeys.VerifiedGithub, "true");
95+
var verfCache = GetService<UserVerifiedCache>();
96+
await verfCache.RefreshAllUserVerifiedSettings(conn);
8597
}
8698

8799
public HttpClient CreateHttpClient()
@@ -90,7 +102,7 @@ public HttpClient CreateHttpClient()
90102
client.BaseAddress = new Uri(WebApp.Urls.First(), UriKind.Absolute);
91103
return client;
92104
}
93-
105+
94106
private string FindPluginBuilderDirectory()
95107
{
96108
var solutionDirectory = TryGetSolutionDirectoryInfo();
@@ -111,17 +123,18 @@ private DirectoryInfo TryGetSolutionDirectoryInfo()
111123

112124
return directory;
113125
}
114-
126+
115127
public async Task<FullBuildId> CreateAndBuildPluginAsync(
116-
string slug = "rockstar-stylist",
117-
string gitRef = "plugins/collection2",
118-
string pluginDir = "Plugins/BTCPayServer.Plugins.RockstarStylist")
128+
string userId,
129+
string slug = PluginSlug,
130+
string gitRef = GitRef,
131+
string pluginDir = PluginDir)
119132
{
120-
var conn = await GetService<DBConnectionFactory>().Open();
133+
await using var conn = await GetService<DBConnectionFactory>().Open();
121134
var buildService = GetService<BuildService>();
122135

123-
await conn.NewPlugin(slug);
124-
var buildId = await conn.NewBuild(slug, new PluginBuildParameters("https://github.com/NicolasDorier/btcpayserver")
136+
await conn.NewPlugin(slug, userId);
137+
var buildId = await conn.NewBuild(slug, new PluginBuildParameters(RepoUrl)
125138
{
126139
GitRef = gitRef,
127140
PluginDirectory = pluginDir
@@ -132,4 +145,27 @@ public async Task<FullBuildId> CreateAndBuildPluginAsync(
132145
return fullBuildId;
133146
}
134147

148+
public async Task<string> CreateFakeUserAsync(string? email = null, bool confirmEmail = true, bool githubVerified = true)
149+
{
150+
using var scope = WebApp.Services.CreateScope();
151+
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
152+
153+
email ??= $"u{Guid.NewGuid():N}@a.com";
154+
var user = new IdentityUser
155+
{
156+
UserName = email,
157+
Email = email,
158+
EmailConfirmed = confirmEmail
159+
};
160+
var res = await userMgr.CreateAsync(user, "Test1234!");
161+
if (!res.Succeeded)
162+
throw new InvalidOperationException("Failed to create test user: " + string.Join(", ", res.Errors.Select(e => e.Description)));
163+
164+
if (!githubVerified) return user.Id;
165+
166+
await using var conn = await GetService<DBConnectionFactory>().Open();
167+
await conn.VerifyGithubAccount(user.Id, "https://gist.github.com/dummy/123");
168+
169+
return user.Id;
170+
}
135171
}

PluginBuilder.Tests/UnitTest1.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ public async Task CanPackPlugin()
4343

4444
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
4545
//https://github.com/NicolasDorier/btcpayserver/tree/plugins/collection2/Plugins/BTCPayServer.Plugins.RockstarStylist
46-
var fullBuildId = await tester.CreateAndBuildPluginAsync();
46+
var ownerId = await tester.CreateFakeUserAsync();
47+
var fullBuildId = await tester.CreateAndBuildPluginAsync(ownerId);
4748

4849
var client = tester.CreateHttpClient();
4950
var versions = await client.GetPublishedVersions("1.4.6.0", true);
@@ -86,6 +87,7 @@ public async Task CanPackPlugin()
8687

8788
// Another plugin slug try to hijack the package
8889
await tester.CreateAndBuildPluginAsync(
90+
ownerId,
8991
slug: "rockstar-stylist-fake",
9092
gitRef: "plugins/collection2",
9193
pluginDir: "Plugins/BTCPayServer.Plugins.RockstarStylist"
@@ -107,7 +109,7 @@ await tester.CreateAndBuildPluginAsync(
107109
versions = await client.GetPublishedVersions("1.4.6.0", true, true);
108110
Assert.Equal("1.0.2.1", versions[0].Version);
109111
Assert.Equal("1.0.2.0", versions[1].Version);
110-
112+
111113
// listed - always render
112114
await conn.ExecuteAsync("UPDATE plugins SET visibility = 'listed' WHERE slug = 'rockstar-stylist'");
113115
var res = await client.GetPublishedVersions("2.1.0.0", false);
@@ -120,7 +122,7 @@ await tester.CreateAndBuildPluginAsync(
120122

121123
res = await client.GetPublishedVersions("2.1.0.0", false, searchPluginName: "rockstar");
122124
Assert.Contains(res, p => p.ProjectSlug == "rockstar-stylist");
123-
125+
124126
var raw = await client.GetStringAsync("/api/v1/plugins");
125127
var legacyRes = JsonConvert.DeserializeObject<PublishedVersion[]>(raw);
126128
Assert.Contains(legacyRes, p => p.ProjectSlug == "rockstar-stylist");

PluginBuilder/Components/MainNav/Default.cshtml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
<span>Settings</span>
2727
</a>
2828
</li>
29+
<li class="nav-item">
30+
<a asp-area="" asp-controller="Plugin" asp-action="Owners" asp-route-pluginSlug="@Model.PluginSlug"
31+
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.Owners)" id="StoreNav-Owners">
32+
<vc:icon symbol="users" />
33+
<span>Owners</span>
34+
</a>
35+
</li>
2936
</ul>
3037
</div>
3138
</div>

0 commit comments

Comments
 (0)