Files
thepraandClaude Opus 5.5 52d201eee9 A focal point is two finite numbers
float.TryParse takes "NaN" and "Infinity", and Math.Clamp keeps a NaN, so `focus=NaN,NaN` on an upload or a media PUT
was stored as given. Once the media was posted, every status and timeline response holding the post, and the Note
and its delivery, failed to serialise, for every viewer. FocalPoint.Parse takes finite numbers only (anything else is
ignored, as an unreadable focus was); the mapper and the renderer leave out a focus that isn't sound, and migration
_016 removes the ones stored before, from the uploads and from the copy each post keeps. What PrivaPub sends is
unchanged for any focus that could be sent before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:23:09 +02:00

251 lines
13 KiB
C#

using PrivaPub.Models.Media;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Infrastructure
{
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public class MigrationTests
{
[Fact]
public async Task A_focal_point_that_is_not_a_number_is_removed()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var broken = new MediaAttachment { OwnerAvatarId = "a1", Focus = [float.NaN, 0f] };
var sound = new MediaAttachment { OwnerAvatarId = "a1", Focus = [0.5f, -0.5f] };
var post = new Post
{
ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}",
Media = [new PostMedia { URL = "https://privapub.test/media/files/a.jpg", Focus = [float.PositiveInfinity, 0f] }, new PostMedia { URL = "https://privapub.test/media/files/b.jpg", Focus = [0.1f, 0.2f] }]
};
await DB.Default.SaveAsync(broken, token);
await DB.Default.SaveAsync(sound, token);
await DB.Default.SaveAsync(post, token);
await new _016_focal_points_are_finite().UpgradeAsync();
Assert.Null((await DB.Default.Find<MediaAttachment>().OneAsync(broken.ID, token)).Focus);
Assert.Equal(new[] { 0.5f, -0.5f }, (await DB.Default.Find<MediaAttachment>().OneAsync(sound.ID, token)).Focus);
var migrated = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Null(migrated.Media[0].Focus);
Assert.Equal(new[] { 0.1f, 0.2f }, migrated.Media[1].Focus);
}
[Fact]
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { remote, local }, token);
await new _002_sanitize_stored_content().UpgradeAsync();
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
Assert.Equal("<p>hi</p>", migratedRemote.Text);
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml);
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
}
[Fact]
public async Task Direct_posts_move_into_posts_with_their_conversation()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
await DB.Default.SaveAsync(dm, token);
await new _005_direct_posts_join_posts().UpgradeAsync();
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
Assert.Equal(PostVisibility.Direct, post.Visibility);
Assert.Equal("conversation1", post.ConversationId);
Assert.Equal("a1", post.AuthorAccountId);
}
[Fact]
public async Task Fetched_records_lose_the_signature_they_never_had_and_every_record_gets_its_extensions()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var fetched = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Fetched, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Algorithm = "rsa-sha256", SignedHeaders = new() { "host" }, ActivityContext = "\"https://www.w3.org/ns/activitystreams\"",
Raw = "{\"type\":\"Note\",\"quoteUrl\":\"https://q.example/1\"}"
};
var delivered = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Delivered, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Raw = "not json"
};
await DB.Default.SaveAsync(new[] { fetched, delivered }, token);
await new _008_fetched_records_lose_the_trigger_signature().UpgradeAsync();
await new _009_object_records_keep_their_extensions().UpgradeAsync();
var afterFetched = await DB.Default.Find<ObjectRecord>().OneAsync(fetched.ID, token);
var afterDelivered = await DB.Default.Find<ObjectRecord>().OneAsync(delivered.ID, token);
Assert.Null(afterFetched.KeyId);
Assert.Null(afterFetched.SignatureScheme);
Assert.Empty(afterFetched.SignedHeaders);
Assert.Null(afterFetched.ActivityContext);
Assert.Equal("https://x.example/u#k", afterDelivered.KeyId);
Assert.Contains("legacy-quote", afterFetched.Extensions);
Assert.Empty(afterDelivered.Extensions);
}
[Fact]
public async Task Conversations_get_their_participants_key_groups_become_circles_and_their_posts_stay_home()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var members = new List<GroupMember> { new() { AvatarId = "a1" }, new() { AvatarId = "https://r.example/u/b", IsForeign = true } };
var conversation = new DmGroup { Members = members };
var group = new GroupEntity { UserName = $"old{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community };
await DB.Default.SaveAsync(conversation, token);
await DB.Default.SaveAsync(group, token);
var localInGroup = new Post { GroupId = group.ID, Text = "inside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
var remoteInGroup = new Post { GroupId = group.ID, IsFederatedCopy = true, Text = "visiting", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var ungrouped = new Post { Text = "outside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { localInGroup, remoteInGroup, ungrouped }, token);
//the migration turns every group into a circle and keeps every local group post home: put the others back afterwards
var communities = (await DB.Default.Find<GroupEntity>().Match(g => g.Kind != GroupKind.Circle && g.ID != group.ID).ExecuteAsync(token)).Select(g => g.ID).ToList();
var federatedGroupPosts = (await DB.Default.Find<Post>().Match(p => p.GroupId != null && !p.IsFederatedCopy && !p.IsLocalOnly && p.ID != localInGroup.ID)
.ExecuteAsync(token)).Select(p => p.ID).ToList();
try
{
await new _003_conversation_keys_and_circles().UpgradeAsync();
Assert.Equal(DmGroup.KeyOf(members), (await DB.Default.Find<DmGroup>().OneAsync(conversation.ID, token)).ParticipantsKey);
Assert.Equal(GroupKind.Circle, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).Kind);
Assert.True((await DB.Default.Find<Post>().OneAsync(localInGroup.ID, token)).IsLocalOnly);
Assert.False((await DB.Default.Find<Post>().OneAsync(remoteInGroup.ID, token)).IsLocalOnly);
Assert.False((await DB.Default.Find<Post>().OneAsync(ungrouped.ID, token)).IsLocalOnly);
}
finally
{
if (communities.Count > 0)
await DB.Default.Update<GroupEntity>().Match(g => communities.Contains(g.ID)).Modify(g => g.Kind, GroupKind.Community).ExecuteAsync(token);
if (federatedGroupPosts.Count > 0)
await DB.Default.Update<Post>().Match(p => federatedGroupPosts.Contains(p.ID)).Modify(p => p.IsLocalOnly, false).ExecuteAsync(token);
}
}
[Fact]
public async Task Pending_deliveries_move_to_the_job_queue_once_and_the_rest_are_left_alone()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var host = $"r{Guid.NewGuid():N}.example";
var activityId = $"https://privapub.test/a/{Guid.NewGuid():N}";
var body = new JsonObject { ["id"] = activityId, ["type"] = "Create" }.ToJsonString();
var nextAttempt = DateTime.UtcNow.AddMinutes(7);
Delivery Legacy(string inbox, DateTime? deliveredAt = default, DateTime? abandonedAt = default) => new()
{
SignerId = "000000000000000000000001", SignerKind = LocalActorKind.Person, InboxURL = inbox, Body = body, Attempts = 3,
NextAttemptAt = nextAttempt, DeliveredAt = deliveredAt, AbandonedAt = abandonedAt
};
var pending = Legacy($"https://{host.ToUpperInvariant()}/inbox");
var twice = Legacy($"https://{host}/shared");
var already = Legacy($"https://{host}/shared");
var broken = Legacy("not an address");
var delivered = Legacy($"https://{host}/done", deliveredAt: DateTime.UtcNow.AddDays(-1));
var abandoned = Legacy($"https://{host}/given-up", abandonedAt: DateTime.UtcNow.AddDays(-1));
await DB.Default.SaveAsync(new[] { pending, twice, already, broken, delivered, abandoned }, token);
await new _004_pending_deliveries_become_jobs().UpgradeAsync();
var jobs = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(activityId + "|")).ExecuteAsync(token);
Assert.Equal(2, jobs.Count);
var job = Assert.Single(jobs, j => j.DedupeKey == $"{activityId}|{pending.InboxURL}");
Assert.Equal(host, job.Host);
Assert.Equal(3, job.Attempts);
Assert.InRange(job.RunAt, nextAttempt.AddSeconds(-1), nextAttempt.AddSeconds(1));
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
Assert.Equal((pending.SignerId, LocalActorKind.Person, pending.InboxURL, body), (payload.SignerId, payload.SignerKind, payload.Inbox, payload.Body));
Assert.Contains(jobs, j => j.DedupeKey == $"{activityId}|{twice.InboxURL}");
foreach (var moved in new[] { pending, twice, already, broken })
{
var after = await DB.Default.Find<Delivery>().OneAsync(moved.ID, token);
Assert.NotNull(after.AbandonedAt);
Assert.Equal("moved to the job queue", after.LastError);
}
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(delivered.ID, token)).AbandonedAt);
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(abandoned.ID, token)).LastError);
}
[Fact]
public async Task Year_one_edit_dates_become_no_edit_and_year_one_revisions_take_the_creation_date()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var created = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc);
var edited = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc);
var broken = new Post
{
IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = DateTime.MinValue, UpdateDate = DateTime.MinValue,
Revisions = new() { new PostRevision { ContentHtml = "<p>first</p>", EditedAt = DateTime.MinValue }, new PostRevision { ContentHtml = "<p>second</p>", EditedAt = edited } }
};
var fine = new Post { IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = edited, UpdateDate = edited };
await DB.Default.SaveAsync(new[] { broken, fine }, token);
await new _006_unedited_remote_posts_lose_year_one().UpgradeAsync();
var repaired = await DB.Default.Find<Post>().OneAsync(broken.ID, token);
Assert.Null(repaired.EditedAt);
Assert.Equal(created, repaired.UpdateDate);
Assert.Equal(new[] { created, edited }, repaired.Revisions.Select(r => r.EditedAt));
var untouched = await DB.Default.Find<Post>().OneAsync(fine.ID, token);
Assert.Equal((edited, edited), (untouched.EditedAt, untouched.UpdateDate));
}
[Fact]
public async Task Personas_and_groups_without_a_published_day_get_one_within_two_weeks_before_creation()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var created = new DateTime(2026, 6, 15, 18, 30, 0, DateTimeKind.Utc);
var avatar = new Avatar { UserName = $"aged{Guid.NewGuid():N}"[..20], CreatedAt = created };
var group = new GroupEntity { UserName = $"aged{Guid.NewGuid():N}"[..20], CreationDate = created };
var dated = new Avatar { UserName = $"dated{Guid.NewGuid():N}"[..20], CreatedAt = created, PublishedOn = new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc) };
await DB.Default.SaveAsync(new[] { avatar, dated }, token);
await DB.Default.SaveAsync(group, token);
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(b => b.Unset(a => a.PublishedOn)).ExecuteAsync(token);
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(b => b.Unset(g => g.PublishedOn)).ExecuteAsync(token);
Assert.False(await DB.Default.Find<Avatar>().Match(new BsonDocument("_id", ObjectId.Parse(avatar.ID)).Add(nameof(Avatar.PublishedOn), new BsonDocument("$exists", true))).ExecuteAnyAsync(token));
await new _007_personas_publish_a_spread_day().UpgradeAsync();
foreach (var published in new[] { (await DB.Default.Find<Avatar>().OneAsync(avatar.ID, token)).PublishedOn, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).PublishedOn })
{
Assert.Equal(TimeSpan.Zero, published.TimeOfDay);
Assert.InRange(published, created.Date.AddDays(-13), created.Date);
}
Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find<Avatar>().OneAsync(dated.ID, token)).PublishedOn);
}
}
}