Files
decePubClient/tests/e2e/Login/SignUpTests.cs
T
thepraandClaude Opus 5.5 5796d62406
Build / Build (push) Successful in 52s
Deploy / decepub.thepra.dev (push) Successful in 3m3s
Setup and invitations: the first login made from the client, everyone else by a group's invitation
PrivaPub's sign-up is closed. While it has no administrator, Login offers /setup, where the first login is made with its
first persona and becomes the administrator (PrivaPub's /clientapi/user/setup; owner decision 2026-10-16: first come,
first served, and setup then closes for good). Everyone else joins at /join?code=: a group's invitation makes the login,
and its first persona joins the group (/clientapi/user/invitation/signup). Login links there, and a group's page shows
the link next to its code. A login without a persona is sent from Login to Settings instead of a dead end. Login, setup
and join share AuthService's StartSession, and all three go without credentials.

e2e (Login/SignUpTests, against the pasture): setup offered on Login, made, the feed reached with Administration, then a
signed-out reader is offered sign-in and invitations only; an invitation link makes a login whose persona joins the
circle. The phone sweep covers /setup and /join: login, setup and join fit every portrait phone, and AccountTests signs
in through the changed Login. The town's signed-in tests were not rerun: today's pasture came up without Mastodon, and
its town seed left stale sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:48 +02:00

132 lines
6.7 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Login
{
// The two ways in now that sign-up is closed: the server's first login, made from the client while it has no
// administrator, and a login made through a group's invitation
public sealed class SignUpTests(Browser browser)
{
const string Password = "Pasture-Pass-1!";
static async Task<bool> SetupPending()
{
using var http = Pasture.Http();
return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue<bool>();
}
static async Task<string> Jwt(string root, string password)
{
using var http = Pasture.Http();
using var login = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
login.EnsureSuccessStatusCode();
return (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
}
static async Task<JsonNode> Send(HttpMethod method, string path, string jwt, object body = null)
{
using var http = Pasture.Http();
using var request = new HttpRequestMessage(method, Pasture.Api + path) { Content = body is null ? null : JsonContent.Create(body) };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task The_first_login_sets_the_server_up_and_becomes_its_administrator()
{
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
var root = $"e2eadmin{stamp}";
var persona = $"e2efounder{stamp}";
// the pasture's administrator closed setup: it is opened for this test, and closed again whatever happens
await Pasture.Mongo("db.ServerSetup.deleteOne({_id: 'setup'})");
try
{
Assert.True(await SetupPending());
await using var session = await browser.Open();
var page = session.Page;
await Results.Cell("client.action.setup", "privapub", root, async () =>
{
await page.GotoAsync($"{Pasture.Site}/login");
await page.Locator("[data-setup-offer]").ClickAsync();
await page.Locator("input[data-setup-username]").FillAsync(root);
await page.Locator("input[data-setup-password]").FillAsync(Password);
await page.Locator("input[data-setup-repeat]").FillAsync(Password);
await page.Locator("input[data-setup-persona-username]").FillAsync(persona);
await page.Locator("input[data-setup-persona-name]").FillAsync("The e2e founder");
await page.Locator("[data-setup-submit]").ClickAsync();
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
await page.Locator("nav a[href='administration']").First.WaitForAsync(new() { Timeout = 15000 });
Assert.False(await SetupPending());
var jwt = await Jwt(root, Password);
var personas = (await Send(HttpMethod.Get, "/clientapi/avatar/private/list", jwt))!.AsArray();
Assert.Equal(persona, Assert.Single(personas)!["userName"]!.GetValue<string>());
await session.AssertGuards();
// set up: a reader without a login is offered sign-in and invitations only
await using var stranger = await browser.Open();
await stranger.Page.GotoAsync($"{Pasture.Site}/setup");
await stranger.Page.Locator("[data-setup-done]").WaitForAsync(new() { Timeout = 15000 });
await stranger.Page.GotoAsync($"{Pasture.Site}/login");
await stranger.Page.Locator("[data-join-offer]").WaitForAsync(new() { Timeout = 15000 });
Assert.Equal(0, await stranger.Page.Locator("[data-setup-offer]").CountAsync());
}, page);
}
finally
{
// the pasture's administrator stays pastureroot: the test's is demoted, and setup stays closed
await Pasture.Mongo($"db.RootUser.updateOne({{UserName: '{root}'}}, {{$set: {{Policies: ['isUser']}}}}); " +
"if (!db.ServerSetup.findOne({_id: 'setup'})) db.ServerSetup.insertOne({_id: 'setup', AdminId: 'e2e', SealedAt: new Date()})");
}
}
[Fact]
public async Task An_invitation_link_makes_a_login_whose_persona_joins_the_group()
{
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
// the circle's owner is a login of the test's own (the pasture's sign-up is open), so the town's sessions don't matter
var ownerJwt = default(string);
using (var http = Pasture.Http())
{
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = $"e2eowner{stamp}", password = Password });
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
ownerJwt = (await signup.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
}
var ownerId = (await Send(HttpMethod.Post, "/clientapi/avatar/private/insert", ownerJwt,
new { userName = $"e2ehost{stamp}", name = "An e2e host", biography = "for the invitation test" }))!["id"]!.GetValue<string>();
var group = await Send(HttpMethod.Post, "/clientapi/group/insert", ownerJwt,
new { avatarId = ownerId, userName = $"e2ecircle{stamp}", name = "An e2e circle", isCommunity = false });
var code = group!["invitationCode"]!.GetValue<string>();
var root = $"e2ejoin{stamp}";
var persona = $"e2eguest{stamp}";
await using var session = await browser.Open();
var page = session.Page;
await Results.Cell("client.action.invitation", "privapub", root, async () =>
{
await page.GotoAsync($"{Pasture.Site}/join?code={Uri.EscapeDataString(code)}");
Assert.Equal(code, await page.Locator("input[data-join-code]").InputValueAsync());
await page.Locator("input[data-join-username]").FillAsync(root);
await page.Locator("input[data-join-password]").FillAsync(Password);
await page.Locator("input[data-join-repeat]").FillAsync(Password);
await page.Locator("input[data-join-persona-username]").FillAsync(persona);
await page.Locator("[data-join-submit]").ClickAsync();
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
var members = (await Send(HttpMethod.Get,
$"/clientapi/group/members?avatarId={Uri.EscapeDataString(ownerId)}&groupId={Uri.EscapeDataString(group["id"]!.GetValue<string>())}", ownerJwt))!.AsArray();
Assert.Contains(members, m => (m!["handle"]?.GetValue<string>() ?? "").Contains(persona) || m["name"]?.GetValue<string>() == persona);
await session.AssertGuards();
}, page);
}
}
}