Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
93 lines
4.6 KiB
C#
93 lines
4.6 KiB
C#
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.User;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
public class ContentRendererTests
|
|
{
|
|
const string Base = "https://privapub.test";
|
|
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
|
|
|
|
[Fact]
|
|
public async Task Decorates_local_and_remote_mentions_and_hashtags()
|
|
{
|
|
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
|
|
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
|
|
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
|
|
Assert.Contains("<strong>now</strong>", rendered.Html);
|
|
Assert.DoesNotContain("<script>", rendered.Html);
|
|
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
|
|
Assert.Equal("fedi", Assert.Single(rendered.Tags));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("`@alice` in code")]
|
|
[InlineData("mail me@alice.example")]
|
|
[InlineData("@nobody is unknown")]
|
|
[InlineData("see https://x.example/page#alice")]
|
|
public async Task Leaves_what_is_not_a_mention_alone(string text)
|
|
{
|
|
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Empty(rendered.Mentions);
|
|
Assert.DoesNotContain("h-card", rendered.Html);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("issue #123")]
|
|
[InlineData("it's fine")]
|
|
[InlineData("[link](https://x.example/#tag)")]
|
|
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
|
|
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
|
|
|
|
[Fact]
|
|
public async Task Renders_plain_text_like_mastodon()
|
|
{
|
|
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal("<p>line one<br>line <two></p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
|
|
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
|
|
}
|
|
|
|
sealed class StubLocalActors : ILocalActorService
|
|
{
|
|
public string BaseAddress => Base;
|
|
|
|
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
|
|
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
|
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
|
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
|
: default);
|
|
|
|
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
|
|
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
|
|
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
|
|
}
|
|
|
|
sealed class StubRemoteActors : IRemoteActorService
|
|
{
|
|
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
|
|
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
|
|
|
|
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
|
|
Task.FromResult(actorUri == "https://m.example/users/bob"
|
|
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
|
|
: default);
|
|
|
|
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
|
|
}
|
|
}
|
|
}
|