2026-10-03 11:51:19 +02:00
|
|
|
using MongoDB.Bson;
|
|
|
|
|
using MongoDB.Driver;
|
2026-10-01 10:54:17 +02:00
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
2026-10-03 11:51:19 +02:00
|
|
|
using PrivaPub.Federation.Outbox;
|
2026-10-01 10:54:17 +02:00
|
|
|
using PrivaPub.Infrastructure.Data.Migrations;
|
2026-10-03 11:15:21 +02:00
|
|
|
using PrivaPub.Models.Federation;
|
2026-10-03 11:51:19 +02:00
|
|
|
using PrivaPub.Models.Group;
|
|
|
|
|
using PrivaPub.Models.Jobs;
|
2026-10-01 10:54:17 +02:00
|
|
|
using PrivaPub.Models.Post;
|
2026-10-03 11:51:19 +02:00
|
|
|
using PrivaPub.Models.User;
|
2026-10-01 10:54:17 +02:00
|
|
|
using PrivaPub.Tests.Support;
|
|
|
|
|
|
2026-10-03 11:51:19 +02:00
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
|
|
2026-10-01 10:54:17 +02:00
|
|
|
namespace PrivaPub.Tests.Infrastructure
|
|
|
|
|
{
|
2026-10-03 10:36:15 +02:00
|
|
|
[Xunit.Collection(nameof(Exclusive))]
|
2026-10-01 10:54:17 +02:00
|
|
|
[Trait("Category", "Integration")]
|
|
|
|
|
public class MigrationTests
|
|
|
|
|
{
|
|
|
|
|
[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> <b>raw</b></p>", migratedLocal.ContentHtml);
|
|
|
|
|
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
|
|
|
|
}
|
2026-10-01 11:17:14 +02:00
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
2026-10-03 11:15:21 +02:00
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
2026-10-03 11:51:19 +02:00
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
2026-10-01 10:54:17 +02:00
|
|
|
}
|
|
|
|
|
}
|