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>
This commit is contained in:
1 parent
4da6a398d7
commit
5796d62406
12 files changed
+630
-35
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user