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>
132 lines
6.7 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|