Files
SocialPub/PrivaPub.Tests/Http/ServerSetupTests.cs
T
thepraandClaude Opus 5.5 8156c1927c
Build / Build (push) Successful in 10m49s
Deploy / privapub.thepra.dev (push) Successful in 14m0s
First-time setup: the first login made while no administrator exists becomes it
Sign-up stays closed (invitations only), so a new server's first login could only be made over SSH with admin
create-root, and an invitation needs a group, which needs a login. While no administrator exists, GET
/clientapi/user/setup says so and POST takes a SetupForm (login, password, first persona): the login is made the
administrator with its persona, and setup closes for good (owner decision 2026-10-16: first come, first served, no setup
code).

ServerSetup is one document with a fixed id, so the first request to insert it wins and two setups at once make one
administrator. A setup that fails before its login is made gives the claim back; one that named an administrator never
does, whoever is demoted or deleted later, and the CLI stays the way back. admin promote and create-root --admin seal it
too, migration _018 seals a server that already had an administrator, and no backup holds it nor does a restore drop it,
since a restore tombstones an administrator made after the backup. Public sign-up, NodeInfo and the instance API are
unchanged. RootUser.SetAdmin is the one place an administrator is made (with isModerator, which IsAdminPolicy asks for),
and the invitation endpoints share InsertPersona with setup.

Tests: ServerSetupTests on the invitations-only host (setup, the race, failed setups, sign-up still closed, the CLI's
seal) and the migration; the whole suite passes (962). In the pasture, decePub's setup and invitation pages pass end to
end.

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

157 lines
6.1 KiB
C#

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli;
using PrivaPub.Models;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Net.Http.Headers;
namespace PrivaPub.Tests.Http
{
// Setup is server-wide, so each test opens it again by removing the seal, and seals it once done.
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public sealed class ServerSetupTests : IAsyncLifetime
{
PrivaPubHost _host;
WebApplicationFactory<Program> _closed;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
// production's mode: sign-up closed, invitations only
_closed = _host.WithWebHostBuilder(builder => builder.UseSetting("Registrations:Mode", nameof(RegistrationMode.Invitations)));
await DB.Default.DeleteAsync<ServerSetup>(ServerSetup.Name);
}
public async ValueTask DisposeAsync()
{
if (_closed == default)
return;
await ServerSetup.Seal("tests", default);
await _closed.DisposeAsync();
}
HttpClient Client(string jwt = default)
{
var client = _closed.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(PrivaPubHost.Base + "/"), AllowAutoRedirect = false });
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
if (jwt != default)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
return client;
}
static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20];
static object Form(string userName, string avatarUserName, string avatarName = default) =>
new { userName, password = Accounts.Password, avatarUserName, avatarName };
async Task<bool> Pending()
{
using var client = Client();
return (await client.Get("/clientapi/user/setup")).Ok().Body.Flag("isPending");
}
static async Task<string> Message(HttpResponseMessage response)
{
var body = await response.JsonBody();
return body["errorMessage"]?.GetValue<string>() ?? body["title"]?.GetValue<string>();
}
[Fact]
public async Task Setup_makes_the_first_login_the_administrator_with_its_persona_and_closes()
{
var token = TestContext.Current.CancellationToken;
var userName = Name("first");
var avatarUserName = Name("founder");
Assert.True(await Pending());
using var client = Client();
var response = await client.PostJson("/clientapi/user/setup", Form(userName, avatarUserName, "The Founder"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var jwt = await response.JsonBody();
var policies = jwt["policies"]!.AsArray().Select(p => p!.GetValue<string>()).Order();
Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), policies);
using var admin = Client(jwt["token"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/clientapi/admin/domainblocks/list", token)).StatusCode);
var persona = Assert.Single(await (await admin.GetAsync("/clientapi/avatar/private/list", token)).JsonItems())!;
Assert.Equal(avatarUserName, persona["userName"]!.GetValue<string>());
Assert.Equal("The Founder", persona["name"]!.GetValue<string>());
var seal = await DB.Default.Find<ServerSetup>().OneAsync(ServerSetup.Name, token);
Assert.Equal(jwt["userId"]!.GetValue<string>(), seal.AdminId);
Assert.False(await Pending());
var again = await client.PostJson("/clientapi/user/setup", Form(Name("second"), Name("latecomer")));
Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode);
Assert.Contains("already set up", await Message(again));
}
[Fact]
public async Task Two_setups_at_once_make_one_administrator()
{
using var first = Client();
using var second = Client();
var answers = await Task.WhenAll(
first.PostJson("/clientapi/user/setup", Form(Name("racea"), Name("racera"))),
second.PostJson("/clientapi/user/setup", Form(Name("raceb"), Name("racerb"))));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Forbidden }, answers.Select(a => a.StatusCode).Order());
Assert.False(await Pending());
}
[Fact]
public async Task A_setup_that_fails_leaves_the_server_to_set_up()
{
var taken = await _host.SignUp("taken");
var userName = Name("same");
using var client = Client();
var samePersona = await client.PostJson("/clientapi/user/setup", Form(userName, userName));
var noPersona = await client.PostJson("/clientapi/user/setup", new { userName = Name("bare"), password = Accounts.Password });
var takenLogin = await client.PostJson("/clientapi/user/setup", Form(taken.UserName, Name("takenp")));
Assert.Equal(HttpStatusCode.BadRequest, samePersona.StatusCode);
Assert.Contains("must differ", await Message(samePersona));
Assert.Equal(HttpStatusCode.BadRequest, noPersona.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, takenLogin.StatusCode);
Assert.Contains("already taken", await Message(takenLogin));
Assert.True(await Pending());
}
[Fact]
public async Task While_setup_waits_sign_up_stays_closed()
{
using var client = Client();
var signUp = await client.PostJson("/clientapi/user/signup", new { userName = Name("outsider"), password = Accounts.Password });
Assert.Equal(HttpStatusCode.Forbidden, signUp.StatusCode);
Assert.True(await Pending());
Assert.False((await client.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
}
[Fact]
public async Task Promoting_an_administrator_with_the_cli_closes_setup()
{
var root = await _host.SignUp("promoted");
Assert.True(await Pending());
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services));
Assert.False(await Pending());
Assert.Equal(root.Id, (await DB.Default.Find<ServerSetup>().OneAsync(ServerSetup.Name, TestContext.Current.CancellationToken)).AdminId);
}
}
}