Files
SocialPub/PrivaPub.Tests/Http/ClientApiPersonasTests.cs
T
thepraandClaude Opus 5.5 5f56681c01
Build / Build (push) Successful in 5m1s
Deploy / privapub.thepra.dev (push) Successful in 5m39s
Everything on, phase 1: geolocation fetches itself, the deploy signs in as @thepra, the crawler is on, sign-up by invitation
Owner decisions (2026-10-04, recorded in docs/ROADMAP.md): production runs everything that is built, and nothing waits
on a person running a command.

- Geolocation updates itself. GeoUpdater, a hosted service, checks daily whether each DB-IP Lite database was built this
  month. If not, it fetches this month's, or last month's early in the month. It installs a file only once it opens as
  the right kind of database, then swaps it in atomically, and the locator reloads at once. Lookups now run under the
  lock, so a reload can no longer dispose a reader mid-lookup. The systemd timer, its script and their setup.sh lines
  are gone: the root step they needed never happened, and none is needed now. /stargazing names the database in use.
- The admin CLI runs after the app is built, with every service and nothing started.
  - `create-root <login> [--admin]` takes the password on stdin; it is how the first login is made while sign-up is
    closed.
  - `smoke <persona>` keeps the root `deploy-smoke` and an undiscoverable persona, and gives the root a new password
    on every run.
- The deploy signs in as @thepra. It runs the CLI, gets a token through the real OAuth flow (tools/smoke/oauth.sh,
  moved out of the pasture's privapub_token, which now uses it), checks the signed-in API and that @thepra is
  undiscoverable, then revokes the token. PRIVAPUB_SMOKE_TOKEN is gone.
- The deploy also fails when:
  - NodeInfo and the instance API disagree about registrations;
  - /stargazing does not say the crawler is on;
  - the geolocation databases are missing or more than 40 days old.
- The crawler is on in production, seeded with ten large servers of different kinds. FEDERATION.md now describes it
  and how to opt out.
- One registrations switch (Registrations:Mode, default Invitations; Open in tests and the pasture). It is read by
  open sign-up (403 when closed), NodeInfo `openRegistrations`, and v1 and v2 of the instance API, so they can no longer
  disagree. Before, NodeInfo said open and the instance API said closed. Group invitations always work, so
  invites_enabled is true.
- A persona edit through /clientapi no longer resets what the Mastodon API set (discoverable, locked, quote policy…):
  the theme is merged into the settings instead of replacing them.

650 tests pass. The deploy's smoke step was rehearsed against the pasture's PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 02:37:38 +02:00

233 lines
10 KiB
C#

using MongoDB.Bson;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class ClientApiPersonasTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20];
async Task<HttpResponseMessage> Insert(Root root, object body)
{
using var client = _host.As(root.Jwt);
return await client.PostJson("/clientapi/avatar/private/insert", body);
}
async Task<List<string>> Listed(Root root)
{
using var client = _host.As(root.Jwt);
return (await (await client.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())
.Select(a => a!["id"]!.GetValue<string>()).ToList();
}
static DateTime IdDay(string id) => ObjectId.Parse(id).CreationTime;
static void WithinTwoWeeksBefore(DateTime created, DateTime published, string id)
{
Assert.Equal(published.Date, published);
Assert.InRange(published, created.Date.AddDays(-13), DateTime.UtcNow.Date);
Assert.Equal(published, IdDay(id));
}
[Fact]
public async Task A_root_id_in_the_body_is_ignored()
{
var root = await _host.SignUp("owner");
var other = await _host.SignUp("victim");
var userName = Name("planted");
var response = await Insert(root, new { userName, name = "planted", biography = "testing", rootId = other.Id });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var id = (await response.JsonBody())["id"]!.GetValue<string>();
var link = await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(root.Id, link.RootId);
Assert.Contains(id, await Listed(root));
Assert.DoesNotContain(id, await Listed(other));
Assert.DoesNotContain(root.Id, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("Upper")]
[InlineData("has-dash")]
[InlineData("dot.ted")]
[InlineData("spa ce")]
[InlineData("ünïcode")]
[InlineData("at@sign")]
[InlineData("")]
public async Task Usernames_outside_the_regex_are_refused(string userName)
{
var root = await _host.SignUp("regex");
var response = await Insert(root, new { userName, name = "regex", biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains((await response.JsonBody())["errors"]!.AsObject(), e => e.Key.Equals("userName", StringComparison.OrdinalIgnoreCase));
Assert.Empty(await Listed(root));
if (userName.Length > 0)
Assert.False(await DB.Default.Find<ReservedName>().Match(r => r.Name == userName || r.Name == userName.ToLowerInvariant()).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("admin")]
[InlineData("privapub")]
[InlineData("root")]
[InlineData("moderator")]
[InlineData("abuse")]
public async Task Reserved_names_are_refused(string userName)
{
var root = await _host.SignUp("reserved");
var response = await Insert(root, new { userName, name = userName, biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue<string>());
Assert.Empty(await Listed(root));
}
[Fact]
public async Task Personas_and_groups_share_one_name_space()
{
var root = await _host.SignUp("names");
var persona = await _host.Persona(root, "names");
var group = await _host.Group(persona, community: true);
var groupName = group["userName"]!.GetValue<string>();
using var client = _host.As(root.Jwt);
var personaOverGroup = await Insert(root, new { userName = groupName, name = "copy", biography = "testing" });
var groupOverPersona = await client.PostJson("/clientapi/group/insert", new { avatarId = persona.Id, userName = persona.UserName, name = "copy", isCommunity = true });
var personaOverPersona = await Insert(await _host.SignUp("names"), new { userName = persona.UserName, name = "copy", biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, personaOverGroup.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, groupOverPersona.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, personaOverPersona.StatusCode);
var reserved = await DB.Default.Find<ReservedName>().Match(r => r.Name == groupName || r.Name == persona.UserName).ExecuteAsync(TestContext.Current.CancellationToken);
Assert.Equal(2, reserved.Count);
Assert.Contains(reserved, r => r.Name == persona.UserName && r.OwnerKind == LocalActorKind.Person && r.OwnerId == persona.Id);
Assert.Contains(reserved, r => r.Name == groupName && r.OwnerKind == LocalActorKind.Group && r.OwnerId == group["id"]!.GetValue<string>());
Assert.Equal(1, await DB.Default.CountAsync<Avatar>(a => a.UserName == persona.UserName, TestContext.Current.CancellationToken));
Assert.Equal(1, await DB.Default.CountAsync<GroupEntity>(g => g.UserName == groupName, TestContext.Current.CancellationToken));
}
[Fact]
public async Task An_update_is_delivered_to_followers()
{
await using var peer = await Peer.Start();
var persona = await _host.Persona(await _host.SignUp("update"), "update");
var follower = new RemoteActor(peer, "fan");
await _host.Follow(follower, peer.A, persona.UserName);
var since = DateTime.UtcNow.AddSeconds(-1);
using var client = _host.As(persona.Root.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Renamed", biography = "new biography" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var update = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, TestContext.Current.CancellationToken), d => d["type"]!.GetValue<string>() == "Update");
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", update["actor"]!.GetValue<string>());
var person = update["object"]!.AsObject();
Assert.Equal("Person", person["type"]!.GetValue<string>());
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", person["id"]!.GetValue<string>());
Assert.Equal("Renamed", person["name"]!.GetValue<string>());
Assert.DoesNotContain(persona.Root.Id, update.ToJsonString());
Assert.DoesNotContain(persona.Root.UserName, update.ToJsonString());
}
[Fact]
public async Task A_theme_change_here_keeps_what_the_mastodon_api_set()
{
var persona = await _host.Persona(await _host.SignUp("keep"), "keep");
await DB.Default.Update<Avatar>().MatchID(persona.Id)
.Modify(a => a.Settings.IsDiscoverable, false)
.Modify(a => a.Settings.IsLocked, true)
.Modify(a => a.Settings.QuotePolicy, QuotePolicies.Nobody)
.ExecuteAsync(TestContext.Current.CancellationToken);
using var client = _host.As(persona.Root.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new
{
avatarId = persona.Id,
name = "Kept",
biography = "same settings",
settings = new { isDefault = false, darkThemeIndexColour = 100, themeIsDarkMode = true }
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var settings = (await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Settings;
Assert.True(settings.ThemeIsDarkMode);
Assert.Equal(100, settings.DarkThemeIndexColour);
Assert.False(settings.IsDiscoverable);
Assert.True(settings.IsLocked);
Assert.Equal(QuotePolicies.Nobody, settings.QuotePolicy);
}
[Fact]
public async Task Updating_another_roots_persona_is_refused()
{
var persona = await _host.Persona(await _host.SignUp("mine"), "mine");
var intruder = await _host.SignUp("intruder");
using var client = _host.As(intruder.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Hijacked", biography = "testing" });
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.NotEqual("Hijacked", (await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Name);
}
[Fact]
public async Task Published_days_and_ids_fall_within_two_weeks_before_creation()
{
var created = DateTime.UtcNow;
var persona = await _host.Persona(await _host.SignUp("published"), "published");
var group = await _host.Group(persona, community: true);
using var client = _host.Client();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{persona.UserName}");
request.Headers.Accept.ParseAdd("application/activity+json");
var avatar = await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
var stored = await DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
var actor = JsonNode.Parse(await (await client.SendAsync(request, TestContext.Current.CancellationToken)).Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!;
WithinTwoWeeksBefore(created, avatar.PublishedOn, avatar.ID);
WithinTwoWeeksBefore(created, stored.PublishedOn, stored.ID);
var published = DateTime.Parse(actor["published"]!.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
Assert.Equal(avatar.PublishedOn, published);
Assert.DoesNotContain(avatar.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture), actor.ToJsonString());
}
[Fact]
public async Task The_list_holds_only_the_roots_own_personas()
{
var root = await _host.SignUp("list");
var other = await _host.SignUp("list");
var first = await _host.Persona(root, "first");
var second = await _host.Persona(root, "second");
var theirs = await _host.Persona(other, "theirs");
Assert.Equal(new[] { first.Id, second.Id }.Order(), (await Listed(root)).Order());
Assert.Equal(new[] { theirs.Id }, await Listed(other));
}
}
}