From 6727e1817adeb835fe32719784d66771e4ba6de1 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 17 Oct 2026 00:28:33 +0200 Subject: [PATCH] No test project: PrivaPub.Tests goes, and neither CI nor the deploy runs tests Owner decision 2026-10-17: the tests took about 22 of a release's 25 minutes on the runner (9 in build.yml after a push, 12 more in deploy.yml on the tag, for the same commit). PrivaPub.Tests is deleted with tools/ci/with-test-mongod.sh; build.yml builds and deploy.yml publishes. CLAUDE.md's Testing section becomes Verifying (build, run locally, the pasture), and the invariants no longer name the tests that checked them. decePubClient's tests/e2e goes too, so the pasture's peers/decepub.sh serves a plain publish with the Pasture environment (decePubClient/publish/wwwroot). Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yml | 3 - .gitea/workflows/deploy.yml | 3 - CLAUDE.md | 52 +- PrivaPub.Tests/Api/MastodonParamsTests.cs | 82 -- PrivaPub.Tests/Api/PersonaSeparationTests.cs | 61 -- PrivaPub.Tests/Api/StatusContentTests.cs | 29 - PrivaPub.Tests/Domain/ContentRendererTests.cs | 95 -- PrivaPub.Tests/Domain/FollowTests.cs | 124 --- PrivaPub.Tests/Domain/LinkPreviewTests.cs | 43 - PrivaPub.Tests/Domain/MediaFlowTests.cs | 160 ---- PrivaPub.Tests/Domain/MediaProcessingTests.cs | 103 --- PrivaPub.Tests/Domain/NearbyTests.cs | 79 -- PrivaPub.Tests/Domain/OutboxTests.cs | 94 -- PrivaPub.Tests/Domain/RelationshipTests.cs | 146 ---- PrivaPub.Tests/Domain/TimelineTests.cs | 162 ---- .../Domain/VisibilityPolicyTests.cs | 42 - .../Federation/ActorFeaturesTests.cs | 50 -- .../Federation/ActorProfileTests.cs | 41 - PrivaPub.Tests/Federation/AuthorGoneTests.cs | 277 ------ PrivaPub.Tests/Federation/BackfillTests.cs | 78 -- .../Federation/ChatMessageSoftwareTests.cs | 75 -- .../Federation/CommunityEditsTests.cs | 223 ----- .../Federation/CommunityModerationTests.cs | 115 --- .../Federation/ContentSanitizerTests.cs | 51 -- PrivaPub.Tests/Federation/ContextTests.cs | 32 - .../Federation/ConversationContainerTests.cs | 142 --- .../Federation/DomainBlocksTests.cs | 37 - PrivaPub.Tests/Federation/FeaturedTests.cs | 143 --- PrivaPub.Tests/Federation/FlairTests.cs | 68 -- .../FollowersSynchronizationTests.cs | 282 ------ .../Federation/FormerRepresentationsTests.cs | 97 --- PrivaPub.Tests/Federation/ForwardedTests.cs | 167 ---- PrivaPub.Tests/Federation/GroupTests.cs | 301 ------- .../Federation/HttpSignaturesTests.cs | 162 ---- .../Federation/InboundBlockTests.cs | 77 -- .../Federation/InboundRoutingTests.cs | 271 ------ PrivaPub.Tests/Federation/InboxGapTests.cs | 611 ------------- .../Federation/InboxScenarioTests.cs | 360 -------- .../Federation/IntegrityProofTests.cs | 175 ---- .../Federation/InteractionPolicyTests.cs | 284 ------ PrivaPub.Tests/Federation/InteractionTests.cs | 225 ----- PrivaPub.Tests/Federation/JobHandlerTests.cs | 811 ------------------ PrivaPub.Tests/Federation/MoveTests.cs | 106 --- PrivaPub.Tests/Federation/NoteParserTests.cs | 220 ----- .../Federation/ObjectRecordsTests.cs | 36 - .../Federation/ObjectShapesTests.cs | 299 ------- .../Federation/ParticipationTests.cs | 100 --- PrivaPub.Tests/Federation/PollTests.cs | 138 --- PrivaPub.Tests/Federation/ProvenanceTests.cs | 72 -- PrivaPub.Tests/Federation/QuoteTests.cs | 221 ----- PrivaPub.Tests/Federation/ReactionTests.cs | 122 --- PrivaPub.Tests/Federation/RelayTests.cs | 143 --- .../Federation/RemoteActorServiceTests.cs | 254 ------ PrivaPub.Tests/Federation/RemoteEditsTests.cs | 38 - .../Federation/RemoteHandleTests.cs | 117 --- PrivaPub.Tests/Federation/RendererTests.cs | 103 --- PrivaPub.Tests/Federation/ReplyRelayTests.cs | 114 --- PrivaPub.Tests/Federation/ReusedIdTests.cs | 64 -- .../Federation/ServiceReportTests.cs | 252 ------ PrivaPub.Tests/Federation/SharedInboxTests.cs | 50 -- PrivaPub.Tests/Federation/WallTests.cs | 213 ----- .../followers-only-reply-one-tag.json | 1 - PrivaPub.Tests/Fixtures/pleroma/edited.json | 71 -- PrivaPub.Tests/Http/BackupEndpointTests.cs | 200 ----- PrivaPub.Tests/Http/ClientApiAccountsTests.cs | 492 ----------- PrivaPub.Tests/Http/ClientApiGroupsTests.cs | 302 ------- .../Http/ClientApiModerationTests.cs | 308 ------- PrivaPub.Tests/Http/ClientApiPersonasTests.cs | 232 ----- PrivaPub.Tests/Http/FederationGetTests.cs | 579 ------------- PrivaPub.Tests/Http/HostBootTests.cs | 146 ---- PrivaPub.Tests/Http/InboxRouteTests.cs | 205 ----- PrivaPub.Tests/Http/InstanceLocationTests.cs | 291 ------- PrivaPub.Tests/Http/MastodonAccountsTests.cs | 672 --------------- PrivaPub.Tests/Http/MastodonFeedTests.cs | 140 --- PrivaPub.Tests/Http/MastodonFiltersTests.cs | 169 ---- PrivaPub.Tests/Http/MastodonInstanceTests.cs | 131 --- PrivaPub.Tests/Http/MastodonListsTests.cs | 179 ---- PrivaPub.Tests/Http/MastodonMediaTests.cs | 504 ----------- .../Http/MastodonScheduledStatusesTests.cs | 158 ---- PrivaPub.Tests/Http/MastodonStatusesTests.cs | 728 ---------------- PrivaPub.Tests/Http/MastodonStreamingTests.cs | 165 ---- PrivaPub.Tests/Http/MastodonTagsTests.cs | 89 -- PrivaPub.Tests/Http/MastodonTimelinesTests.cs | 265 ------ PrivaPub.Tests/Http/MastodonTrendsTests.cs | 133 --- .../Http/MastodonWordSearchTests.cs | 135 --- PrivaPub.Tests/Http/MediaLifecycleTests.cs | 234 ----- PrivaPub.Tests/Http/MessageSignatureTests.cs | 103 --- PrivaPub.Tests/Http/NeverFiveHundredTests.cs | 127 --- .../Http/NotificationPolicyTests.cs | 119 --- PrivaPub.Tests/Http/OAuthTests.cs | 346 -------- PrivaPub.Tests/Http/OneAnswerTests.cs | 270 ------ PrivaPub.Tests/Http/PersonaArchiveTests.cs | 346 -------- PrivaPub.Tests/Http/PersonaExchangeTests.cs | 218 ----- .../Http/PersonaSeparationHttpTests.cs | 261 ------ PrivaPub.Tests/Http/PostPlaceTests.cs | 106 --- .../Http/ProvenanceAndReactionsTests.cs | 224 ----- PrivaPub.Tests/Http/RegistrationsTests.cs | 50 -- PrivaPub.Tests/Http/RootRemovalTests.cs | 118 --- PrivaPub.Tests/Http/ServerSetupTests.cs | 156 ---- PrivaPub.Tests/Http/ThreadCollectionsTests.cs | 63 -- PrivaPub.Tests/Http/WellKnownTests.cs | 237 ----- .../Infrastructure/AdminCommandsTests.cs | 175 ---- PrivaPub.Tests/Infrastructure/BackupTests.cs | 287 ------- .../Infrastructure/CdnCatalogTests.cs | 111 --- .../Infrastructure/CdnUpdaterTests.cs | 105 --- .../Infrastructure/DeliveryWorkerTests.cs | 95 -- .../Infrastructure/FederationHttpTests.cs | 140 --- .../Infrastructure/GeoLocatorTests.cs | 69 -- .../Infrastructure/GeoUpdaterTests.cs | 125 --- .../Infrastructure/HostCircuitBreakerTests.cs | 67 -- PrivaPub.Tests/Infrastructure/IndexTests.cs | 66 -- .../Infrastructure/IpRangeGuardTests.cs | 86 -- .../Infrastructure/JobQueueTests.cs | 193 ----- .../Infrastructure/MigrationTests.cs | 266 ------ .../Infrastructure/PrivacyIdsTests.cs | 70 -- PrivaPub.Tests/Infrastructure/RestoreTests.cs | 316 ------- .../Infrastructure/TopologyTests.cs | 21 - PrivaPub.Tests/PrivaPub.Tests.csproj | 30 - .../Statistics/AdminStatisticsTests.cs | 126 --- PrivaPub.Tests/Statistics/CrawlerTests.cs | 276 ------ .../Statistics/DeliveryAttemptTests.cs | 111 --- PrivaPub.Tests/Statistics/DescribeTests.cs | 332 ------- .../Statistics/HandlerVerdictTests.cs | 211 ----- PrivaPub.Tests/Statistics/InboxAnswerTests.cs | 169 ---- .../Statistics/InteractionsTests.cs | 107 --- .../Statistics/LedgerOverHttpTests.cs | 113 --- PrivaPub.Tests/Statistics/LedgerStoreTests.cs | 89 -- .../Statistics/OutboundRequestTests.cs | 142 --- PrivaPub.Tests/Statistics/RollupTests.cs | 152 ---- PrivaPub.Tests/Support/Exclusive.cs | 7 - PrivaPub.Tests/Support/FederatedSeeds.cs | 55 -- PrivaPub.Tests/Support/Harness.cs | 192 ----- PrivaPub.Tests/Support/Host/Accounts.cs | 117 --- .../Support/Host/ClientApiHelpers.cs | 166 ---- .../Support/Host/FederationHelpers.cs | 141 --- PrivaPub.Tests/Support/Host/Jobs.cs | 64 -- .../Support/Host/MastodonHelpers.cs | 232 ----- PrivaPub.Tests/Support/Host/PrivaPubHost.cs | 173 ---- PrivaPub.Tests/Support/MemoryLedger.cs | 27 - PrivaPub.Tests/Support/MiniMmdb.cs | 79 -- PrivaPub.Tests/Support/MongoFixture.cs | 81 -- PrivaPub.Tests/Support/Peer.cs | 132 --- PrivaPub.Tests/Support/RemoteActor.cs | 159 ---- PrivaPub.Tests/Web/PublicPagesTests.cs | 222 ----- PrivaPub.sln | 14 - docs/ROADMAP.md | 1 + tools/ci/with-test-mongod.sh | 68 -- tools/pasture/peers/decepub.sh | 7 +- tools/pasture/town/report.py | 2 +- 149 files changed, 16 insertions(+), 24341 deletions(-) delete mode 100644 PrivaPub.Tests/Api/MastodonParamsTests.cs delete mode 100644 PrivaPub.Tests/Api/PersonaSeparationTests.cs delete mode 100644 PrivaPub.Tests/Api/StatusContentTests.cs delete mode 100644 PrivaPub.Tests/Domain/ContentRendererTests.cs delete mode 100644 PrivaPub.Tests/Domain/FollowTests.cs delete mode 100644 PrivaPub.Tests/Domain/LinkPreviewTests.cs delete mode 100644 PrivaPub.Tests/Domain/MediaFlowTests.cs delete mode 100644 PrivaPub.Tests/Domain/MediaProcessingTests.cs delete mode 100644 PrivaPub.Tests/Domain/NearbyTests.cs delete mode 100644 PrivaPub.Tests/Domain/OutboxTests.cs delete mode 100644 PrivaPub.Tests/Domain/RelationshipTests.cs delete mode 100644 PrivaPub.Tests/Domain/TimelineTests.cs delete mode 100644 PrivaPub.Tests/Domain/VisibilityPolicyTests.cs delete mode 100644 PrivaPub.Tests/Federation/ActorFeaturesTests.cs delete mode 100644 PrivaPub.Tests/Federation/ActorProfileTests.cs delete mode 100644 PrivaPub.Tests/Federation/AuthorGoneTests.cs delete mode 100644 PrivaPub.Tests/Federation/BackfillTests.cs delete mode 100644 PrivaPub.Tests/Federation/ChatMessageSoftwareTests.cs delete mode 100644 PrivaPub.Tests/Federation/CommunityEditsTests.cs delete mode 100644 PrivaPub.Tests/Federation/CommunityModerationTests.cs delete mode 100644 PrivaPub.Tests/Federation/ContentSanitizerTests.cs delete mode 100644 PrivaPub.Tests/Federation/ContextTests.cs delete mode 100644 PrivaPub.Tests/Federation/ConversationContainerTests.cs delete mode 100644 PrivaPub.Tests/Federation/DomainBlocksTests.cs delete mode 100644 PrivaPub.Tests/Federation/FeaturedTests.cs delete mode 100644 PrivaPub.Tests/Federation/FlairTests.cs delete mode 100644 PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs delete mode 100644 PrivaPub.Tests/Federation/FormerRepresentationsTests.cs delete mode 100644 PrivaPub.Tests/Federation/ForwardedTests.cs delete mode 100644 PrivaPub.Tests/Federation/GroupTests.cs delete mode 100644 PrivaPub.Tests/Federation/HttpSignaturesTests.cs delete mode 100644 PrivaPub.Tests/Federation/InboundBlockTests.cs delete mode 100644 PrivaPub.Tests/Federation/InboundRoutingTests.cs delete mode 100644 PrivaPub.Tests/Federation/InboxGapTests.cs delete mode 100644 PrivaPub.Tests/Federation/InboxScenarioTests.cs delete mode 100644 PrivaPub.Tests/Federation/IntegrityProofTests.cs delete mode 100644 PrivaPub.Tests/Federation/InteractionPolicyTests.cs delete mode 100644 PrivaPub.Tests/Federation/InteractionTests.cs delete mode 100644 PrivaPub.Tests/Federation/JobHandlerTests.cs delete mode 100644 PrivaPub.Tests/Federation/MoveTests.cs delete mode 100644 PrivaPub.Tests/Federation/NoteParserTests.cs delete mode 100644 PrivaPub.Tests/Federation/ObjectRecordsTests.cs delete mode 100644 PrivaPub.Tests/Federation/ObjectShapesTests.cs delete mode 100644 PrivaPub.Tests/Federation/ParticipationTests.cs delete mode 100644 PrivaPub.Tests/Federation/PollTests.cs delete mode 100644 PrivaPub.Tests/Federation/ProvenanceTests.cs delete mode 100644 PrivaPub.Tests/Federation/QuoteTests.cs delete mode 100644 PrivaPub.Tests/Federation/ReactionTests.cs delete mode 100644 PrivaPub.Tests/Federation/RelayTests.cs delete mode 100644 PrivaPub.Tests/Federation/RemoteActorServiceTests.cs delete mode 100644 PrivaPub.Tests/Federation/RemoteEditsTests.cs delete mode 100644 PrivaPub.Tests/Federation/RemoteHandleTests.cs delete mode 100644 PrivaPub.Tests/Federation/RendererTests.cs delete mode 100644 PrivaPub.Tests/Federation/ReplyRelayTests.cs delete mode 100644 PrivaPub.Tests/Federation/ReusedIdTests.cs delete mode 100644 PrivaPub.Tests/Federation/ServiceReportTests.cs delete mode 100644 PrivaPub.Tests/Federation/SharedInboxTests.cs delete mode 100644 PrivaPub.Tests/Federation/WallTests.cs delete mode 100644 PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json delete mode 100644 PrivaPub.Tests/Fixtures/pleroma/edited.json delete mode 100644 PrivaPub.Tests/Http/BackupEndpointTests.cs delete mode 100644 PrivaPub.Tests/Http/ClientApiAccountsTests.cs delete mode 100644 PrivaPub.Tests/Http/ClientApiGroupsTests.cs delete mode 100644 PrivaPub.Tests/Http/ClientApiModerationTests.cs delete mode 100644 PrivaPub.Tests/Http/ClientApiPersonasTests.cs delete mode 100644 PrivaPub.Tests/Http/FederationGetTests.cs delete mode 100644 PrivaPub.Tests/Http/HostBootTests.cs delete mode 100644 PrivaPub.Tests/Http/InboxRouteTests.cs delete mode 100644 PrivaPub.Tests/Http/InstanceLocationTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonAccountsTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonFeedTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonFiltersTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonInstanceTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonListsTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonMediaTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonStatusesTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonStreamingTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonTagsTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonTimelinesTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonTrendsTests.cs delete mode 100644 PrivaPub.Tests/Http/MastodonWordSearchTests.cs delete mode 100644 PrivaPub.Tests/Http/MediaLifecycleTests.cs delete mode 100644 PrivaPub.Tests/Http/MessageSignatureTests.cs delete mode 100644 PrivaPub.Tests/Http/NeverFiveHundredTests.cs delete mode 100644 PrivaPub.Tests/Http/NotificationPolicyTests.cs delete mode 100644 PrivaPub.Tests/Http/OAuthTests.cs delete mode 100644 PrivaPub.Tests/Http/OneAnswerTests.cs delete mode 100644 PrivaPub.Tests/Http/PersonaArchiveTests.cs delete mode 100644 PrivaPub.Tests/Http/PersonaExchangeTests.cs delete mode 100644 PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs delete mode 100644 PrivaPub.Tests/Http/PostPlaceTests.cs delete mode 100644 PrivaPub.Tests/Http/ProvenanceAndReactionsTests.cs delete mode 100644 PrivaPub.Tests/Http/RegistrationsTests.cs delete mode 100644 PrivaPub.Tests/Http/RootRemovalTests.cs delete mode 100644 PrivaPub.Tests/Http/ServerSetupTests.cs delete mode 100644 PrivaPub.Tests/Http/ThreadCollectionsTests.cs delete mode 100644 PrivaPub.Tests/Http/WellKnownTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/BackupTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/CdnCatalogTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/CdnUpdaterTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/DeliveryWorkerTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/FederationHttpTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/GeoLocatorTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/GeoUpdaterTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/HostCircuitBreakerTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/IndexTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/IpRangeGuardTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/JobQueueTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/MigrationTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/RestoreTests.cs delete mode 100644 PrivaPub.Tests/Infrastructure/TopologyTests.cs delete mode 100644 PrivaPub.Tests/PrivaPub.Tests.csproj delete mode 100644 PrivaPub.Tests/Statistics/AdminStatisticsTests.cs delete mode 100644 PrivaPub.Tests/Statistics/CrawlerTests.cs delete mode 100644 PrivaPub.Tests/Statistics/DeliveryAttemptTests.cs delete mode 100644 PrivaPub.Tests/Statistics/DescribeTests.cs delete mode 100644 PrivaPub.Tests/Statistics/HandlerVerdictTests.cs delete mode 100644 PrivaPub.Tests/Statistics/InboxAnswerTests.cs delete mode 100644 PrivaPub.Tests/Statistics/InteractionsTests.cs delete mode 100644 PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs delete mode 100644 PrivaPub.Tests/Statistics/LedgerStoreTests.cs delete mode 100644 PrivaPub.Tests/Statistics/OutboundRequestTests.cs delete mode 100644 PrivaPub.Tests/Statistics/RollupTests.cs delete mode 100644 PrivaPub.Tests/Support/Exclusive.cs delete mode 100644 PrivaPub.Tests/Support/FederatedSeeds.cs delete mode 100644 PrivaPub.Tests/Support/Harness.cs delete mode 100644 PrivaPub.Tests/Support/Host/Accounts.cs delete mode 100644 PrivaPub.Tests/Support/Host/ClientApiHelpers.cs delete mode 100644 PrivaPub.Tests/Support/Host/FederationHelpers.cs delete mode 100644 PrivaPub.Tests/Support/Host/Jobs.cs delete mode 100644 PrivaPub.Tests/Support/Host/MastodonHelpers.cs delete mode 100644 PrivaPub.Tests/Support/Host/PrivaPubHost.cs delete mode 100644 PrivaPub.Tests/Support/MemoryLedger.cs delete mode 100644 PrivaPub.Tests/Support/MiniMmdb.cs delete mode 100644 PrivaPub.Tests/Support/MongoFixture.cs delete mode 100644 PrivaPub.Tests/Support/Peer.cs delete mode 100644 PrivaPub.Tests/Support/RemoteActor.cs delete mode 100644 PrivaPub.Tests/Web/PublicPagesTests.cs delete mode 100755 tools/ci/with-test-mongod.sh diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 651b0b6..fbbb448 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -14,6 +14,3 @@ jobs: - name: Build run: dotnet build PrivaPub.sln -c Release - - - name: Test (unit and integration, on a throwaway mongod) - run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cebdc2a..368ef06 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -27,9 +27,6 @@ jobs: echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV" echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - - name: Test (unit and integration, on a throwaway mongod) - run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release - - name: Publish run: | rm -rf "$GITHUB_WORKSPACE/publish" diff --git a/CLAUDE.md b/CLAUDE.md index 490ddb8..2dfa738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,6 @@ PrivaPub/ ASP.NET Core Web API, net10.0 StaticServices/ DbEntities (Find accessors), AuthTokenManager (JWT), PasswordHasher Data/InitDb.cs first-run seeding (languages) PrivaPub.ClientModels/ DTOs + validation resources shared with clients -PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh .gitea/workflows/ build.yml (push) · deploy.yml (tag v*) docs/ROADMAP.md decisions + phased plan @@ -135,10 +134,6 @@ one pure-move commit at a time. ```bash dotnet build PrivaPub.sln -c Release -dotnet test PrivaPub.sln # unit tests; integration tests skip -PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017 - # (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after) -tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \ Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \ AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \ @@ -292,8 +287,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. 4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. 5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api` error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from - `UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and - with a junk token. + `UseMastodonErrorBodies`. 6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays, because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. @@ -313,7 +307,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. ## Media invariants 1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with - `-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone. + `-map_metadata -1`. 2. **An image is checked before it is decoded.** - **Loaders:** only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (`MediaService`'s static constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads. @@ -601,37 +595,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. - Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict + OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit. -## Testing +## Verifying -`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip -without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through -`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards. -Like production's, it is a one-member replica set (`rs0`, reached with `directConnection=true`), so backups' snapshot -reads are what the tests exercise (`TopologyTests`). -The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub`, -and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1`, which the wrapper sets, a missing -mongod fails the run instead of silently skipping half the tests. - -- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules - can be tested; `Support/RemoteActor` signs real deliveries with its own key. -- Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers. -- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory`, environment `Testing`, - configured only through `UseSetting`, on the fixture's database). `PrivaPubHost.Shared()` boots it once per run; - `SecureModeHost` is the same with `Federation:SecureMode`. Background workers are removed, so a test runs the jobs it - queued with `host.Run(j => ...)` or `host.RunInbox(activityId)`. `Accounts` signs a root up, adds personas, and gets a - Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost`/`SignedGet` sign `HttpRequestMessage`s - for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide. -- All test classes share one database and run in parallel, so a test touches only rows it made: - - random names and GUIDs; - - `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused; - - a worker gets a scoped `new JobQueue(j => ...)` so it never leases another test's jobs; - - domain blocks are set with `DomainBlocks.Load`, never written to the database. - - A test that must change something database-wide (drop indexes, run a migration over every post, let deliveries to - `localhost` fail and trip its breaker) goes in `[Xunit.Collection(nameof(Exclusive))]`, which runs alone, and - cleans up after itself. Pure logic belongs in an unconditional unit test, not in a Mongo-gated class. - -Beyond the tests, verify by building, running locally, and exercising: +There is no test project (owner decision 2026-10-17: `PrivaPub.Tests` was removed, with the CI and deploy steps that +ran it). Don't add one back unless asked. Verify by building, running locally, and exercising: - the client API (sign up, create an avatar, a group, a post); - the ActivityPub endpoints with curl and `Accept: application/activity+json`. @@ -929,16 +896,17 @@ tools/pasture/town.sh backlog --write # docs/INTEROP-BACKLOG.md audience only; a Misskey heart reaction arrives as a Like. - Peers' own limits are lifted for the town: GoToSocial `GTS_ADVANCED_RATE_LIMIT_REQUESTS=0`, Lemmy's `local_site_rate_limit`. Mastodon reserves usernames that contain `mastodon`. -- decePubClient's e2e tests (`decePubClient/tests/e2e/run.sh`) publish the client with the Pasture environment, serve - it as `https://decepub.test` (`peers/decepub.sh`) and write their cells to `out/client.jsonl`. +- decePubClient is served as `https://decepub.test` (`peers/decepub.sh`) once published with the Pasture environment + (in decePubClient: `dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish`), + its API then `https://privapub.test`. After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.** ## Deploy -- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs - `deploy.yml`: tests, self-contained linux-x64 publish, a snapshot of the site to `/var/backups/privapub.thepra.dev`, +- **CI/CD:** push to `master` runs `build.yml` (a build) on the instance-wide `build` runner. A `v*` tag runs + `deploy.yml`: a self-contained linux-x64 publish, a snapshot of the site to `/var/backups/privapub.thepra.dev`, the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits), stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo, diff --git a/PrivaPub.Tests/Api/MastodonParamsTests.cs b/PrivaPub.Tests/Api/MastodonParamsTests.cs deleted file mode 100644 index 015722a..0000000 --- a/PrivaPub.Tests/Api/MastodonParamsTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Microsoft.AspNetCore.Http; - -using PrivaPub.Api.Mastodon.Auth; -using PrivaPub.Api.Mastodon.Infrastructure; - -using System.Text; - -namespace PrivaPub.Tests.Api -{ - public class MastodonParamsTests - { - static async Task Read(string query = "", string contentType = default, string body = default) - { - var context = new DefaultHttpContext(); - context.Request.QueryString = new QueryString(query); - if (body != default) - { - context.Request.ContentType = contentType; - var bytes = Encoding.UTF8.GetBytes(body); - context.Request.Body = new MemoryStream(bytes); - context.Request.ContentLength = bytes.Length; - } - return await MastodonParams.Read(context.Request, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Absent_values_are_null_not_false_or_zero() - { - var parameters = await Read(); - - Assert.Null(parameters.Bool("sensitive")); - Assert.Null(parameters.Int("limit")); - Assert.Null(parameters.Get("status")); - Assert.Empty(parameters.List("media_ids")); - } - - [Fact] - public async Task Reads_rails_style_arrays_from_the_query() - { - var parameters = await Read("?id[]=1&id[]=2&limit=5&local=true"); - - Assert.Equal(new[] { "1", "2" }, parameters.List("id")); - Assert.Equal(5, parameters.Int("limit")); - Assert.True(parameters.Bool("local")); - } - - [Fact] - public async Task Reads_forms_and_nested_keys() - { - var parameters = await Read(contentType: "application/x-www-form-urlencoded", - body: "status=hi&media_ids[]=a&media_ids[]=b&poll[options][]=x&poll[options][]=y&sensitive=0"); - - Assert.Equal("hi", parameters.Get("status")); - Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids")); - Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]")); - Assert.False(parameters.Bool("sensitive")); - } - - [Fact] - public async Task Reads_json_bodies_the_same_way() - { - var parameters = await Read(contentType: "application/json", - body: """{"status":"hi","media_ids":["a","b"],"poll":{"options":["x","y"],"expires_in":300},"sensitive":true,"source":{"privacy":"unlisted"}}"""); - - Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids")); - Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]")); - Assert.Equal(300, parameters.Int("poll[expires_in]")); - Assert.True(parameters.Bool("sensitive")); - Assert.Equal("unlisted", parameters.Get("source[privacy]")); - } - - [Theory] - [InlineData(new[] { "read" }, "read:statuses", true)] - [InlineData(new[] { "read:statuses" }, "read:statuses", true)] - [InlineData(new[] { "read:accounts" }, "read:statuses", false)] - [InlineData(new[] { "write" }, "read:statuses", false)] - [InlineData(new[] { "follow" }, "write:follows", true)] - [InlineData(new[] { "follow" }, "write:statuses", false)] - public void Scopes_grant_like_mastodons(string[] granted, string required, bool expected) => - Assert.Equal(expected, MastodonScopes.Grants(granted, required)); - } -} diff --git a/PrivaPub.Tests/Api/PersonaSeparationTests.cs b/PrivaPub.Tests/Api/PersonaSeparationTests.cs deleted file mode 100644 index dd8c011..0000000 --- a/PrivaPub.Tests/Api/PersonaSeparationTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; - -using MongoDB.Entities; - -using PrivaPub.Api.Mastodon.Infrastructure; -using PrivaPub.Api.Mastodon.Mappers; -using PrivaPub.ClientModels.Post; -using PrivaPub.ClientModels.Social; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json; - -namespace PrivaPub.Tests.Api -{ - [Trait("Category", "Integration")] - public sealed class PersonaSeparationTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - [Fact] - public async Task Nothing_shown_to_one_persona_names_its_sibling() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var (_, sibling) = await _harness.Persona("sibling", root); - var (bobRoot, bob) = await _harness.Persona("bob"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token); - await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token); - await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token); - await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token); - - var mapper = new MastodonMapper(_harness.Db, _harness.Local); - var account = await mapper.Local(alice, withSource: true, token); - var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList(); - var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token); - var statuses = await mapper.Statuses(home, alice.Id, token); - var bobAsSeenByAlice = await mapper.Account(bob.Id, token); - - var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options); - Assert.DoesNotContain(sibling.Id, json); - Assert.DoesNotContain(sibling.UserName, json); - Assert.DoesNotContain(root, json); - Assert.Contains("hi both", json); - Assert.Equal(2, bobAsSeenByAlice.FollowersCount); - } - } -} diff --git a/PrivaPub.Tests/Api/StatusContentTests.cs b/PrivaPub.Tests/Api/StatusContentTests.cs deleted file mode 100644 index 64944ae..0000000 --- a/PrivaPub.Tests/Api/StatusContentTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using PrivaPub.Api.Mastodon.Mappers; -using PrivaPub.Models.Post; - -namespace PrivaPub.Tests.Api -{ - public class StatusContentTests - { - [Fact] - public void A_remote_article_shows_its_title_above_its_body() => - Assert.Equal("

Rain & sun

body

", MastodonMapper.Content(new Post - { - IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "

body

" - })); - - [Fact] - public void A_remote_note_keeps_its_content_alone() => - Assert.Equal("

body

", MastodonMapper.Content(new Post - { - IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "

body

" - })); - - [Fact] - public void A_remote_event_without_a_body_shows_its_excerpt() => - Assert.Equal("

Meetup

Saturday <10:00>

", MastodonMapper.Content(new Post - { - IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty - })); - } -} diff --git a/PrivaPub.Tests/Domain/ContentRendererTests.cs b/PrivaPub.Tests/Domain/ContentRendererTests.cs deleted file mode 100644 index 6ecaf6b..0000000 --- a/PrivaPub.Tests/Domain/ContentRendererTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -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** ", TestContext.Current.CancellationToken); - - Assert.Contains("now", rendered.Html); - Assert.DoesNotContain("" }, - { "type": "PropertyValue", "name": "Pronouns", "value": "she/her" } - ] - } - """); - - var actor = ActorDocument.Parse(json.RootElement); - - Assert.True(actor.Locked); - Assert.True(actor.Indexable); - Assert.Equal("https://m.example/h.png", actor.Header); - Assert.Equal("a cat", actor.IconDescription); - Assert.Equal("mountains", actor.HeaderDescription); - Assert.Equal("https://n.example/users/ann", actor.MovedTo); - Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published); - Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode); - Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys); - Assert.DoesNotContain("

hi

", "

hi

")] - [InlineData("

hi

", "

hi

")] - [InlineData("", "")] - [InlineData("
x", "x")] - [InlineData("alert(1)\">x", "x")] - [InlineData("x", "x")] - [InlineData("text", "text")] - [InlineData("

x

", "

x

")] - [InlineData("", "")] - [InlineData("

x

", "

x

")] - [InlineData("
ok", "ok")] - [InlineData("

ab

", "

ab

")] - [InlineData("

kept

", "

kept

")] - [InlineData("

Title

", "

Title

")] - [InlineData("x", "x")] - public void Removes_what_is_not_allowed(string input, string expected) => - Assert.Equal(expected, ContentSanitizer.Html(input)); - - [Fact] - public void Keeps_mastodon_mention_and_hashtag_markup() - { - var html = "

@alice " - + "#fedi " - + "https://example.org/a-long

"; - - var sanitized = ContentSanitizer.Html(html); - - Assert.Contains("class=\"h-card\"", sanitized); - Assert.Contains("class=\"u-url mention\"", sanitized); - Assert.Contains("class=\"mention hashtag\"", sanitized); - Assert.Contains("class=\"invisible\"", sanitized); - Assert.Contains("class=\"ellipsis\"", sanitized); - Assert.Contains("href=\"https://m.example/@alice\"", sanitized); - Assert.DoesNotContain("rel=\"tag\"", sanitized); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void Empty_input_is_empty(string input) => - Assert.Equal(string.Empty, ContentSanitizer.Html(input)); - } -} diff --git a/PrivaPub.Tests/Federation/ContextTests.cs b/PrivaPub.Tests/Federation/ContextTests.cs deleted file mode 100644 index dd17651..0000000 --- a/PrivaPub.Tests/Federation/ContextTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using PrivaPub.Federation.Rendering; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // The @context on everything we send. Readers that expand JSON-LD 1.0 (Smithereen) refuse a whole document over one - // term only 1.1 knows, so every term stays within 1.0 - public class ContextTests - { - static readonly string[] Containers10 = ["@list", "@set", "@index", "@language"]; - static readonly string[] Keywords11 = ["@version", "@protected", "@propagate", "@import", "@nest", "@prefix", "@context", "@direction", "@json"]; - - [Fact] - public void Every_term_is_json_ld_1_0() - { - var terms = ActivityPubRenderer.Context().OfType().Single(); - - foreach (var (name, definition) in terms) - { - if (definition is not JsonObject term) - continue; - if (term["@container"] is JsonValue container) - Assert.True(Containers10.Contains(container.GetValue()), $"{name}: @container {container} is JSON-LD 1.1"); - Assert.True(term["@container"] is null or JsonValue, $"{name}: an array of containers is JSON-LD 1.1"); - Assert.DoesNotContain(term.Select(p => p.Key), Keywords11.Contains); - Assert.NotEqual("@json", term["@type"]?.GetValue()); - } - Assert.DoesNotContain(terms.Select(p => p.Key), Keywords11.Contains); - } - } -} diff --git a/PrivaPub.Tests/Federation/ConversationContainerTests.cs b/PrivaPub.Tests/Federation/ConversationContainerTests.cs deleted file mode 100644 index 3990768..0000000 --- a/PrivaPub.Tests/Federation/ConversationContainerTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using PostEntity = PrivaPub.Models.Post.Post; - -namespace PrivaPub.Tests.Federation -{ - // FEP-171b: a thread's owner adds a reply someone else wrote to the thread's context (Hubzilla's Add{Create}), and the - // reply is taken as passed on by the owner, on its own proof; an Add aimed at a collection elsewhere is not a thread's - [Trait("Category", "Integration")] - public sealed class ConversationContainerTests : IAsyncLifetime - { - const string Public = "https://www.w3.org/ns/activitystreams#Public"; - - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static JsonObject Create(RemoteActor author, string text, string context, string inReplyTo = default) - { - var noteId = $"{Origin(author)}/item/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", ["context"] = context, - ["to"] = new JsonArray(Public), ["published"] = DateTime.UtcNow.ToString("O") - }; - if (inReplyTo != default) - note["inReplyTo"] = inReplyTo; - return new JsonObject - { - ["id"] = $"{Origin(author)}/activity/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["to"] = new JsonArray(Public), - ["object"] = note - }; - } - - async Task Held(string objectUri) => await DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(Token); - - async Task RunQueued(string innerId) - { - var job = await DB.Default.Find().Match(j => j.DedupeKey == "inbox|forwarded|" + innerId && j.State == JobState.Pending).ExecuteFirstAsync(Token); - Assert.NotNull(job); - Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result); - } - - [Fact] - public async Task A_reply_the_threads_owner_adds_to_its_context_is_taken_on_its_proof() - { - var (_, alice) = await _harness.Persona("alice"); - var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); - var commenter = new RemoteActor(_harness.Peer, "commenter", ed25519: true); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); - var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}"; - var root = Create(owner, "a thread", context); - await _harness.Deliver(owner, "/human-centipede", root); - var rootId = root["object"]!["id"]!.GetValue(); - Assert.True(await Held(rootId)); - - var reply = commenter.Prove(Create(commenter, "a reply in it", context, rootId)); - var replyId = reply["object"]!["id"]!.GetValue(); - await _harness.Deliver(owner, "/human-centipede", owner.Prove(new JsonObject - { - ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply, - ["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id }, ["to"] = new JsonArray(Public) - })); - await RunQueued(reply["id"]!.GetValue()); - - var stored = await DB.Default.Find().Match(p => p.ObjectURI == replyId).ExecuteFirstAsync(Token); - Assert.NotNull(stored); - Assert.Equal(commenter.Id, stored.ActorURI); - Assert.Equal((await DB.Default.Find().Match(p => p.ObjectURI == rootId).ExecuteFirstAsync(Token)).ID, stored.AnsweringToPostId); - } - - // Forte gives a post's Update the id of its Create, and sends it only added to the thread's context - [Fact] - public async Task An_update_added_under_its_creates_id_is_taken_as_the_edit() - { - var (_, alice) = await _harness.Persona("alice"); - var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); - var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}"; - var create = owner.Prove(Create(owner, "as first written", context)); - JsonObject Added(JsonObject inner) => owner.Prove(new JsonObject - { - ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = inner, - ["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id } - }); - await _harness.Deliver(owner, "/human-centipede", Added(create)); - await RunQueued(create["id"]!.GetValue()); - var update = create.DeepClone().AsObject(); - update.Remove("proof"); - update["type"] = "Update"; - update["object"]!["content"] = "

as edited

"; - update["object"]!["updated"] = DateTime.UtcNow.AddSeconds(1).ToString("O"); - owner.Prove(update); - - await _harness.Deliver(owner, "/human-centipede", Added(update)); - var job = await DB.Default.Find().Match(j => j.DedupeKey.StartsWith("inbox|forwarded|" + update["id"]!.GetValue() + "|") && j.State == JobState.Pending) - .ExecuteFirstAsync(Token); - Assert.NotNull(job); - Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result); - - var noteId = create["object"]!["id"]!.GetValue(); - Assert.Contains("as edited", (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token)).ContentHtml); - } - - [Fact] - public async Task An_add_to_a_collection_on_another_server_is_no_threads() - { - var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); - var commenter = new RemoteActor(_harness.Peer, "commenter", _harness.Peer.B, ed25519: true); - var reply = commenter.Prove(Create(commenter, "elsewhere", $"{Origin(commenter)}/conversation/1")); - - await _harness.Deliver(owner, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply, - ["target"] = $"{Origin(commenter)}/conversation/1" - }); - - Assert.False(await DB.Default.Find().Match(j => j.DedupeKey == "inbox|forwarded|" + reply["id"]!.GetValue()).ExecuteAnyAsync(Token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/DomainBlocksTests.cs b/PrivaPub.Tests/Federation/DomainBlocksTests.cs deleted file mode 100644 index 87701d7..0000000 --- a/PrivaPub.Tests/Federation/DomainBlocksTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using PrivaPub.Federation.Moderation; -using PrivaPub.Models.Federation; - -namespace PrivaPub.Tests.Federation -{ - public class DomainBlocksTests - { - [Fact] - public void A_block_covers_subdomains_but_not_lookalikes() - { - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] { new DomainBlock { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } }); - - Assert.NotNull(blocks.Find("evil.example")); - Assert.NotNull(blocks.Find("A.Evil.Example.")); - Assert.Null(blocks.Find("notevil.example")); - Assert.False(blocks.IsSuspended("evil.example")); - } - - [Fact] - public void A_suspension_is_found_from_any_subdomain() - { - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] - { - new DomainBlock { Domain = " Spam.Example. ", Severity = DomainBlockSeverity.Suspend }, - new DomainBlock { Domain = "", Severity = DomainBlockSeverity.Suspend } - }); - - Assert.True(blocks.IsSuspended("deep.inside.spam.example")); - Assert.False(blocks.IsSuspended("example")); - Assert.Null(blocks.Find(default)); - } - } -} diff --git a/PrivaPub.Tests/Federation/FeaturedTests.cs b/PrivaPub.Tests/Federation/FeaturedTests.cs deleted file mode 100644 index a5b41bd..0000000 --- a/PrivaPub.Tests/Federation/FeaturedTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // pins across servers: an account elsewhere features its own posts (Add and Remove on its `featured` collection, and - // the collection itself), a community features posts made in it, and a persona's pin is told to its followers - [Trait("Category", "Integration")] - public sealed class FeaturedTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - // a public note of the actor's, served where its id points - string Note(RemoteActor author, string text, string audience = default) - { - var path = $"/notes/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O") - }; - if (audience != default) - note["audience"] = audience; - _harness.Peer.Serve(path, note.ToJsonString()); - return Origin(author) + path; - } - - JsonObject Featuring(RemoteActor actor, string type, string objectUri, string target = default) => new() - { - ["id"] = $"{actor.Id}#{type}-{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = objectUri, - ["target"] = target ?? actor.Id + "/featured" - }; - - Task> FeaturedBy(RemoteActor actor) => - DB.Default.Find().Match(f => f.ActorURI == actor.Id).Sort(f => f.Position, Order.Ascending).ExecuteAsync(TestContext.Current.CancellationToken); - - [Fact] - public async Task An_account_features_its_own_post_and_takes_it_back_but_nothing_else() - { - var bob = new RemoteActor(_harness.Peer, "bob"); - var carol = new RemoteActor(_harness.Peer, "carol", _harness.Peer.B); - var mine = Note(bob, "pinned"); - var theirs = Note(carol, "not bob's"); - var older = Note(bob, "pinned before"); - - await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", older)); - await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", mine)); - await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", theirs)); - // Smithereen's wall is a collection of the actor's too, but no pin - await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", Note(bob, "on the wall"), bob.Id + "/wall")); - - Assert.Equal(new[] { mine, older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI)); - var post = await DB.Default.Find().Match(p => p.ObjectURI == mine).ExecuteSingleAsync(TestContext.Current.CancellationToken); - Assert.Equal(post.ID, (await FeaturedBy(bob))[0].PostId); - - await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Remove", mine)); - - Assert.Equal(new[] { older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI)); - } - - [Fact] - public async Task The_featured_collection_is_read_in_its_order_from_the_accounts_own_server() - { - var token = TestContext.Current.CancellationToken; - var bob = new RemoteActor(_harness.Peer, "bob"); - var first = Note(bob, "first pin"); - var second = Note(bob, "second pin"); - _harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/featured", new JsonObject - { - ["id"] = bob.Id + "/featured", ["type"] = "OrderedCollection", ["totalItems"] = 2, - ["orderedItems"] = new JsonArray(new JsonObject { ["id"] = first, ["type"] = "Note" }, second) - }.ToJsonString()); - var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token); - - await _harness.Featured.Sync(account, token); - - Assert.Equal(new[] { first, second }, (await FeaturedBy(bob)).Select(f => f.ObjectURI)); - } - - [Fact] - public async Task A_personas_pin_and_unpin_reach_its_followers_as_add_and_remove() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var follower = new RemoteActor(_harness.Peer, "fan"); - await _harness.FollowedBy(alice, follower); - await _harness.Posts.InsertPost(root, new PrivaPub.ClientModels.Post.InsertPostForm { AvatarId = alice.Id, Text = "worth a pin" }, token); - var post = await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token); - - await _harness.Outbox.PublishFeatured(alice.Id, post, featured: true, token); - await _harness.Outbox.PublishFeatured(alice.Id, post, featured: false, token); - - var sent = (await _harness.Outgoing(follower.SharedInbox)).Concat(await _harness.Outgoing(follower.Id + "/inbox")) - .Where(a => a["type"]!.GetValue() is "Add" or "Remove").ToList(); - Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue())); - Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue(), a["target"]!.GetValue()))); - } - - [Fact] - public async Task A_community_features_a_post_made_in_it() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "books", type: "Group"); - var member = new RemoteActor(_harness.Peer, "reader", _harness.Peer.B); - var account = await _harness.Remote.GetActor(community.Id, refresh: false, token); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = community.Id, TargetAccountId = account.ID, State = FollowState.Accepted }, token); - var post = Note(member, "a thread in the community", community.Id); - - await _harness.Deliver(community, "/human-centipede", new JsonObject - { - ["id"] = $"{community.Id}#announce-{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = community.Id, - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["object"] = new JsonObject - { - ["id"] = $"{Origin(member)}/activities/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = member.Id, ["object"] = post, - ["target"] = community.Id + "/featured" - } - }); - - Assert.Equal(new[] { post }, (await FeaturedBy(community)).Select(f => f.ObjectURI)); - } - } -} diff --git a/PrivaPub.Tests/Federation/FlairTests.cs b/PrivaPub.Tests/Federation/FlairTests.cs deleted file mode 100644 index 3a62756..0000000 --- a/PrivaPub.Tests/Federation/FlairTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Objects; - -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class FlairTests - { - [Fact] - public void A_lemmy_posts_flair_is_described_by_its_community() - { - using var group = JsonDocument.Parse(""" - { - "id": "https://lemmy.example/c/tenforward", "type": "Group", "preferredUsername": "tenforward", "inbox": "https://lemmy.example/c/tenforward/inbox", - "attributedTo": "https://lemmy.example/c/tenforward/moderators", - "tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news", - "name": "News", "content": "What happened", "color": "color03" }] - } - """); - var community = ActorDocument.Parse(group.RootElement); - var page = NoteParser.Parse(JsonNode.Parse(""" - { - "id": "https://lemmy.example/post/1", "type": "Page", "name": "Big news", "attributedTo": "https://lemmy.example/u/bo", - "tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news" }, - { "type": "Mention", "href": "https://lemmy.example/c/tenforward", "name": "@tenforward@lemmy.example" }] - } - """)); - - Assert.Equal("https://lemmy.example/c/tenforward/moderators", community.Moderators); - var flair = Assert.Single(ObjectShapes.Described(page.Flairs, community.Flairs)); - Assert.Equal("News", flair.Name); - Assert.Equal("news", flair.Slug); - Assert.Equal("color03", flair.Color); - Assert.Equal("What happened", flair.Description); - } - - [Fact] - public void Reads_piefeds_flairs_in_both_dialects_and_its_older_community_list() - { - var post = NoteParser.Parse(JsonNode.Parse(""" - { - "id": "https://piefed.example/post/9", "type": "Page", "name": "A question", "attributedTo": "https://piefed.example/u/pf", - "tag": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/3", "display_name": "Solved", - "text_color": "#ffffff", "background_color": "#2e7d32", "blur_images": false }, - { "type": "Hashtag", "href": "https://piefed.example/tag/q", "name": "#q" }] - } - """)); - using var group = JsonDocument.Parse(""" - { - "id": "https://piefed.example/c/ask", "type": "Group", "preferredUsername": "ask", "inbox": "https://piefed.example/c/ask/inbox", - "lemmy:tagsForPosts": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/4", "display_name": "Spoiler", - "text_color": "#000", "background_color": "red; background: url(x)", "blur_images": true }] - } - """); - - var solved = Assert.Single(post.Flairs); - Assert.Equal("Solved", solved.Name); - Assert.Equal("#2e7d32", solved.BackgroundColor); - Assert.Equal("q", Assert.Single(post.Tags)); - var spoiler = Assert.Single(ActorDocument.Parse(group.RootElement).Flairs); - Assert.True(spoiler.BlurImages); - Assert.Equal("#000", spoiler.TextColor); - Assert.Null(spoiler.BackgroundColor); - } - } -} diff --git a/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs b/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs deleted file mode 100644 index 606fb14..0000000 --- a/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Outbox; -using PrivaPub.Federation.Signing; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers tells the receiving server, in a signed - // header, a digest of the persona's followers there; the roll-call it names lists them, to that server only - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class FollowersSynchronizationTests : IAsyncLifetime - { - static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; - - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - [Fact] - public void The_digest_is_the_xor_of_each_ids_sha256_whatever_the_order() - { - Assert.Equal(new string('0', 64), FollowersSynchronization.Digest([])); - Assert.Equal(FollowersSynchronization.Digest(["https://a.example/users/one", "https://a.example/users/two"]), - FollowersSynchronization.Digest(["https://a.example/users/two", "https://a.example/users/one"])); - Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes("https://a.example/users/one"))), - FollowersSynchronization.Digest(["https://a.example/users/one"])); - } - - async Task Delivered(LocalActor signer, string inbox, JsonObject activity) - { - await _harness.Delivery.Enqueue(signer, new[] { inbox }, activity, Token); - var job = await DB.Default.Find().Match(j => j.DedupeKey == $"{activity["id"]!.GetValue()}|{inbox}").ExecuteSingleAsync(Token); - var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), - NullLogger.Instance); - Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result); - return Assert.Single(_harness.Peer.Requests, r => r.Body?.Contains(activity["id"]!.GetValue()) == true); - } - - static JsonObject Create(LocalActor author, params string[] to) => new() - { - ["id"] = $"{author.Uri}/grunts/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Uri, ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()), - ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi", ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) } - }; - - [Fact] - public async Task A_delivery_to_followers_carries_a_signed_digest_of_the_followers_on_that_server_only() - { - var (_, alice) = await _harness.Persona("alice"); - var one = new RemoteActor(_harness.Peer, "one"); - var two = new RemoteActor(_harness.Peer, "two"); - var far = new RemoteActor(_harness.Peer, "far", _harness.Peer.B); - await _harness.FollowedBy(alice, one); - await _harness.FollowedBy(alice, two); - await _harness.FollowedBy(alice, far); - await DB.Default.SaveAsync(new Follower - { - LocalActorId = alice.Id, LocalActorKind = alice.Kind, ActorURI = _harness.Peer.A + "/users/asking", InboxURL = _harness.Peer.A + "/users/asking/inbox", IsAccepted = false - }, Token); - _harness.Peer.Answer("/inbox", 202); - - var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, alice.Followers)); - - Assert.Equal(alice.Followers, Value(received, "collectionId")); - Assert.Equal(alice.Followers + "/roll-call", Value(received, "url")); - Assert.Equal(FollowersSynchronization.Digest([one.Id, two.Id]), Value(received, "digest")); - var signature = HttpSignatures.Parse(received.Signature); - Assert.Equal(new[] { "(request-target)", "host", "date", "digest", "collection-synchronization" }, signature.Headers); - var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}\n" - + $"collection-synchronization: {received.Headers[FollowersSynchronization.Header]}"; - using var key = RSA.Create(); - key.ImportFromPem(alice.PublicKeyPem); - Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - } - - static string Value(HttpRequestRecord received, string name) - { - var values = received.Headers[FollowersSynchronization.Header].Split(',', StringSplitOptions.TrimEntries) - .Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1].Trim('"')); - return values[name]; - } - - [Fact] - public async Task A_delivery_that_is_not_for_followers_carries_none() - { - var (_, alice) = await _harness.Persona("alice"); - var recipient = new RemoteActor(_harness.Peer, "recipient"); - await _harness.FollowedBy(alice, recipient); - _harness.Peer.Answer("/inbox", 202); - - var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, recipient.Id)); - - Assert.False(received.Headers.ContainsKey(FollowersSynchronization.Header)); - Assert.DoesNotContain("collection-synchronization", HttpSignatures.Parse(received.Signature).Headers); - } - - // Mastodon undoes a follow it never knew of with the same id each time ({actor}#follows//undo): when it comes again, - // after a new follow, it ends that one too; a copy of it, with nothing to end, stays a copy - [Fact] - public async Task An_undo_of_a_follow_sent_again_after_a_new_follow_ends_that_one_too() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var undo = new JsonObject - { - ["id"] = bob.Id + "#follows//undo", ["type"] = "Undo", ["actor"] = bob.Id, - ["object"] = new JsonObject { ["id"] = bob.Id + "#follows/", ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri } - }; - Task Follows() => DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(Token); - - await _harness.FollowedBy(alice, bob); - await _harness.Deliver(bob, "/human-centipede", undo.DeepClone()); - Assert.False(await Follows()); - - await _harness.FollowedBy(alice, bob); - await _harness.Deliver(bob, "/human-centipede", undo.DeepClone()); - Assert.False(await Follows()); - - Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason); - } - - // received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL - async Task Claimed(RemoteActor bob, string digest, params string[] listed) - { - var path = new Uri(bob.Id).AbsolutePath + "/followers/sync"; - _harness.Peer.Serve(path, new JsonObject - { - ["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection", - ["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray()) - }.ToJsonString()); - var note = $"{bob.Id}/notes/{Guid.NewGuid():N}"; - var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject - { - ["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), - ["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" } - }); - request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\""; - await _harness.Receiver.Receive(request, default, Token); - var job = await DB.Default.Find().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id)) - .ExecuteSingleAsync(Token); - var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery); - Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result); - } - - async Task Follows(LocalActor persona, RemoteActor bob, FollowState state) - { - var following = new Following - { - AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}") - }; - await DB.Default.SaveAsync(following, Token); - return following; - } - - Task Following(LocalActor persona, RemoteActor bob) => - DB.Default.Find().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token); - - [Fact] - public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list() - { - var (_, alice) = await _harness.Persona("alice"); - var (_, carol) = await _harness.Persona("carol"); - var (_, dave) = await _harness.Persona("dave"); - var bob = new RemoteActor(_harness.Peer, "bob"); - await Follows(alice, bob, FollowState.Accepted); - await Follows(carol, bob, FollowState.Requested); - - await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri); - - Assert.Null(await Following(alice, bob)); - Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State); - var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Undo").ToList(); - Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue()).Order()); - var daves = Assert.Single(undos, u => u["actor"]!.GetValue() == dave.Uri); - Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue(), daves["object"]!["object"]!.GetValue())); - } - - [Fact] - public async Task A_list_the_claim_does_not_describe_ends_no_follow() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - await Follows(alice, bob, FollowState.Accepted); - - await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"])); - - Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); - } - - [Fact] - public async Task A_claim_that_agrees_reads_nothing() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - await Follows(alice, bob, FollowState.Accepted); - - await Claimed(bob, FollowersSynchronization.Digest([alice.Uri])); - - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync")); - Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); - } - } - - [Trait("Category", "Integration")] - public sealed class RollCallTests : IAsyncLifetime - { - PrivaPubHost _host; - HttpClient _client; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - _client?.Dispose(); - if (_peer != default) - await _peer.DisposeAsync(); - } - - static string[] Items(JsonObject collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToArray(); - - [Fact] - public async Task The_roll_call_lists_the_followers_on_the_signing_server_and_asks_for_a_signature() - { - var token = TestContext.Current.CancellationToken; - var persona = await _host.Persona(await _host.SignUp(), "rollcall"); - var near = new RemoteActor(_peer, "near"); - var server = new RemoteActor(_peer, "instance", type: "Application"); - var far = new RemoteActor(_peer, "far", _peer.B); - foreach (var follower in new[] { near, far }) - await DB.Default.SaveAsync(new Follower - { - LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" - }, token); - var path = $"/peasants/{persona.UserName}/groupies/roll-call"; - - var asServer = await _client.Fetch(server.SignedGet(path)); - var asFar = await _client.Fetch(far.SignedGet(path)); - - Assert.Equal(HttpStatusCode.OK, asServer.Status); - Assert.Equal(persona.ActorUri() + "/groupies/roll-call", asServer.Json["id"]!.GetValue()); - Assert.Equal(new[] { near.Id }, Items(asServer.Json)); - Assert.Equal(new[] { far.Id }, Items(asFar.Json)); - Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Fetch(path)).Status); - } - } -} diff --git a/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs b/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs deleted file mode 100644 index 9aa6fc7..0000000 --- a/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // Pleroma's and Akkoma's edited posts carry their earlier versions (formerRepresentations): a post first met after its - // edits has its history, and an edit tells the ones missed in between - [Trait("Category", "Integration")] - public sealed class FormerRepresentationsTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - static readonly DateTime Start = DateTime.UtcNow.AddHours(-1); - - static JsonObject Version(string text, int minutes) => new() - { - ["type"] = "Note", ["content"] = $"

{text}

", ["published"] = Start.ToString("O"), - ["updated"] = minutes == 0 ? default : Start.AddMinutes(minutes).ToString("O") - }; - - async Task<(RemoteActor Carol, JsonObject Note)> Followed(string text, int minutes, params JsonObject[] former) - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var carol = new RemoteActor(_harness.Peer, "carol"); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token); - var note = Version(text, minutes); - note["id"] = $"{Origin(carol)}/objects/{Guid.NewGuid():N}"; - note["attributedTo"] = carol.Id; - note["to"] = new JsonArray(Addressing.Public); - note["cc"] = new JsonArray($"{carol.Id}/followers"); - if (former.Length > 0) - note["formerRepresentations"] = new JsonObject { ["type"] = "OrderedCollection", ["totalItems"] = former.Length, ["orderedItems"] = new JsonArray(former) }; - return (carol, note); - } - - Task Deliver(RemoteActor carol, string type, JsonObject note) => _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/activities/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = carol.Id, - ["to"] = new JsonArray(Addressing.Public), ["object"] = note.DeepClone() - }); - - Task Stored(JsonObject note) => - DB.Default.Find().Match(p => p.ObjectURI == note["id"]!.GetValue()).ExecuteSingleAsync(TestContext.Current.CancellationToken); - - [Fact] - public async Task A_post_first_met_after_its_edits_has_its_history() - { - var (carol, note) = await Followed("third", 40, Version("second", 20), Version("first", 0)); - - await Deliver(carol, "Create", note); - - var post = await Stored(note); - Assert.Equal(["

first

", "

second

"], post.Revisions.Select(r => r.ContentHtml)); - Assert.Equal("

third

", post.ContentHtml); - } - - [Fact] - public async Task An_edit_tells_the_versions_missed_in_between() - { - var (carol, note) = await Followed("first", 0); - await Deliver(carol, "Create", note); - - var edited = (JsonObject)note.DeepClone(); - edited["content"] = "

third

"; - edited["updated"] = Start.AddMinutes(40).ToString("O"); - edited["formerRepresentations"] = new JsonObject - { - ["type"] = "OrderedCollection", ["totalItems"] = 2, ["orderedItems"] = new JsonArray(Version("second", 20), Version("first", 0)) - }; - await Deliver(carol, "Update", edited); - - var post = await Stored(note); - Assert.Equal(["

first

", "

second

"], post.Revisions.Select(r => r.ContentHtml)); - Assert.Equal("

third

", post.ContentHtml); - } - } -} diff --git a/PrivaPub.Tests/Federation/ForwardedTests.cs b/PrivaPub.Tests/Federation/ForwardedTests.cs deleted file mode 100644 index a5132c2..0000000 --- a/PrivaPub.Tests/Federation/ForwardedTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // Inbox forwarding: a thread's server passes on the activities in it, signed with its own key (Mastodon the replies to - // its accounts' posts and their deletions, Friendica everything in its threads). They used to be refused with 401. - [Trait("Category", "Integration")] - public sealed class ForwardedTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); - - PrivaPub.Models.Statistics.InteractionEvent Received(string activity) => _harness.Ledger.Of("recv").Last(e => e.Activity == activity); - - Task Stored(string objectUri) => - DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(TestContext.Current.CancellationToken); - - // alice follows the thread's author (on its own server), whose post she holds; bob, elsewhere, replies to it - async Task<(RemoteActor Owner, RemoteActor Bob, JsonObject Reply)> Thread() - { - var (_, alice) = await _harness.Persona("alice"); - var owner = new RemoteActor(_harness.Peer, "owner", _harness.Peer.B); - var bob = new RemoteActor(_harness.Peer, "bob"); - await Follows(alice.Id, owner); - var post = PublicNote(owner, "

the thread

"); - await _harness.Deliver(owner, "/human-centipede", Create(owner, post)); - Assert.NotNull(await Stored(IdOf(post))); - - var reply = PublicNote(bob, "

bob's reply as he wrote it

", owner.Id); - reply["inReplyTo"] = IdOf(post); - _harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString()); - return (owner, bob, reply); - } - - [Fact] - public async Task A_reply_the_threads_server_forwards_is_kept_as_its_author_wrote_it() - { - var (owner, bob, reply) = await Thread(); - var claimed = (JsonObject)reply.DeepClone(); - claimed["content"] = "

what the forwarder says bob wrote

"; - - var result = await _harness.Deliver(owner, "/human-centipede", Create(bob, claimed)); - - Assert.Equal((202, "forwarded"), (result.StatusCode, result.Reason)); - var stored = await Stored(IdOf(reply)); - Assert.NotNull(stored); - Assert.Equal(bob.Id, stored.ActorURI); - Assert.Contains("as he wrote it", stored.ContentHtml); - Assert.Equal(("accepted", "stored"), (Processed("Create").Outcome, Processed("Create").Reason)); - Assert.True((await DB.Default.Find().Match(r => r.ObjectURI == IdOf(reply)).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Refetched); - } - - [Fact] - public async Task A_forwarded_edit_applies_the_post_as_its_origin_has_it_now() - { - var (owner, bob, reply) = await Thread(); - await _harness.Deliver(owner, "/human-centipede", Create(bob, reply)); - var edited = (JsonObject)reply.DeepClone(); - edited["content"] = "

bob's reply, edited

"; - edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); - _harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, edited.ToJsonString()); - var claimed = (JsonObject)edited.DeepClone(); - claimed["content"] = "

an edit bob never made

"; - - await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Update", claimed)); - - Assert.Contains("bob's reply, edited", (await Stored(IdOf(reply))).ContentHtml); - } - - [Fact] - public async Task A_forwarded_deletion_waits_for_the_origin_to_say_the_post_is_gone() - { - var (owner, bob, reply) = await Thread(); - await _harness.Deliver(owner, "/human-centipede", Create(bob, reply)); - - await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!)); - Assert.Equal(("dropped", "forwarded-not-gone"), (Processed("Delete").Outcome, Processed("Delete").Reason)); - Assert.NotNull(await Stored(IdOf(reply))); - - _harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 410); - await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!)); - Assert.Null(await Stored(IdOf(reply))); - } - - // the origin answers 404 for a followers-only post to our instance actor too: only its author's server says it is gone - [Fact] - public async Task A_forwarded_deletion_of_a_followers_only_post_is_left_to_its_author() - { - var (owner, bob, reply) = await Thread(); - await _harness.Deliver(owner, "/human-centipede", Create(bob, reply)); - await DB.Default.Update().Match(p => p.ObjectURI == IdOf(reply)).Modify(p => p.Visibility, PostVisibility.FollowersOnly).ExecuteAsync(TestContext.Current.CancellationToken); - _harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404); - - await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!)); - - Assert.Equal(("dropped", "forwarded-private"), (Processed("Delete").Outcome, Processed("Delete").Reason)); - Assert.NotNull(await Stored(IdOf(reply))); - } - - // a forwarded copy is kept apart from the author's own delivery, which carries what the copy could not - [Fact] - public async Task The_authors_own_delivery_is_still_taken_after_a_forwarded_copy_that_failed() - { - var (owner, bob, reply) = await Thread(); - _harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404); - var create = Create(bob, reply); - - await _harness.Deliver(owner, "/human-centipede", create); - Assert.Null(await Stored(IdOf(reply))); - - var direct = await _harness.Deliver(bob, "/human-centipede", create); - Assert.Equal("queued", direct.Reason); - Assert.NotNull(await Stored(IdOf(reply))); - } - - // what cannot be checked against its origin (a vote, a follow, someone else's post) is let go, answered 202: a 401 - // tells the forwarder its signature failed - [Fact] - public async Task What_a_forwarder_cannot_vouch_for_is_let_go_without_an_error() - { - var (owner, bob, reply) = await Thread(); - var like = new JsonObject { ["id"] = NewId(bob, "likes"), ["type"] = "Like", ["actor"] = bob.Id, ["object"] = IdOf(reply) }; - - var result = await _harness.Deliver(owner, "/human-centipede", like); - Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason)); - Assert.Equal("dropped", Received("Like").Outcome); - - var onTheForwardersServer = PublicNote(owner, "

a post of the forwarder's

"); - onTheForwardersServer["attributedTo"] = bob.Id; - result = await _harness.Deliver(owner, "/human-centipede", Create(bob, onTheForwardersServer)); - Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason)); - Assert.Null(await Stored(IdOf(onTheForwardersServer))); - - // our own, coming back from a thread on another server - var (_, carol) = await _harness.Persona("carol"); - var ours = new JsonObject - { - ["id"] = carol.Uri + "/grunts/create-" + Guid.NewGuid().ToString("N"), - ["type"] = "Create", - ["actor"] = carol.Uri, - ["object"] = new JsonObject { ["id"] = carol.Uri + "/scribbles/" + Guid.NewGuid().ToString("N"), ["type"] = "Note", ["attributedTo"] = carol.Uri } - }; - result = await _harness.Deliver(owner, "/human-centipede", ours); - Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason)); - } - } -} diff --git a/PrivaPub.Tests/Federation/GroupTests.cs b/PrivaPub.Tests/Federation/GroupTests.cs deleted file mode 100644 index 8d46859..0000000 --- a/PrivaPub.Tests/Federation/GroupTests.cs +++ /dev/null @@ -1,301 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Social; -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Objects; -using PrivaPub.Federation.Rendering; -using PrivaPub.Federation.Signing; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.StaticServices; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class GroupTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers) - { - var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair(); - var group = new GroupEntity - { - UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), - Kind = kind, - PostingPolicy = policy, - PrivateKey = privateKey, - PublicKey = publicKey, - Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } } - }; - group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true })); - await DB.Default.SaveAsync(group); - return (group, _harness.Local.FromGroup(group)); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static JsonObject Create(RemoteActor author, IEnumerable to, string audience = default) - { - var note = new JsonObject - { - ["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}", - ["type"] = "Note", - ["attributedTo"] = author.Id, - ["content"] = "

to the group

", - ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) - }; - if (audience != default) - note["audience"] = audience; - return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; - } - - [Fact] - public async Task A_follower_posting_to_a_community_is_announced_activity_and_object() - { - var token = TestContext.Current.CancellationToken; - var (_, owner) = await _harness.Persona("owner"); - var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id); - var lemmy = new RemoteActor(_harness.Peer, "lemmy"); - var stranger = new RemoteActor(_harness.Peer, "stranger"); - await _harness.FollowedBy(community, lemmy); - - await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri)); - await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri)); - - Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); - var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); - Assert.Equal(2, announces.Count); - Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue() == "Create" && a["audience"]!.GetValue() == community.Uri); - Assert.Contains(announces, a => a["object"] is JsonValue); - } - - [Fact] - public async Task A_comment_in_a_community_is_announced_activity_and_object_too() - { - var (_, owner) = await _harness.Persona("owner"); - var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id); - var lemmy = new RemoteActor(_harness.Peer, "lemmy"); - var akkoma = new RemoteActor(_harness.Peer, "akkoma"); - await _harness.FollowedBy(community, lemmy); - await _harness.FollowedBy(community, akkoma); - var thread = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri); - await _harness.Deliver(lemmy, "/human-centipede", thread); - var comment = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri); - comment["object"]!["inReplyTo"] = thread["object"]!["id"]!.GetValue(); - - await _harness.Deliver(lemmy, "/human-centipede", comment); - - var commentId = comment["object"]!["id"]!.GetValue(); - var announces = (await _harness.Outgoing(akkoma.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); - Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["object"]?["id"]?.GetValue() == commentId); - Assert.Contains(announces, a => a["object"] is JsonValue bare && bare.GetValue() == commentId); - } - - [Fact] - public async Task An_open_community_takes_posts_from_anyone() - { - var token = TestContext.Current.CancellationToken; - var (_, owner) = await _harness.Persona("owner"); - var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id); - var stranger = new RemoteActor(_harness.Peer, "stranger"); - - await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public })); - - Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); - } - - // Lemmy keeps its user's post pending until the community announces it back, whether anyone there follows the - // community or not - [Fact] - public async Task A_post_into_a_community_is_announced_back_to_its_authors_server_though_nobody_there_follows_it() - { - var (_, owner) = await _harness.Persona("owner"); - var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id); - var stranger = new RemoteActor(_harness.Peer, "stranger", sharedInbox: true); - - await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri)); - - var announces = (await _harness.Outgoing(stranger.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); - Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue() == "Create"); - } - - [Fact] - public async Task Mentioning_a_community_posts_into_it_as_a_titled_page() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id); - var follower = new RemoteActor(_harness.Peer, "follower"); - await _harness.FollowedBy(community, follower); - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token); - - Assert.Equal(community.Id, outcome.Post.GroupId); - var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default); - Assert.Equal("Page", note["type"]!.GetValue()); - Assert.Equal(community.Uri, note["audience"]!.GetValue()); - Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue())); - Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue() == "Announce"); - } - - [Fact] - public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var member = new RemoteActor(_harness.Peer, "member"); - var outsider = new RemoteActor(_harness.Peer, "outsider"); - var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); - await _harness.Remote.GetActor(member.Id, refresh: false, token); - var follower = new RemoteActor(_harness.Peer, "follower"); - await _harness.FollowedBy(alice, follower); - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token); - - Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility); - var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox")); - Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue())); - // the member's copy names the member, so Mastodon and GoToSocial keep it, and names nobody else - Assert.Equal(new[] { member.Id }, create["cc"]!.AsArray().Select(t => t!.GetValue())); - Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue())); - Assert.Contains(create["object"]!["tag"]!.AsArray(), t => t!["type"]!.GetValue() == "Mention" && t["href"]!.GetValue() == member.Id); - Assert.DoesNotContain(Addressing.Public, create.ToJsonString()); - Assert.Empty(await _harness.Outgoing(follower.SharedInbox)); - Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue() == "Announce")); - - var authorizer = new SignedFetchAuthorizer(_harness.Remote, new DbEntities()); - var path = new Uri(outcome.Post.ObjectURI).AbsolutePath; - var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token); - var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token); - Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember)); - Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider)); - Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default)); - Assert.True(await authorizer.MayRead(outcome.Post, asMember, token)); - Assert.False(await authorizer.MayRead(outcome.Post, asOutsider, token)); - Assert.Equal(new[] { member.Id }, SignedFetchAuthorizer.CircleReaders(circleEntity, asMember)); - Assert.True(circle.IsCircle); - Assert.False(circle.Discoverable); - } - - [Fact] - public async Task A_circle_posts_edit_and_delete_reach_each_member_naming_only_that_member() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var first = new RemoteActor(_harness.Peer, "first"); - var second = new RemoteActor(_harness.Peer, "second", _harness.Peer.B); - var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, first.Id, second.Id); - await _harness.Remote.GetActor(first.Id, refresh: false, token); - await _harness.Remote.GetActor(second.Id, refresh: false, token); - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token); - await _harness.Statuses.Edit(alice, outcome.Post.ID, new StatusDraft { Text = "just us, edited" }, token); - await _harness.Statuses.Remove(alice, outcome.Post.ID, token); - - foreach (var (member, other) in new[] { (first, second), (second, first) }) - { - var sent = await _harness.Outgoing(member.Id + "/inbox"); - Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue())); - Assert.All(sent, a => Assert.Contains(member.Id, a["cc"]!.AsArray().Select(c => c!.GetValue()))); - Assert.All(sent, a => Assert.DoesNotContain(other.Id, a.ToJsonString())); - } - } - - [Fact] - public async Task A_reply_to_a_circle_post_stays_in_it_and_a_circle_post_asks_nobody_outside_for_a_quote() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var (_, bob) = await _harness.Persona("bob"); - var (entity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id); - entity.Members.Add(new GroupMember { AvatarId = bob.Id }); - await DB.Default.SaveAsync(entity, token); - var outsider = new RemoteActor(_harness.Peer, "asked"); - var outsiderAccount = await _harness.Remote.GetActor(outsider.Id, refresh: false, token); - var asksFirst = new Post - { - ObjectURI = $"{Origin(outsider)}/notes/{Guid.NewGuid():N}", - ActorURI = outsider.Id, - GroupUserId = outsiderAccount.ID, - AuthorAccountId = outsiderAccount.ID, - IsFederatedCopy = true, - Visibility = PostVisibility.Public, - ContentHtml = "

ask me first

", - QuotePolicy = new InteractionRule { Manual = new() { Addressing.Public } } - }; - await DB.Default.SaveAsync(asksFirst, token); - - var root = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token); - var reply = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "still just us", InReplyTo = root.Post.ID }, token); - var quote = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", GroupId = circle.Id, QuotedStatusId = asksFirst.ID }, token); - - Assert.Equal(PostVisibility.Circle, reply.Post.Visibility); - Assert.Equal(circle.Id, reply.Post.GroupId); - Assert.Equal(422, quote.Status); - Assert.Empty(await _harness.Outgoing(outsider.Id + "/inbox")); - } - - [Fact] - public async Task Only_circle_members_can_post_into_a_circle() - { - var token = TestContext.Current.CancellationToken; - var (aliceRoot, alice) = await _harness.Persona("alice"); - var member = new RemoteActor(_harness.Peer, "member"); - var outsider = new RemoteActor(_harness.Peer, "outsider"); - var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); - - await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock })); - await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock })); - - var stored = await DB.Default.Find().Match(p => p.GroupId == circle.Id).ExecuteAsync(token); - Assert.Equal(member.Id, Assert.Single(stored).ActorURI); - Assert.Equal(PostVisibility.Circle, stored[0].Visibility); - Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id); - var noteId = create["object"]!["id"]!.GetValue(); - _harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString()); - - await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id, - ["to"] = new JsonArray(Addressing.Public), ["object"] = create - }); - - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token); - Assert.Equal(lemmyCommunity.Id, post.AudienceURI); - Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/HttpSignaturesTests.cs b/PrivaPub.Tests/Federation/HttpSignaturesTests.cs deleted file mode 100644 index 6579a91..0000000 --- a/PrivaPub.Tests/Federation/HttpSignaturesTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Signing; - -using System.Globalization; -using System.Security.Cryptography; -using System.Text; - -namespace PrivaPub.Tests.Federation -{ - public class HttpSignaturesTests - { - const string Host = "privapub.test"; - const string KeyId = "https://mastodon.example/users/alice#main-key"; - static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); - static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}"""); - - readonly RSA _key = RSA.Create(2048); - - string PublicKey => _key.ExportSubjectPublicKeyInfoPem(); - - DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default, - string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "") - { - var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture); - var digest = HttpSignatures.Digest(signedBody ?? Body); - var values = new Dictionary - { - ["(request-target)"] = $"post {signedTarget}", - ["host"] = Host, - ["date"] = date, - ["digest"] = digest, - ["content-type"] = "application/activity+json" - }; - var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}")); - var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - - var context = new DefaultHttpContext(); - context.Request.Method = "POST"; - context.Request.Host = new HostString(Host); - context.Request.Path = "/peasants/bob/mouth"; - context.Features.Get().RawTarget = "/peasants/bob/mouth"; - context.Request.Headers["Date"] = date; - context.Request.Headers["Digest"] = digest; - context.Request.Headers["Content-Type"] = "application/activity+json"; - context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}"; - return context; - } - - string Check(HttpContext context, byte[] body = default) - { - var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString()); - var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now); - if (problem != default) - return problem; - return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature) - ? default - : "does not verify"; - } - - [Fact] - public void Accepts_a_mastodon_style_delivery() => - Assert.Null(Check(MastodonRequest())); - - [Fact] - public void Refuses_a_tampered_body() => - Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}"""))); - - [Fact] - public void Refuses_a_signature_for_another_inbox() => - Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth"))); - - [Fact] - public void Refuses_a_signature_older_than_an_hour() => - Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61)))); - - [Fact] - public void Accepts_a_clock_slightly_ahead() => - Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10)))); - - [Fact] - public void Refuses_a_clock_far_ahead() => - Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20)))); - - [Fact] - public void Refuses_a_signature_without_the_request_target() => - Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest"))); - - [Fact] - public void Refuses_a_signature_without_the_host() => - Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest"))); - - [Fact] - public void Refuses_a_body_whose_digest_is_not_signed() => - Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date"))); - - [Fact] - public void Refuses_an_expired_signature() => - Assert.Equal("the signature has expired", - Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\""))); - - // hs2019 leaves the hash to the key: some sign RSA with SHA-512, which rsa-sha256 does not allow; and a SHA-512 digest - [Fact] - public void Accepts_hs2019_hashed_with_sha512_and_a_sha512_digest() - { - var context = new DefaultHttpContext(); - context.Request.Method = "POST"; - context.Request.Host = new HostString(Host); - context.Request.Path = "/peasants/bob/mouth"; - context.Features.Get().RawTarget = "/peasants/bob/mouth"; - var date = Now.ToString("r", CultureInfo.InvariantCulture); - var digest = "SHA-512=" + Convert.ToBase64String(SHA512.HashData(Body)); - context.Request.Headers["Date"] = date; - context.Request.Headers["Digest"] = digest; - var signingString = $"(request-target): post /peasants/bob/mouth\nhost: {Host}\ndate: {date}\ndigest: {digest}"; - var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1)); - string Signed(string algorithm) => $"keyId=\"{KeyId}\",algorithm=\"{algorithm}\",headers=\"(request-target) host date digest\",signature=\"{signature}\""; - - context.Request.Headers["Signature"] = Signed("hs2019"); - var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString()); - Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body, Now)); - Assert.True(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm)); - - context.Request.Headers["Signature"] = Signed("rsa-sha256"); - parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString()); - Assert.False(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm)); - } - - [Fact] - public void Signs_with_the_raw_request_target() - { - var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth"); - context.Features.Get().RawTarget = "/peasants/b%6Fb/mouth"; - - Assert.Null(Check(context)); - } - - [Fact] - public void Verifies_its_own_outbound_signature() - { - var (privateKey, publicKey) = Keys.NewKeyPair(); - var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey }; - using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox"); - HttpSignatures.Sign(outbound, signer, Body); - - var context = new DefaultHttpContext(); - context.Request.Method = "POST"; - context.Request.Host = new HostString("mastodon.example"); - context.Request.Path = "/users/alice/inbox"; - context.Features.Get().RawTarget = "/users/alice/inbox"; - foreach (var header in outbound.Headers) - context.Request.Headers[header.Key] = string.Join(", ", header.Value); - var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString()); - - Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId); - Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body)); - Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)); - } - } -} diff --git a/PrivaPub.Tests/Federation/InboundBlockTests.cs b/PrivaPub.Tests/Federation/InboundBlockTests.cs deleted file mode 100644 index fabc301..0000000 --- a/PrivaPub.Tests/Federation/InboundBlockTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Relationships; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // A remote account's Block of a persona, as Mastodon sends it (the pasture's last Mastodon expected failure, G-0002) - [Trait("Category", "Integration")] - public sealed class InboundBlockTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - [Fact] - public async Task A_block_ends_the_follows_both_ways_hides_the_blocker_and_its_undo_lifts_it() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var carol = new RemoteActor(_harness.Peer, "carol"); - await _harness.FollowedBy(alice, carol); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token); - var block = new JsonObject - { - ["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id, ["object"] = alice.Uri - }; - - await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", block); - - Assert.True(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == carol.Id).ExecuteAnyAsync(token)); - Assert.Contains(carol.Id, await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token)); - Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, carol.Id, token)); - - await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", new JsonObject - { - ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = block - }); - - Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token)); - Assert.Empty(await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token)); - } - - [Fact] - public async Task A_block_of_someone_who_is_not_ours_is_dropped() - { - var token = TestContext.Current.CancellationToken; - var carol = new RemoteActor(_harness.Peer, "carol"); - - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id, - ["object"] = $"{Origin(carol)}/users/somebody-else" - }); - - Assert.False(await DB.Default.Find().Match(b => b.ActorURI == carol.Id).ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/InboundRoutingTests.cs b/PrivaPub.Tests/Federation/InboundRoutingTests.cs deleted file mode 100644 index c9d6d88..0000000 --- a/PrivaPub.Tests/Federation/InboundRoutingTests.cs +++ /dev/null @@ -1,271 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Post; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class InboundRoutingTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; - - async Task LocalPost(string text) - { - var (root, alice) = await _harness.Persona("alice"); - await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken); - return await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task A_downvote_counts_and_its_undo_takes_it_back() - { - var token = TestContext.Current.CancellationToken; - var post = await LocalPost("vote on me"); - var lemmy = new RemoteActor(_harness.Peer, "voter"); - var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI }; - - await _harness.Deliver(lemmy, "/human-centipede", dislike); - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).DownvotesCount); - - await _harness.Deliver(lemmy, "/human-centipede", new JsonObject - { - ["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone() - }); - Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).DownvotesCount); - } - - [Fact] - public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var noteId = NewId(mallory, "notes"); - - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id, - ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" } - }); - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id, - ["object"] = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "

late

", - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }) - } - }); - - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_lemmy_private_message_arrives_as_a_direct_message() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var lemmy = new RemoteActor(_harness.Peer, "pm"); - var messageId = NewId(lemmy, "private_message"); - - await _harness.Deliver(lemmy, "/human-centipede", new JsonObject - { - ["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri), - ["object"] = new JsonObject - { - ["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri), - ["content"] = "

psst

", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O") - } - }); - - var message = await DB.Default.Find().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token); - Assert.NotNull(message); - Assert.Equal(PostVisibility.Direct, message.Visibility); - Assert.NotNull(message.ConversationId); - } - - // Mbin addresses its private messages to the recipient's profile page (its `url`), not to the actor - [Fact] - public async Task A_chat_message_addressed_to_a_personas_profile_page_reaches_the_persona() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mbin = new RemoteActor(_harness.Peer, "mbuser"); - var messageId = NewId(mbin, "f/object"); - - await _harness.Deliver(mbin, "/human-centipede", new JsonObject - { - ["id"] = NewId(mbin, "f/object"), ["type"] = "Create", ["actor"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(), - ["object"] = new JsonObject - { - ["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(), - ["content"] = "

an answer

", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O") - } - }); - - var message = await DB.Default.Find().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token); - Assert.NotNull(message); - Assert.Equal(PostVisibility.Direct, message.Visibility); - var conversation = await DB.Default.Find().OneAsync(message.ConversationId, token); - Assert.Equal(new[] { alice.Id, mbin.Id }.Order(StringComparer.Ordinal), conversation.Members.Select(m => m.AvatarId).Order(StringComparer.Ordinal)); - } - - // Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in - // kind, anyone else with a Note - [Fact] - public async Task A_direct_message_answers_in_the_form_its_recipient_writes_in() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var lemmy = new RemoteActor(_harness.Peer, "pm"); - var mastodon = new RemoteActor(_harness.Peer, "dm"); - async Task Received(RemoteActor sender, string type) - { - var id = NewId(sender, "messages"); - await _harness.Deliver(sender, "/human-centipede", new JsonObject - { - ["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri), - ["object"] = new JsonObject - { - ["id"] = id, ["type"] = type, ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri), - ["content"] = "

psst

", ["published"] = DateTime.UtcNow.ToString("O") - } - }); - return await DB.Default.Find().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token); - } - async Task Answer(RemoteActor to, Post message) - { - var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft - { - Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId - }, token); - Assert.True(answer.Ok, answer.Error); - return (JsonObject)(await _harness.Outgoing(to.Id + "/inbox")).Last(a => a["type"]!.GetValue() == "Create")["object"]!; - } - - var chat = await Answer(lemmy, await Received(lemmy, "ChatMessage")); - var note = await Answer(mastodon, await Received(mastodon, "Note")); - - Assert.Equal("ChatMessage", chat["type"]!.GetValue()); - Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue()); - Assert.Null(chat["cc"]); - Assert.DoesNotContain("h-card", chat["content"]!.GetValue()); - Assert.Equal("Note", note["type"]!.GetValue()); - } - - [Fact] - public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing() - { - var (_, alice) = await _harness.Persona("alice"); - var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9"); - - var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject - { - ["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri - }); - - Assert.Equal(503, result.StatusCode); - Assert.Equal(300, result.RetryAfterSeconds); - } - - // Forte follows a persona by its profile page, as WebFinger names it, rather than by its actor's id; so does its Undo - [Fact] - public async Task A_follow_of_a_personas_profile_page_follows_the_persona() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var forte = new RemoteActor(_harness.Peer, "forte"); - var follow = new JsonObject { ["id"] = NewId(forte, "follow"), ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl }; - - Assert.Equal(202, (await _harness.Deliver(forte, "/human-centipede", follow)).StatusCode); - Assert.True(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id && f.IsAccepted).ExecuteAnyAsync(token)); - Assert.Contains(await _harness.Outgoing(forte.Id + "/inbox"), a => a["type"]!.GetValue() == "Accept"); - - await _harness.Deliver(forte, "/human-centipede", new JsonObject - { - ["id"] = NewId(forte, "undo"), ["type"] = "Undo", ["actor"] = forte.Id, - ["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl } - }); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token)); - } - - // Castopod announces an episode with a note that only links to it: the post takes the episode's sound, title and cover - [Fact] - public async Task A_note_that_only_links_its_authors_episode_carries_the_episodes_sound() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var podcast = new RemoteActor(_harness.Peer, "pod"); - await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following - { - AvatarId = alice.Id, TargetActorURI = podcast.Id, TargetInboxURL = podcast.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted - }, token); - var origin = new Uri(podcast.Id).GetLeftPart(UriPartial.Authority); - var episodePath = $"/episodes/{Guid.NewGuid():N}"; - _harness.Peer.Serve(episodePath, new JsonObject - { - ["id"] = origin + episodePath, ["type"] = "PodcastEpisode", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["description"] = new JsonObject { ["type"] = "Note", ["content"] = "the meadow", ["mediaType"] = "text/markdown" }, - ["image"] = new JsonObject { ["type"] = "Image", ["url"] = origin + "/cover.png" }, - ["audio"] = new JsonObject - { - ["id"] = origin + "/one.mp3", ["type"] = "Audio", ["name"] = "Episode one", ["duration"] = 4.0, - ["url"] = new JsonObject { ["href"] = origin + "/one.mp3", ["type"] = "Link", ["mediaType"] = "audio/mpeg" } - } - }.ToJsonString()); - var noteId = NewId(podcast, "posts"); - - await _harness.Deliver(podcast, "/human-centipede", new JsonObject - { - ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["object"] = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["content"] = $"Episode one
", ["published"] = DateTime.UtcNow.ToString("O") - } - }); - - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token); - Assert.Equal(origin + "/one.mp3", Assert.Single(post.Media).RemoteURL); - Assert.Equal(("Episode one", origin + "/cover.png", 4.0), (post.Title, post.CoverURL, post.Audio.DurationSeconds)); - } - - [Fact] - public async Task A_join_of_a_local_post_is_answered_with_ignore() - { - var post = await LocalPost("not an event"); - var mobilizon = new RemoteActor(_harness.Peer, "joiner"); - - await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject - { - ["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI - }); - - var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox")); - Assert.Equal("Ignore", answer["type"]!.GetValue()); - } - } -} diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs deleted file mode 100644 index 39a85e4..0000000 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ /dev/null @@ -1,611 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Social; -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; - -using System.Security.Cryptography; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class InboxGapTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static JsonObject Follow(RemoteActor follower, string target) => - new() { ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target }; - - Task Stored(RemoteActor actor) => - DB.Default.Find().Match(f => f.ActorURI == actor.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken); - - PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); - - // a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a - // follow it took before it started sending to us, and the community's posts were refused as not followed) - [Fact] - public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var quiet = new RemoteActor(_harness.Peer, "quiet"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token); - var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token); - async Task Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue() == "Follow"); - var made = following.CreatedAt; - Assert.Equal(1, await Follows()); - - // (the store is shared with other tests' requests: what this one got is counted on its own inbox) - async Task After(TimeSpan wait) - { - var before = await Follows(); - await _harness.Follows.ResendPending(made + wait, token); - return await Follows() - before; - } - Assert.Equal(0, await After(TimeSpan.FromMinutes(10))); - Assert.Equal(1, await After(TimeSpan.FromMinutes(16))); - Assert.Equal(0, await After(TimeSpan.FromMinutes(30))); - Assert.Equal(1, await After(TimeSpan.FromMinutes(77))); - Assert.Equal(3, await Follows()); - // each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts - var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue()).ToList(); - Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds); - - // a request older than the count is sent again too; an answered one never - await DB.Default.Update().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token); - Assert.Equal(1, await After(TimeSpan.FromDays(1))); - await _harness.Deliver(quiet, "/human-centipede", new JsonObject - { - ["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2" - }); - Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); - Assert.Equal(0, await After(TimeSpan.FromDays(30))); - } - - // Pixelfed names our post by its page (/@name/, the post's url) in its Like, Announce and their Undo: the post is - // found the same as by its id - // Friendica's activity ids are uniqid(), a prefix and the microsecond: two of its processes answering two follows at - // once gave both Accepts one id, and the second was dropped as a copy of the first, leaving that follow pending - [Fact] - public async Task Two_activities_a_server_gave_one_id_are_both_taken_and_a_true_copy_once() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var dario = new RemoteActor(_harness.Peer, "dario"); - var jun = new RemoteActor(_harness.Peer, "jun"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = dario.Id }, token); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = jun.Id }, token); - Task Of(RemoteActor target) => - DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteSingleAsync(token); - async Task Accept(RemoteActor by, string id) => new() - { - ["id"] = id, - ["type"] = "Accept", - ["actor"] = by.Id, - ["object"] = new JsonObject { ["id"] = (await Of(by)).FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = by.Id } - }; - var shared = NewId(dario, "activity"); - - var first = await _harness.Deliver(dario, "/human-centipede", await Accept(dario, shared)); - var second = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared)); - var copy = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared)); - - Assert.Equal(("queued", "queued", "duplicate"), (first.Reason, second.Reason, copy.Reason)); - Assert.Equal(FollowState.Accepted, (await Of(dario)).State); - Assert.Equal(FollowState.Accepted, (await Of(jun)).State); - } - - // Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used - // to be refused as misattributed (400), so the edit was lost and the deletion left the event in place - [Fact] - public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var group = new RemoteActor(_harness.Peer, "group", type: "Group"); - var organiser = new RemoteActor(_harness.Peer, "organiser"); - await Follows(alice.Id, group); - var path = $"/events/{Guid.NewGuid():N}"; - JsonObject Event(string name) => new() - { - ["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "

bring bread

", - ["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z", - ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers") - }; - _harness.Peer.Serve(path, Event("A picnic").ToJsonString()); - Task Stored() => DB.Default.Find().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token); - - var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic"))); - Assert.Equal(202, created.StatusCode); - Assert.Equal(group.Id, (await Stored()).ActorURI); - - var moved = Event("A picnic, moved indoors"); - moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); - _harness.Peer.Serve(path, moved.ToJsonString()); - await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims"))); - Assert.Equal("A picnic, moved indoors", (await Stored()).Title); - - await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); - Assert.NotNull(await Stored()); - _harness.Peer.Answer(path, 410); - await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); - Assert.Null(await Stored()); - - // attributed to someone of another server, it is still refused - var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B); - var claimed = Event("Someone else's"); - claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}"; - claimed["attributedTo"] = elsewhere.Id; - Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode); - } - - // Funkwhale names its Accept after the Follow it answers (`/accept`), on our origin, not its own - [Fact] - public async Task An_answer_named_after_our_follow_is_taken_and_one_naming_someone_elses_is_not() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var channel = new RemoteActor(_harness.Peer, "channel"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = channel.Id }, token); - var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == channel.Id).ExecuteSingleAsync(token); - - var elsewhere = await _harness.Deliver(channel, "/human-centipede", new JsonObject - { - ["id"] = "https://privapub.test/peasants/somebody/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = following.FollowActivityURI - }); - Assert.Equal(400, elsewhere.StatusCode); - - var named = await _harness.Deliver(channel, "/human-centipede", new JsonObject - { - ["id"] = following.FollowActivityURI + "/accept", ["type"] = "Accept", ["actor"] = channel.Id, - ["object"] = new JsonObject { ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = channel.Id } - }); - Assert.Equal(202, named.StatusCode); - Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); - } - - // Funkwhale's channel deletes its uploads in one Delete, their ids in a list as the object's id (and the Delete has - // no id of its own) - [Fact] - public async Task A_delete_naming_several_objects_removes_each() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var channel = new RemoteActor(_harness.Peer, "channel"); - await Follows(alice.Id, channel); - var first = PublicNote(channel, "

a first track

"); - var second = PublicNote(channel, "

a second track

"); - await _harness.Deliver(channel, "/human-centipede", Create(channel, first)); - await _harness.Deliver(channel, "/human-centipede", Create(channel, second)); - Task Held(JsonObject note) => DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteAnyAsync(token); - Assert.True(await Held(first) && await Held(second)); - - var result = await _harness.Receiver.Receive(channel.Post(Harness.Host, "/human-centipede", new JsonObject - { - ["type"] = "Delete", ["actor"] = channel.Id, - ["object"] = new JsonObject { ["id"] = new JsonArray(IdOf(first), IdOf(second)), ["type"] = "Audio" } - }), default, token); - Assert.Equal(202, result.StatusCode); - var listed = IdOf(first); - var job = await DB.Default.Find().Match(j => j.Kind == PrivaPub.Models.Jobs.JobKind.ProcessInbox - && j.State == PrivaPub.Models.Jobs.JobState.Pending && j.Payload.Contains(listed)).Sort(j => j.CreatedAt, MongoDB.Entities.Order.Descending) - .ExecuteFirstAsync(token); - await _harness.Processor.Handle(job, token); - - Assert.False(await Held(first)); - Assert.False(await Held(second)); - } - - [Fact] - public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var fan = new RemoteActor(_harness.Peer, "fan"); - var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post; - var page = alice.PostHtmlUrl(post.ID); - Assert.NotEqual(post.ObjectURI, page); - var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page }; - - await _harness.Deliver(fan, "/human-centipede", like); - Assert.Equal("accepted", Processed("Like").Outcome); - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like)); - Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - - // a page that is no post of ours stays as it is - var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") }; - await _harness.Deliver(fan, "/human-centipede", elsewhere); - Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason)); - } - - [Fact] - public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri)); - var before = await Stored(bob); - using var rotated = RSA.Create(2048); - var document = bob.Document(); - document["name"] = "Bob Renamed"; - document["summary"] = "

a new bio

"; - document["publicKey"]!["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem(); - _harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString()); - var embedded = (JsonObject)document.DeepClone(); - embedded["name"] = "What the activity claims"; - embedded["updated"] = "2001-01-01T00:00:00Z"; - - var result = await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", embedded)); - - var after = await Stored(bob); - Assert.Equal(202, result.StatusCode); - Assert.Null(before.Name); - Assert.Equal(before.ID, after.ID); - Assert.Equal("Bob Renamed", after.Name); - Assert.Equal("

a new bio

", after.Biography); - Assert.Equal(bob.KeyId, after.PublicKeyId); - Assert.NotEqual(before.PublicKey, after.PublicKey); - Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey); - Assert.Equal(("accepted", "actor-refresh"), (Processed("Update").Outcome, Processed("Update").Reason)); - } - - [Fact] - public async Task A_key_moved_to_a_new_id_replaces_the_old_one() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri)); - using var rotated = RSA.Create(2048); - var document = bob.Document(); - document["publicKey"] = new JsonObject { ["id"] = bob.Id + "#key-2", ["owner"] = bob.Id, ["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem() }; - _harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString()); - - await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", JsonValue.Create(bob.Id)!)); - - var after = await Stored(bob); - Assert.Equal(bob.Id + "#key-2", after.PublicKeyId); - Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey); - } - - [Fact] - public async Task Undoing_a_follow_removes_the_follower_by_the_activity_id_or_by_its_object() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var carol = new RemoteActor(_harness.Peer, "carol"); - var bobFollows = Follow(bob, alice.Uri); - await _harness.Deliver(bob, "/human-centipede", bobFollows); - await _harness.Deliver(carol, "/human-centipede", Follow(carol, alice.Uri)); - Assert.Equal(2, await DB.Default.CountAsync(f => f.LocalActorId == alice.Id, token)); - - await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Undo", JsonValue.Create(IdOf(bobFollows))!)); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(token)); - Assert.Equal(("accepted", "undone"), (Processed("Undo").Outcome, Processed("Undo").Reason)); - - var forgotten = Follow(carol, alice.Uri); - forgotten["id"] = NewId(carol, "follows"); - await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten)); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token)); - - await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten)); - Assert.Equal(("dropped", "unknown-object"), (Processed("Undo").Outcome, Processed("Undo").Reason)); - } - - [Fact] - public async Task A_rejected_quote_request_marks_the_quote_rejected_and_only_the_quoted_author_can_reject_it() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var ann = new RemoteActor(_harness.Peer, "ann"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var note = PublicNote(ann, "

ask me first

", alice.Uri); - note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }); - note["interactionPolicy"] = new JsonObject { ["canQuote"] = new JsonObject { ["manualApproval"] = new JsonArray(Addressing.Public) } }; - await _harness.Deliver(ann, "/human-centipede", Create(ann, note)); - var original = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", QuotedStatusId = original.ID }, token); - Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState); - var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue() == "QuoteRequest"); - - await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!)); - Assert.Equal(QuoteState.Pending, (await DB.Default.Find().OneAsync(outcome.Post.ID, token)).QuoteState); - - await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request)); - - var quoting = await DB.Default.Find().OneAsync(outcome.Post.ID, token); - Assert.Equal(QuoteState.Rejected, quoting.QuoteState); - Assert.Null(quoting.QuoteAuthorizationURI); - Assert.Equal(0, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); - Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason)); - } - - [Fact] - public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var stranger = new RemoteActor(_harness.Peer, "dogs", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - var voter = new RemoteActor(_harness.Peer, "voter"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - var page = PublicNote(poster, "

a post

", community.Id); - page["type"] = "Page"; - page["name"] = "a title"; - page["audience"] = community.Id; - _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); - var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); - var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI, ["audience"] = community.Id }; - - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); - var liked = Processed("Announce"); - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); - var unliked = Processed("Announce"); - await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like)); - var unfollowed = Processed("Announce"); - - //Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server - //is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored - Assert.Equal("accepted", liked.Outcome); - Assert.Equal("accepted", unliked.Outcome); - Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason)); - var after = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal(0, after.FavouritesCount); - Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); - } - - // Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still - // reaches us through it, and only through that community (found by the town's village) - [Fact] - public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - var voter = new RemoteActor(_harness.Peer, "voter"); - var page = PublicNote(poster, "

a thread

", community.Id); - page["type"] = "Page"; - page["name"] = "a thread"; - page["audience"] = community.Id; - _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); - var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token); - Assert.Equal(community.Id, root.AudienceURI); - var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post; - var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI }; - - await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like)); - Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); - Assert.Equal("accepted", Processed("Announce").Outcome); - Assert.Equal(1, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); - Assert.Equal(0, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); - } - - // PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it - [Fact] - public async Task A_post_moved_to_another_community_takes_its_thread_along() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - async Task Announced(JsonObject note) - { - note["audience"] = community.Id; - _harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString()); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note))); - return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); - } - var page = PublicNote(poster, "

misplaced

", community.Id); - page["type"] = "Page"; - page["name"] = "misplaced"; - var post = await Announced(page); - var comment = PublicNote(poster, "

a comment

", community.Id); - comment["inReplyTo"] = IdOf(page); - var answer = await Announced(comment); - var move = new JsonObject - { - ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id - }; - - // only the community it leaves speaks for it - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject - { - ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id - })); - Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move)); - Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(post.ID, token)).AudienceURI); - Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(answer.ID, token)).AudienceURI); - } - - // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce - // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo - [Fact] - public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - var moderator = new RemoteActor(_harness.Peer, "moderator"); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - JsonObject Page(string text, bool commentsEnabled = true) - { - var page = PublicNote(poster, $"

{text}

", community.Id); - page["type"] = "Page"; - page["name"] = text; - page["audience"] = community.Id; - page["commentsEnabled"] = commentsEnabled; - _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); - return page; - } - async Task Announced(JsonObject page) - { - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); - return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); - } - Task Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token); - var post = await Announced(Page("a thread")); - var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI }; - - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt)); - Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - var refused = await Reply(post); - Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error)); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt))); - Assert.Null((await DB.Default.Find().OneAsync(post.ID, token)).LockedAt); - Assert.True((await Reply(post)).Ok); - // a post its community serves locked arrives locked - Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt); - - var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban)); - Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - var banned = await Reply(post); - Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error)); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban))); - Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - Assert.True((await Reply(post)).Ok); - - // Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban, - // never the moderator's own block of the persona - var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; - await _harness.Deliver(moderator, "/human-centipede", direct); - Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason)); - Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token)); - Assert.Equal(422, (await Reply(post)).Status); - await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct)); - Assert.True((await Reply(post)).Ok); - - var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id }; - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere)); - Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - } - - // a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for - // anything else only once its vote is fetched from its own origin - [Fact] - public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched() - { - var token = TestContext.Current.CancellationToken; - var (root, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); - var poster = new RemoteActor(_harness.Peer, "poster"); - var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B); - var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B); - await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - var page = PublicNote(poster, "

a post

", community.Id); - page["type"] = "Page"; - page["name"] = "a title"; - _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); - var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); - var other = await OwnPost(alice, token); - var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI }; - var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI }; - - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); - await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged)); - - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - Assert.True(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); - Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); - Assert.Equal(0, (await DB.Default.Find().OneAsync(other.ID, token)).FavouritesCount); - } - - // a public post of a persona's own, outside any community - static async Task OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token) - { - var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" }; - post.ID = (string)post.GenerateNewID(); - post.ObjectURI = author.PostUri(post.ID); - await DB.Default.SaveAsync(post, token); - return post; - } - - [Fact] - public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow() - { - var token = TestContext.Current.CancellationToken; - var (_, open) = await _harness.Persona("alice"); - await DB.Default.Update().MatchID(open.Id).Modify(a => a.Settings.IsLocked, true).ExecuteAsync(token); - var alice = await _harness.Local.FindById(LocalActorKind.Person, open.Id, token); - var bob = new RemoteActor(_harness.Peer, "bob"); - var carol = new RemoteActor(_harness.Peer, "carol"); - var bobFollows = Follow(bob, alice.Uri); - var carolFollows = Follow(carol, alice.Uri); - Assert.True(alice.ManuallyApprovesFollowers); - - await _harness.Deliver(bob, "/human-centipede", bobFollows); - await _harness.Deliver(carol, "/human-centipede", carolFollows); - - var pending = await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAsync(token); - Assert.Equal(2, pending.Count); - Assert.All(pending, f => Assert.False(f.IsAccepted)); - Assert.Equal(("accepted", "pending"), (Processed("Follow").Outcome, Processed("Follow").Reason)); - var requests = await DB.Default.Find().Match(n => n.AvatarId == alice.Id).ExecuteAsync(token); - Assert.Equal(2, requests.Count); - Assert.All(requests, n => Assert.Equal(NotificationType.FollowRequest, n.Type)); - Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox")); - Assert.Empty(await _harness.Delivery.FollowerInboxes(alice, token)); - - var bobAccount = await Stored(bob); - var carolAccount = await Stored(carol); - Assert.True(await _harness.Follows.Decide(alice, bobAccount.ID, accept: true, token)); - Assert.True(await _harness.Follows.Decide(alice, carolAccount.ID, accept: false, token)); - Assert.False(await _harness.Follows.Decide(alice, carolAccount.ID, accept: true, token)); - - var accept = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox")); - Assert.Equal("Accept", accept["type"]!.GetValue()); - Assert.Equal(alice.Uri, accept["actor"]!.GetValue()); - Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!)); - Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue()); - var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox")); - Assert.Equal("Reject", reject["type"]!.GetValue()); - Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!)); - Assert.True((await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); - Assert.Contains(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Follow).ExecuteAsync(token), - n => n.FromAccountId == bobAccount.ID); - Assert.Equal(new[] { bob.Id + "/inbox" }, await _harness.Delivery.FollowerInboxes(alice, token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs deleted file mode 100644 index b60a455..0000000 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ /dev/null @@ -1,360 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Domain.Timelines; -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Inbox.Handlers; -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Moderation; -using PrivaPub.Domain.Social; -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Objects; -using PrivaPub.Federation.Outbox; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Models.User; -using PrivaPub.Models; -using PrivaPub.StaticServices; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class InboxScenarioTests : IAsyncLifetime - { - const string Host = "privapub.test"; - const string Base = "https://" + Host; - - Peer _peer; - LocalActorService _local; - InboxReceiver _receiver; - InboxProcessor _processor; - DomainBlocks _blocks; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _peer = await Peer.Start(); - var cache = new MemoryCache(new MemoryCacheOptions()); - _local = new LocalActorService(new DbEntities(), new StaticOptions(new AppConfiguration { BackendBaseAddress = Base })); - var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities()); - var queue = new JobQueue(); - var delivery = new DeliveryService(new DbEntities(), queue); - var db = new DbEntities(); - _blocks = new DomainBlocks(NullLogger.Instance); - _receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger.Instance); - var remotePosts = new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews()); - var quotes = new QuoteService(db, remote, remotePosts, _local, delivery, new OutboxPublisher(db, _local, delivery)); - _processor = new InboxProcessor(remote, new IActivityHandler[] - { - new FollowHandler(db, _local, remote, delivery), - new UndoHandler(db, _local, new Reactions(db, delivery)), - new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), remotePosts, new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews(), quotes), - new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery), quotes), - new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue), quotes) - }, NullLogger.Instance); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - async Task LocalAvatar(string name) - { - var (privateKey, publicKey) = Keys.NewKeyPair(); - var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey }; - await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken); - return _local.FromAvatar(avatar); - } - - static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default) - { - var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = id, - ["type"] = "Note", - ["attributedTo"] = attributedTo ?? author.Id, - ["content"] = "

psst

", - ["to"] = new JsonArray(to), - ["cc"] = new JsonArray() - }; - if (context != default) - note["context"] = context; - return new JsonObject - { - ["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}", - ["type"] = "Create", - ["actor"] = author.Id, - ["to"] = new JsonArray(to), - ["object"] = note - }; - } - - async Task Deliver(RemoteActor sender, string path, JsonNode activity) - { - var token = TestContext.Current.CancellationToken; - var result = await _receiver.Receive(sender.Post(Host, path, activity), default, token); - var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue() : default); - var job = await DB.Default.Find().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(token); - if (job != default) - Assert.Equal(JobResult.Done, (await _processor.Handle(job, token)).Result); - return result; - } - - static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority); - - [Fact] - public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var bob = new RemoteActor(_peer, "bob"); - var mallory = new RemoteActor(_peer, "mallory"); - var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}"; - - var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)); - var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)); - - Assert.Equal(202, first.StatusCode); - Assert.Equal(202, injected.StatusCode); - var bobDm = await DB.Default.Find().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token); - var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); - Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId); - var bobConversation = await DB.Default.Find().OneAsync(bobDm.ConversationId, token); - Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id); - } - - [Fact] - public async Task Replies_between_the_same_people_land_in_the_same_conversation() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var bob = new RemoteActor(_peer, "bob"); - - await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); - await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); - - var dms = await DB.Default.Find().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token); - Assert.Equal(2, dms.Count); - Assert.Single(dms.Select(d => d.ConversationId).Distinct()); - } - - [Fact] - public async Task An_activity_id_on_another_origin_is_refused() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var mallory = new RemoteActor(_peer, "mallory"); - var create = DirectCreate(mallory, alice.Uri); - create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}"; - - var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create); - - Assert.Equal(400, result.StatusCode); - } - - [Fact] - public async Task A_note_put_in_someone_elses_mouth_is_refused() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var mallory = new RemoteActor(_peer, "mallory"); - var victim = new RemoteActor(_peer, "victim", _peer.B); - - var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)); - - Assert.Equal(400, result.StatusCode); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); - - // a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and - // it has no such note - var colleague = new RemoteActor(_peer, "colleague"); - result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id)); - Assert.Equal(202, result.StatusCode); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var mallory = new RemoteActor(_peer, "mallory"); - - var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)); - - Assert.Equal(202, result.StatusCode); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token)); - } - - static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc) - { - var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = id, - ["type"] = "Note", - ["attributedTo"] = author.Id, - ["content"] = "

hello

", - ["to"] = new JsonArray(Addressing.Public), - ["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray()) - }; - if (inReplyTo != default) - note["inReplyTo"] = inReplyTo; - return new JsonObject - { - ["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}", - ["type"] = "Create", - ["actor"] = author.Id, - ["object"] = note - }; - } - - [Fact] - public async Task A_public_reply_to_a_local_post_is_kept_and_counted() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" }; - parent.ID = (string)parent.GenerateNewID(); - parent.ObjectURI = alice.PostUri(parent.ID); - await DB.Default.SaveAsync(parent, token); - var bob = new RemoteActor(_peer, "bob"); - - await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI)); - - var reply = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); - Assert.Equal(PostVisibility.Public, reply.Visibility); - Assert.Equal(parent.ID, reply.AnsweringToPostId); - Assert.Equal(alice.Id, reply.InReplyToAccountId); - Assert.Equal(1, (await DB.Default.Find().OneAsync(parent.ID, token)).RepliesCount); - } - - [Fact] - public async Task A_public_post_nobody_here_asked_for_is_dropped() - { - var token = TestContext.Current.CancellationToken; - var bob = new RemoteActor(_peer, "bob"); - - await Deliver(bob, "/human-centipede", PublicCreate(bob)); - - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_mention_is_kept_and_linked_to_the_local_persona() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var bob = new RemoteActor(_peer, "bob"); - var create = PublicCreate(bob, default, alice.Uri); - create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle }); - - await Deliver(bob, "/human-centipede", create); - - var post = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); - var mention = Assert.Single(post.Mentions); - Assert.True(mention.IsLocal); - Assert.Equal(alice.Id, mention.AccountId); - } - - // GoToSocial kept a persona in cc after an edit took the @name out, and Akkoma addresses with to[] alone: the persona it - // is addressed to sees a followers-only post as a silent mention, which no list shows as a mention (found by the town) - [Fact] - public async Task A_followers_only_post_addressed_to_a_persona_without_naming_it_is_a_silent_mention() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var bob = new RemoteActor(_peer, "bob"); - var create = PublicCreate(bob, default, alice.Uri); - create["object"]!["to"] = bob.Id + "/followers"; - create["object"]!["cc"] = alice.Uri; - - await Deliver(bob, "/human-centipede", create); - - var post = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); - Assert.Equal(PostVisibility.FollowersOnly, post.Visibility); - var mention = Assert.Single(post.Mentions); - Assert.True(mention.Silent); - Assert.Equal(alice.Id, mention.AccountId); - Assert.True(await PrivaPub.Domain.Privacy.VisibilityPolicy.CanSee(post, alice.Id, token)); - } - - [Fact] - public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var bob = new RemoteActor(_peer, "bob", _peer.B); - _blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } }); - var before = _peer.Requests.Count; - - var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); - - Assert.Equal(202, result.StatusCode); - Assert.Equal(before, _peer.Requests.Count); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_bad_signature_is_a_401() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var mallory = new RemoteActor(_peer, "mallory"); - var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri)); - request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA"); - - Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode); - } - - [Fact] - public async Task Junk_is_a_400_never_a_500() - { - var token = TestContext.Current.CancellationToken; - var alice = await LocalAvatar("alice"); - var mallory = new RemoteActor(_peer, "mallory"); - - foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } }) - Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode); - } - - [Fact] - public async Task A_circle_only_takes_follow_requests() - { - var token = TestContext.Current.CancellationToken; - var (privateKey, publicKey) = Keys.NewKeyPair(); - var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey }; - await DB.Default.SaveAsync(circle, token); - var bob = new RemoteActor(_peer, "bob"); - var actor = _local.FromGroup(circle); - var follow = new JsonObject - { - ["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}", - ["type"] = "Follow", - ["actor"] = bob.Id, - ["object"] = actor.Uri - }; - - Assert.True(actor.IsCircle); - Assert.True(actor.ManuallyApprovesFollowers); - Assert.False(actor.Discoverable); - Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode); - var request = await DB.Default.Find().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token); - Assert.False(request.IsAccepted); - Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id); - } - } -} diff --git a/PrivaPub.Tests/Federation/IntegrityProofTests.cs b/PrivaPub.Tests/Federation/IntegrityProofTests.cs deleted file mode 100644 index 26f1a6a..0000000 --- a/PrivaPub.Tests/Federation/IntegrityProofTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Rendering; -using PrivaPub.Federation.Signing; -using PrivaPub.Models.Federation; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; - -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // FEP-8b32 proofs (eddsa-jcs-2022) by a persona's Ed25519 key, which its actor names as a FEP-521a Multikey - public sealed class IntegrityProofTests - { - [Fact] - public void Canonical_json_is_rfc_8785s() - { - // RFC 8785, section 3.2.2 - var input = JsonNode.Parse("{\"numbers\":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001]," - + "\"string\":\"\\u20ac$\\u000F\\u000aA'\\u0042\\u0022\\u005c\\\\\\\"\\/\",\"literals\":[null,true,false]}"); - - Assert.Equal("{\"literals\":[null,true,false],\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27],\"string\":\"€$\\u000f\\nA'B\\\"\\\\\\\\\\\"/\"}", - Jcs.Serialize(input)); - } - - [Fact] - public void Base58_and_multikeys_read_back_what_they_write() - { - Assert.Equal("2NEpo7TZRRrLZSi2U", IntegrityProofs.Base58(Encoding.ASCII.GetBytes("Hello World!"))); - Assert.Equal("112", IntegrityProofs.Base58(new byte[] { 0, 0, 1 })); - Assert.Equal(new byte[] { 0, 0, 1 }, IntegrityProofs.FromBase58("112")); - - var publicKey = IntegrityProofs.PublicKey(IntegrityProofs.NewSeed()); - var multikey = IntegrityProofs.Multikey(publicKey); - Assert.StartsWith("z6Mk", multikey); - Assert.Equal(publicKey, IntegrityProofs.FromMultikey(multikey)); - } - - [Fact] - public void A_proof_verifies_with_its_key_and_with_nothing_changed() - { - var seed = IntegrityProofs.NewSeed(); - var activity = new JsonObject - { - ["@context"] = ActivityPubRenderer.Context(), ["id"] = "https://privapub.test/peasants/alice/grunts/1", ["type"] = "Create", - ["actor"] = "https://privapub.test/peasants/alice", ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "

ciao, è così

", ["width"] = 4.5 } - }; - activity["proof"] = IntegrityProofs.Create(activity, "https://privapub.test/peasants/alice#ed25519-key", seed, DateTime.UtcNow); - - var delivered = JsonNode.Parse(activity.ToJsonString())!.AsObject(); - Assert.True(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(seed))); - Assert.Equal("assertionMethod", delivered["proof"]!["proofPurpose"]!.GetValue()); - - var changed = delivered.DeepClone().AsObject(); - changed["object"]!["content"] = "

something else

"; - Assert.False(IntegrityProofs.Verify(changed, IntegrityProofs.PublicKey(seed))); - Assert.False(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(IntegrityProofs.NewSeed()))); - } - - [Fact] - public void A_persona_with_a_key_names_it_as_a_multikey() - { - var seed = IntegrityProofs.NewSeed(); - var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = LocalActorKind.Person, SigningKey = seed }); - var keyless = ActivityPubRenderer.Actor(new LocalActor { Id = "b", UserName = "bob", BaseAddress = Harness.Base, Kind = LocalActorKind.Person }); - - var key = Assert.Single(actor["assertionMethod"]!.AsArray())!; - Assert.Equal(($"{Harness.Base}/peasants/alice#ed25519-key", "Multikey", $"{Harness.Base}/peasants/alice"), - (key["id"]!.GetValue(), key["type"]!.GetValue(), key["controller"]!.GetValue())); - Assert.Equal(IntegrityProofs.PublicKey(seed), IntegrityProofs.FromMultikey(key["publicKeyMultibase"]!.GetValue())); - Assert.Null(keyless["assertionMethod"]); - } - } - - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class DeliveredProofTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - // what goes to a relay carries the persona's proof, to be forwarded on its strength; what goes to a server directly - // does not (Mitra refuses a proof by a key it has not read yet); another's activity passed on never gets ours - [Fact] - public async Task A_personas_activity_goes_to_a_relay_with_its_proof_and_elsewhere_without() - { - var token = TestContext.Current.CancellationToken; - var (_, persona) = await _harness.Persona("alice"); - var seed = IntegrityProofs.NewSeed(); - await DB.Default.Update().MatchID(persona.Id).Modify(a => a.SigningKey, seed).ExecuteAsync(token); - var alice = _harness.Local.FromAvatar(await DB.Default.Find().MatchID(persona.Id).ExecuteSingleAsync(token)); - var relayInbox = $"{_harness.Peer.A}/relay-{Guid.NewGuid():N}/inbox"; - var serverInbox = _harness.Peer.A + "/proofs/inbox"; - await DB.Default.SaveAsync(new RelaySubscription - { - Configured = relayInbox, ActorURI = relayInbox.Replace("/inbox", "/actor"), InboxURL = relayInbox, State = RelayState.Accepted - }, token); - var own = new JsonObject { ["id"] = alice.ActivityUri(Guid.NewGuid().ToString("N")), ["type"] = "Create", ["actor"] = alice.Uri, ["object"] = new JsonObject { ["type"] = "Note" } }; - var passedOn = new JsonObject { ["id"] = $"https://elsewhere.example/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = "https://elsewhere.example/users/bob" }; - - try - { - await _harness.Delivery.Enqueue(alice, new[] { relayInbox, serverInbox }, own, token); - await _harness.Delivery.Enqueue(alice, new[] { relayInbox }, passedOn, token); - - var toRelay = await _harness.Outgoing(relayInbox); - var signed = Assert.Single(toRelay, a => a["actor"]!.GetValue() == alice.Uri); - Assert.Equal(alice.AssertionKeyId, signed["proof"]!["verificationMethod"]!.GetValue()); - Assert.True(IntegrityProofs.Verify(signed, IntegrityProofs.PublicKey(seed))); - Assert.Null(Assert.Single(toRelay, a => a["actor"]!.GetValue() != alice.Uri)["proof"]); - Assert.Null(Assert.Single(await _harness.Outgoing(serverInbox))["proof"]); - Assert.Null(own["proof"]); - } - finally - { - await DB.Default.DeleteAsync(s => s.InboxURL == relayInbox); - } - } - - // a post forwarded by another server, which its origin does not serve: taken on its author's proof, never without - [Fact] - public async Task A_forwarded_post_with_its_authors_proof_is_taken_as_it_came() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var author = new RemoteActor(_harness.Peer, "author", ed25519: true); - var forwarder = new RemoteActor(_harness.Peer, "forwarder", _harness.Peer.B); - await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following - { - AvatarId = alice.Id, TargetActorURI = author.Id, TargetInboxURL = author.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted - }, token); - JsonObject Create(string text) - { - var noteId = $"{new Uri(author.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}"; - return new JsonObject - { - ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id, - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["object"] = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O") - } - }; - } - var proven = author.Prove(Create("proven")); - var tampered = author.Prove(Create("as written")); - tampered["object"]!["content"] = "

changed on the way

"; - var bare = Create("unproven"); - - await _harness.Deliver(forwarder, "/human-centipede", proven); - await _harness.Deliver(forwarder, "/human-centipede", tampered); - await _harness.Deliver(forwarder, "/human-centipede", bare); - - Task Held(JsonObject create) => DB.Default.Find().Match(p => p.ObjectURI == create["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token); - Assert.True(await Held(proven)); - Assert.False(await Held(tampered)); - Assert.False(await Held(bare)); - } - } -} diff --git a/PrivaPub.Tests/Federation/InteractionPolicyTests.cs b/PrivaPub.Tests/Federation/InteractionPolicyTests.cs deleted file mode 100644 index 0e8c0a1..0000000 --- a/PrivaPub.Tests/Federation/InteractionPolicyTests.cs +++ /dev/null @@ -1,284 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // GoToSocial's interaction policies: who may reply to, like and boost a post at once, who must ask its author first, and - // who may not - [Trait("Category", "Integration")] - public sealed class InteractionPolicyTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static JsonObject Rule(params string[] automatic) => Rule(automatic, Array.Empty()); - - static JsonObject Rule(string[] automatic, string[] manual) => new() - { - ["automaticApproval"] = new JsonArray(automatic.Select(a => (JsonNode)a).ToArray()), - ["manualApproval"] = new JsonArray(manual.Select(m => (JsonNode)m).ToArray()) - }; - - // bob's public post, which alice follows bob to see, under the policy given - async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Posted(JsonObject policy) - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var fan = new RemoteActor(_harness.Peer, "fan"); - await Follows(alice.Id, bob); - await _harness.FollowedBy(alice, fan); - var note = PublicNote(bob, "

a post with rules

"); - note["interactionPolicy"] = policy; - await _harness.Deliver(bob, "/human-centipede", Create(bob, note)); - var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); - return (alice, bob, post, fan); - } - - Task> To(RemoteActor actor) => _harness.Outgoing(actor.Id + "/inbox"); - - Task> ToShared(RemoteActor actor) => _harness.Outgoing(actor.SharedInbox); - - JsonObject Authorization(RemoteActor author, string type, string interaction, Post target) - { - var path = $"/authorizations/{Guid.NewGuid():N}"; - var stamp = new JsonObject - { - ["id"] = Origin(author) + path, ["type"] = type, ["attributedTo"] = author.Id, - ["interactingObject"] = interaction, ["interactionTarget"] = target.ObjectURI - }; - _harness.Peer.Serve(path, stamp.ToJsonString()); - return stamp; - } - - [Fact] - public async Task A_reply_the_policy_lets_in_at_once_goes_out_as_always_and_one_it_shuts_out_is_refused() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, _) = await Posted(new JsonObject { ["canReply"] = Rule("https://www.w3.org/ns/activitystreams#Public") }); - var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an open reply", InReplyTo = post.ID }, token); - Assert.Equal(ApprovalState.None, reply.Post.Approval); - Assert.Contains(await To(bob), a => a["type"]!.GetValue() == "Create"); - - var (carol, dave, shut, _) = await Posted(new JsonObject { ["canReply"] = Rule(new string[0], new string[0]) }); - var refused = await _harness.Statuses.Publish(carol, new StatusDraft { Text = "a shut-out reply", InReplyTo = shut.ID }, token); - Assert.Equal(422, refused.Status); - } - - [Fact] - public async Task A_reply_its_author_must_approve_is_asked_for_and_goes_out_with_the_authorization_once_given() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, fan) = await Posted(new JsonObject - { - ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) - }); - - var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post; - - Assert.Equal(ApprovalState.Pending, reply.Approval); - var request = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); - Assert.Equal(post.ObjectURI, request["object"]!.GetValue()); - Assert.Equal(reply.ObjectURI, request["instrument"]!["id"]!.GetValue()); - Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - - var stamp = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, post); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, - ["object"] = new JsonObject { ["type"] = "ReplyRequest", ["id"] = request["id"]!.GetValue() }, - ["result"] = IdOf(stamp) - }); - - var after = await DB.Default.Find().OneAsync(reply.ID, token); - Assert.Equal((ApprovalState.Accepted, IdOf(stamp)), (after.Approval, after.ApprovalURI)); - var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - Assert.Equal(IdOf(stamp), create["object"]!["replyAuthorization"]!.GetValue()); - } - - [Fact] - public async Task A_rejected_reply_stays_ours_alone_and_a_forged_authorization_changes_nothing() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, fan) = await Posted(new JsonObject - { - ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) - }); - var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post; - var request = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); - - // an authorization naming another post is no authorization - var forged = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, new Post { ObjectURI = "https://elsewhere.invalid/post" }); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue(), ["result"] = IdOf(forged) - }); - Assert.Equal(ApprovalState.Pending, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); - - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue() - }); - Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); - Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - } - - [Fact] - public async Task A_like_and_a_boost_its_author_must_approve_are_asked_for_and_a_shut_out_boost_is_refused() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, fan) = await Posted(new JsonObject - { - ["canLike"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }), - ["canAnnounce"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) - }); - - await _harness.Statuses.Favourite(alice, post.ID, true, token); - var like = await DB.Default.Find().Match(f => f.AccountId == alice.Id && f.PostId == post.ID).ExecuteSingleAsync(token); - Assert.Equal(ApprovalState.Pending, like.Approval); - var likeRequest = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "LikeRequest"); - Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue() == "Like"); - var stamp = Authorization(bob, "LikeAuthorization", like.ActivityURI, post); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = likeRequest["id"]!.GetValue(), ["result"] = IdOf(stamp) - }); - Assert.Equal(ApprovalState.Accepted, (await DB.Default.Find().OneAsync(like.ID, token)).Approval); - Assert.Equal(IdOf(stamp), Assert.Single(await To(bob), a => a["type"]!.GetValue() == "Like")["likeAuthorization"]!.GetValue()); - - var boost = (await _harness.Statuses.Reblog(alice, post.ID, true, PostVisibility.Public, token)).Post; - Assert.Equal(ApprovalState.Pending, boost.Approval); - var announceRequest = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"), a => a["type"]!.GetValue() == "AnnounceRequest"); - Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Announce"); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = announceRequest["id"]!.GetValue() - }); - Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(boost.ID, token)).Approval); - - var (carol, _, shut, _) = await Posted(new JsonObject { ["canAnnounce"] = Rule(new string[0], new string[0]) }); - Assert.Equal(422, (await _harness.Statuses.Reblog(carol, shut.ID, true, PostVisibility.Public, token)).Status); - } - - // as a third party: a reply bob's policy does not let in at once is shown only with bob's authorization - [Fact] - public async Task A_reply_to_a_post_that_needs_its_authors_approval_is_kept_only_with_that_approval() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, _) = await Posted(new JsonObject - { - ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) - }); - var carol = new RemoteActor(_harness.Peer, "carol"); - await Follows(alice.Id, carol); - - var unasked = PublicNote(carol, "

barging in

"); - unasked["inReplyTo"] = post.ObjectURI; - await _harness.Deliver(carol, "/human-centipede", Create(carol, unasked)); - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(unasked)).ExecuteAnyAsync(token)); - - var asked = PublicNote(carol, "

with leave

"); - asked["inReplyTo"] = post.ObjectURI; - asked["replyAuthorization"] = IdOf(Authorization(bob, "ReplyAuthorization", IdOf(asked), post)); - await _harness.Deliver(carol, "/human-centipede", Create(carol, asked)); - Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token)); - } - - // bob's public post whose replies wait for his approval (FEP-5624's canReply, as PeerTube sets it on a video whose - // comments are moderated), which alice follows bob to see - async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Moderated(JsonNode canReply) - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var fan = new RemoteActor(_harness.Peer, "fan"); - await Follows(alice.Id, bob); - await _harness.FollowedBy(alice, fan); - var note = PublicNote(bob, "

a moderated post

"); - note["canReply"] = canReply; - await _harness.Deliver(bob, "/human-centipede", Create(bob, note)); - var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); - return (alice, bob, post, fan); - } - - Task Answer(RemoteActor from, string type, Post reply, string inReplyTo) => _harness.Deliver(from, "/human-centipede", new JsonObject - { - ["id"] = NewId(from, "approvals"), ["type"] = type, ["actor"] = from.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = inReplyTo - }); - - [Fact] - public async Task A_reply_its_author_approves_goes_to_that_author_alone_then_out_with_the_approval() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public"); - - var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to approve", InReplyTo = post.ID }, token)).Post; - - Assert.Equal(ApprovalState.Pending, reply.Approval); - var asked = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "Create"); - Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue()); - Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); - Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - - var approval = NewId(bob, "approve-reply"); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = approval, ["type"] = "ApproveReply", ["actor"] = bob.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = post.ObjectURI - }); - - var after = await DB.Default.Find().OneAsync(reply.ID, token); - Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI)); - var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue()); - Assert.Null(create["object"]!["replyAuthorization"]); - } - - [Fact] - public async Task A_refused_reply_stays_ours_alone_and_an_approval_from_elsewhere_changes_nothing() - { - var token = TestContext.Current.CancellationToken; - var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public"); - var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to refuse", InReplyTo = post.ID }, token)).Post; - - // an approval by someone else than the post's author, or naming another post, is no approval - await Answer(new RemoteActor(_harness.Peer, "mallory"), "ApproveReply", reply, post.ObjectURI); - await Answer(bob, "ApproveReply", reply, "https://elsewhere.invalid/post"); - Assert.Equal(ApprovalState.Pending, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); - - await Answer(bob, "RejectReply", reply, post.ObjectURI); - Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); - Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); - } - - [Fact] - public async Task Only_those_its_canReply_names_or_it_mentions_may_reply() - { - var (alice, _, post, _) = await Moderated(new JsonArray()); - - var refused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nobody asked me", InReplyTo = post.ID }, - TestContext.Current.CancellationToken); - - Assert.Equal(422, refused.Status); - } - } -} diff --git a/PrivaPub.Tests/Federation/InteractionTests.cs b/PrivaPub.Tests/Federation/InteractionTests.cs deleted file mode 100644 index 99b63af..0000000 --- a/PrivaPub.Tests/Federation/InteractionTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Post; -using PrivaPub.ClientModels.Social; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class InteractionTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - async Task<(string Root, Post Post)> LocalPost(string visibility = default) - { - var (root, alice) = await _harness.Persona("alice"); - var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility }, - TestContext.Current.CancellationToken)).Data; - return (root, await DB.Default.Find().OneAsync(view.Id)); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - [Fact] - public async Task A_like_counts_once_notifies_and_can_be_undone() - { - var token = TestContext.Current.CancellationToken; - var (_, post) = await LocalPost(); - var carol = new RemoteActor(_harness.Peer, "carol"); - var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }; - - await _harness.Deliver(carol, "/human-centipede", like); - await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); - - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - Assert.Equal(NotificationType.Favourite, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); - - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like - }); - Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - } - - [Fact] - public async Task A_stranger_cannot_like_a_followers_only_post() - { - var token = TestContext.Current.CancellationToken; - var (_, post) = await LocalPost("followersonly"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI - }); - - Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - } - - // Our followers-only posts are addressed to the followers collection, never to each follower, so a follower is found - // among our followers, not in to/cc. The town found every follower's like, reaction and downvote dropped (G-0001). - [Fact] - public async Task A_follower_likes_reacts_to_and_downvotes_a_followers_only_post() - { - var token = TestContext.Current.CancellationToken; - var (_, post) = await LocalPost("followersonly"); - var carol = new RemoteActor(_harness.Peer, "carol"); - await _harness.FollowedBy(_harness.Local.FromAvatar(await DB.Default.Find().OneAsync(post.GroupUserId, token)), carol); - - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI - }); - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI, - ["content"] = "🔥" - }); - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI - }); - - // the downvote counts, and takes the place of carol's like (one vote each, as Lemmy votes) - var stored = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal(0, stored.FavouritesCount); - Assert.Equal(1, stored.DownvotesCount); - Assert.True(await DB.Default.Find().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_vote_turned_the_other_way_replaces_the_first_as_lemmy_sends_it() - { - var token = TestContext.Current.CancellationToken; - var (_, post) = await LocalPost(); - var voter = new RemoteActor(_harness.Peer, "voter"); - var other = new RemoteActor(_harness.Peer, "other"); - JsonObject Vote(RemoteActor actor, string type) => new() - { - ["id"] = $"{Origin(actor)}/{type.ToLowerInvariant()}s/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = post.ObjectURI - }; - - await _harness.Deliver(other, "/human-centipede", Vote(other, "Like")); - await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); - await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Like")); - var up = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal((2, 0), (up.FavouritesCount, up.DownvotesCount)); - - await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); - var down = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount)); - Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var carol = new RemoteActor(_harness.Peer, "carol"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var circle = new Group - { - UserName = $"circle{Guid.NewGuid():N}"[..20], Name = "a circle", Kind = GroupKind.Circle, OwnerAvatarId = alice.Id, - Members = { new() { AvatarId = alice.Id }, new() { AvatarId = carol.Id, IsForeign = true } } - }; - await DB.Default.SaveAsync(circle, token); - var post = new Post - { - GroupUserId = alice.Id, AuthorAccountId = alice.Id, GroupId = circle.ID, Visibility = PostVisibility.Circle, Text = "only us", - ObjectURI = $"{alice.Uri}/scribbles/{Guid.NewGuid():N}" - }; - await DB.Default.SaveAsync(post, token); - - foreach (var who in new[] { carol, mallory }) - await _harness.Deliver(who, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(who)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = who.Id, ["object"] = post.ObjectURI - }); - - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - Assert.True(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone() - { - var token = TestContext.Current.CancellationToken; - var (_, post) = await LocalPost(); - var carol = new RemoteActor(_harness.Peer, "carol"); - var announce = new JsonObject - { - ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI, - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers") - }; - - await _harness.Deliver(carol, "/human-centipede", announce); - - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); - Assert.Equal(NotificationType.Reblog, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); - - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue() - }); - Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == announce["id"]!.GetValue()).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home() - { - var token = TestContext.Current.CancellationToken; - var (bobRoot, bob) = await _harness.Persona("bob"); - var carol = new RemoteActor(_harness.Peer, "carol"); - var dave = new RemoteActor(_harness.Peer, "dave"); - var stranger = new RemoteActor(_harness.Peer, "stranger"); - await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token); - await DB.Default.Update().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); - var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}"; - _harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "

worth sharing

", ["to"] = new JsonArray(Addressing.Public) - }.ToJsonString()); - - await _harness.Deliver(stranger, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId, - ["to"] = new JsonArray(Addressing.Public) - }); - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token)); - - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId, - ["to"] = new JsonArray(Addressing.Public) - }); - - var home = (List)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data; - var boosted = Assert.Single(home); - Assert.Equal("

worth sharing

", boosted.ReblogOf.ContentHtml); - Assert.Equal(carol.Id, boosted.AuthorActorURI); - } - } -} diff --git a/PrivaPub.Tests/Federation/JobHandlerTests.cs b/PrivaPub.Tests/Federation/JobHandlerTests.cs deleted file mode 100644 index 84ff0ed..0000000 --- a/PrivaPub.Tests/Federation/JobHandlerTests.cs +++ /dev/null @@ -1,811 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using OpenIddict.Abstractions; - -using PrivaPub.Api.Mastodon.Auth; -using PrivaPub.Domain.Content; -using PrivaPub.Domain.Media; -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Objects; -using PrivaPub.Federation.Outbox; -using PrivaPub.Federation.Signing; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Media; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class JobHandlerTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - sealed class PeerDescriber : InstanceDescriber - { - readonly string _peer; - - public PeerDescriber(IFederationHttp http, string peer) : base(http) => _peer = peer; - - protected override string Address(string url) => _peer + new Uri(url).PathAndQuery; - } - - [Fact] - public async Task Ancestors_are_fetched_one_job_at_a_time_and_stop_at_the_depth_limit() - { - var token = TestContext.Current.CancellationToken; - var poster = new RemoteActor(_harness.Peer, "poster"); - var chain = new List(); - for (var i = 0; i < RemotePosts.MaxDepth + 3; i++) - { - var note = PublicNote(poster, $"

number {i}

"); - if (i > 0) - note["inReplyTo"] = IdOf(chain[^1]); - _harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString()); - chain.Add(note); - } - var leaf = new Post - { - ObjectURI = NewId(poster, "notes"), ActorURI = poster.Id, IsFederatedCopy = true, Visibility = PostVisibility.Public, - InReplyToURI = IdOf(chain[^1]), ContentHtml = "

the leaf

" - }; - await DB.Default.SaveAsync(leaf, token); - await _harness.Queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(leaf.ID, 1)), "127.0.0.1", "ancestors|" + leaf.ID, token); - var handler = new AncestorsJobHandler(_harness.Db, _harness.RemotePosts); - - var keys = new List { "ancestors|" + leaf.ID }; - var ran = 0; - while (await DB.Default.Find().Match(j => j.Kind == JobKind.FetchAncestors && j.State == JobState.Pending && keys.Contains(j.DedupeKey)) - .ExecuteFirstAsync(token) is { } job) - { - Assert.Equal(JobResult.Done, (await handler.Handle(job, token)).Result); - await DB.Default.Update().MatchID(job.ID).Modify(j => j.State, JobState.Done).ExecuteAsync(token); - ran++; - var uris = chain.Select(IdOf).ToList(); - keys = (await DB.Default.Find().Match(p => uris.Contains(p.ObjectURI)).ExecuteAsync(token)).Select(p => "ancestors|" + p.ID).Append("ancestors|" + leaf.ID).ToList(); - Assert.True(ran <= RemotePosts.MaxDepth, "the chain did not stop"); - } - - var all = chain.Select(IdOf).ToList(); - var stored = (await DB.Default.Find().Match(p => all.Contains(p.ObjectURI)).ExecuteAsync(token)).ToDictionary(p => p.ObjectURI); - Assert.Equal(RemotePosts.MaxDepth, ran); - Assert.Equal(all.Skip(3), stored.Keys.OrderBy(all.IndexOf)); - Assert.Equal(stored[all[^1]].ID, (await DB.Default.Find().OneAsync(leaf.ID, token)).AnsweringToPostId); - for (var i = 4; i < all.Count; i++) - Assert.Equal(stored[all[i - 1]].ID, stored[all[i]].AnsweringToPostId); - Assert.Null(stored[all[3]].AnsweringToPostId); - Assert.Equal(all[2], stored[all[3]].InReplyToURI); - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(all[2]).AbsolutePath); - } - - JsonObject Served(JsonObject document) - { - _harness.Peer.Serve(new Uri(IdOf(document)).AbsolutePath, document.ToJsonString()); - return document; - } - - static JsonObject Reply(RemoteActor author, string text, JsonObject parent) - { - var note = PublicNote(author, $"

{text}

"); - note["inReplyTo"] = IdOf(parent); - return note; - } - - static async Task Held(JsonObject note) - { - var post = new Post - { - ObjectURI = IdOf(note), ActorURI = note["attributedTo"]!.GetValue(), IsFederatedCopy = true, Visibility = PostVisibility.Public, - ContentHtml = note["content"]!.GetValue() - }; - await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); - return post; - } - - // runs the FetchReplies job for the post, and those it queues for this peer's posts, until none is left; how many ran - async Task RunReplies(Post first) - { - var token = TestContext.Current.CancellationToken; - var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue); - var origin = new Uri(first.ObjectURI).GetLeftPart(UriPartial.Authority); - await _harness.Queue.EnqueueMany(new[] { RepliesJobHandler.For(first, 1) }, token); - var ran = 0; - while (true) - { - var mine = new List(); - foreach (var job in await DB.Default.Find().Match(j => j.Kind == JobKind.FetchReplies && j.State == JobState.Pending).ExecuteAsync(token)) - { - var post = await DB.Default.Find().OneAsync(JsonSerializer.Deserialize(job.Payload)!.PostId, token); - if (post?.ObjectURI.StartsWith(origin) == true) - mine.Add(job); - } - if (mine.Count == 0) - return ran; - foreach (var job in mine) - { - Assert.Equal(JobResult.Done, (await handler.Handle(job, token)).Result); - await DB.Default.Update().MatchID(job.ID).Modify(j => j.State, JobState.Done).ExecuteAsync(token); - ran++; - } - } - } - - static async Task> Stored(params JsonObject[] notes) - { - var uris = notes.Select(IdOf).ToList(); - return (await DB.Default.Find().Match(p => uris.Contains(p.ObjectURI)).ExecuteAsync(TestContext.Current.CancellationToken)) - .ToDictionary(p => p.ObjectURI); - } - - [Fact] - public async Task A_threads_replies_are_read_from_their_servers_two_levels_down_and_only_public_ones_kept() - { - var poster = new RemoteActor(_harness.Peer, "poster"); - var answerer = new RemoteActor(_harness.Peer, "answerer"); - var root = PublicNote(poster, "

the root

"); - // what a post carries is as old as the post: the collection is read again by its id - root["replies"] = new JsonObject - { - ["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray() } - }; - Served(root); - var first = Reply(answerer, "first answer", root); - var second = Served(Reply(poster, "second answer", root)); - var hidden = Reply(answerer, "for followers", root); - hidden["to"] = new JsonArray(answerer.Id + "/followers"); - hidden["cc"] = new JsonArray(); - Served(hidden); - var nested = Reply(poster, "nested", first); - var deeper = Served(Reply(answerer, "too deep", nested)); - first["replies"] = IdOf(first) + "/replies"; - Served(first); - nested["replies"] = IdOf(nested) + "/replies"; - Served(nested); - - Served(new JsonObject { ["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = IdOf(root) + "/replies/1" }); - Served(new JsonObject - { - ["id"] = IdOf(root) + "/replies/1", ["type"] = "CollectionPage", ["next"] = IdOf(root) + "/replies/2", - ["items"] = new JsonArray(first.DeepClone(), IdOf(second)) - }); - Served(new JsonObject { ["id"] = IdOf(root) + "/replies/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(hidden)) }); - Served(new JsonObject { ["id"] = IdOf(first) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(nested)) }); - Served(new JsonObject { ["id"] = IdOf(nested) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(deeper)) }); - var held = await Held(root); - - Assert.Equal(3, await RunReplies(held)); - var stored = await Stored(first, second, hidden, nested, deeper); - Assert.Equal(new[] { IdOf(first), IdOf(second), IdOf(nested) }.Order(), stored.Keys.Order()); - Assert.Equal(held.ID, stored[IdOf(first)].AnsweringToPostId); - Assert.Equal(held.ID, stored[IdOf(second)].AnsweringToPostId); - Assert.Equal(stored[IdOf(first)].ID, stored[IdOf(nested)].AnsweringToPostId); - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(IdOf(nested) + "/replies").AbsolutePath); - Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature))); - } - - [Fact] - public async Task A_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken() - { - var owner = new RemoteActor(_harness.Peer, "owner"); - var answerer = new RemoteActor(_harness.Peer, "answerer"); - var root = PublicNote(owner, "

a conversation

"); - var history = NewId(owner, "conversations"); - root["contextHistory"] = history; - root["context"] = NewId(owner, "never"); - Served(root); - var embedded = Served(Reply(answerer, "added whole", root)); - var referenced = Served(Reply(owner, "added by reference", embedded)); - var liked = Served(Reply(answerer, "only liked", root)); - var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id }; - JsonObject Add(JsonNode activity) => new() - { - ["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone() - }; - var create = Served(Create(owner, referenced)); - create["object"] = IdOf(referenced); - Served(create); - var byReference = Served(Add(IdOf(create))); - Served(new JsonObject - { - ["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity", - ["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked))))) - }); - var held = await Held(root); - - Assert.Equal(1, await RunReplies(held)); - var stored = await Stored(embedded, referenced, liked); - Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order()); - Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId); - Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId); - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue()).AbsolutePath); - } - - [Fact] - public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread() - { - var token = TestContext.Current.CancellationToken; - var poster = new RemoteActor(_harness.Peer, "poster"); - var answerer = new RemoteActor(_harness.Peer, "answerer"); - var root = PublicNote(poster, "

a topic

"); - var context = NewId(poster, "topics"); - var path = new Uri(context).AbsolutePath; - root["context"] = context; - Served(root); - var first = Served(Reply(answerer, "first", root)); - var later = Served(Reply(poster, "later", root)); - // NodeBB's: every post inline, counted, and an ETag without quotes - JsonObject Thread(params JsonObject[] notes) => new() - { - ["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length, - ["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray()) - }; - _harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb"); - var held = await Held(root); - var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue); - - await handler.Handle(RepliesJobHandler.For(held, 1), token); - Assert.Equal("4f930186eb", (await DB.Default.Find().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag); - // the same version: a 304, and nothing read - _harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb"); - await handler.Handle(RepliesJobHandler.For(held, 1), token); - Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]); - Assert.Single(await Stored(first, later)); - // a new one - _harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b"); - await handler.Handle(RepliesJobHandler.For(held, 1), token); - Assert.Equal(2, (await Stored(first, later)).Count); - Assert.Equal("5a0b", (await DB.Default.Find().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag); - } - - [Fact] - public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later() - { - var token = TestContext.Current.CancellationToken; - var poster = new RemoteActor(_harness.Peer, "poster"); - var root = PublicNote(poster, "

a long thread

"); - var context = NewId(poster, "contexts"); - root["context"] = context; - Served(root); - var answer = Served(Reply(poster, "on the second page", root)); - _harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject - { - ["id"] = context, ["type"] = "Collection", - ["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" } - }.ToJsonString(), "W/\"first\""); - Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) }); - var held = await Held(root); - - await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token); - Assert.Single(await Stored(answer)); - Assert.False(await DB.Default.Find().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_threads_context_collection_brings_every_reply_at_once() - { - var poster = new RemoteActor(_harness.Peer, "poster"); - var answerer = new RemoteActor(_harness.Peer, "answerer"); - var root = PublicNote(poster, "

a thread

"); - var context = NewId(poster, "contexts"); - root["context"] = context; - root["replies"] = NewId(poster, "never"); - Served(root); - var answer = Served(Reply(answerer, "an answer", root)); - var deep = Served(Reply(poster, "an answer to it", answer)); - // FEP-f228 lets a context list the activities that made the thread - Served(new JsonObject - { - ["id"] = context, ["type"] = "OrderedCollection", - ["first"] = new JsonObject { ["type"] = "OrderedCollectionPage", ["orderedItems"] = new JsonArray(IdOf(root), Create(answerer, answer), IdOf(deep)) } - }); - var held = await Held(root); - - Assert.Equal(1, await RunReplies(held)); - var stored = await Stored(answer, deep); - Assert.Equal(held.ID, stored[IdOf(answer)].AnsweringToPostId); - Assert.Equal(stored[IdOf(answer)].ID, stored[IdOf(deep)].AnsweringToPostId); - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["replies"]!.GetValue()).AbsolutePath); - } - - async Task<(LocalActor Alice, RemoteActor Mallory, Post Poll)> LocalPoll() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri - }); - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token); - Assert.True(outcome.Ok); - return (alice, mallory, outcome.Post); - } - - async Task> Updates(RemoteActor to) => - (await _harness.Outgoing(to.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Update").ToList(); - - static int[] Counts(JsonNode question) => - question["oneOf"]!.AsArray().Select(o => o!["replies"]!["totalItems"]!.GetValue()).ToArray(); - - [Fact] - public async Task A_poll_refresh_sends_the_new_counts_to_followers_as_an_update() - { - var token = TestContext.Current.CancellationToken; - var (_, mallory, poll) = await LocalPoll(); - var (_, bob) = await _harness.Persona("bob"); - Assert.True((await _harness.Polls.Vote(bob, poll, new[] { 1 }, token)).Ok); - var job = await DB.Default.Find().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == poll.ID).ExecuteSingleAsync(token); - Assert.True(job.RunAt > DateTime.UtcNow); - Assert.Empty(await Updates(mallory)); - - Assert.Equal(JobResult.Done, (await new PollRefreshJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result); - - var question = Assert.Single(await Updates(mallory))["object"]!; - Assert.Equal("Question", question["type"]!.GetValue()); - Assert.Equal(new[] { 0, 1 }, Counts(question)); - Assert.Null(question["closed"]); - Assert.Null((await DB.Default.Find().OneAsync(poll.ID, token)).Poll.ClosedAt); - } - - [Fact] - public async Task Closing_a_local_poll_tells_its_voters_and_sends_the_closed_question() - { - var token = TestContext.Current.CancellationToken; - var (alice, mallory, poll) = await LocalPoll(); - var (_, bob) = await _harness.Persona("bob"); - Assert.True((await _harness.Polls.Vote(bob, poll, new[] { 0 }, token)).Ok); - var job = await DB.Default.Find().Match(j => j.Kind == JobKind.PollClose && j.Payload == poll.ID).ExecuteSingleAsync(token); - - Assert.Equal(JobResult.Done, (await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result); - - var closed = (await DB.Default.Find().OneAsync(poll.ID, token)).Poll.ClosedAt; - Assert.NotNull(closed); - var question = Assert.Single(await Updates(mallory))["object"]!; - Assert.NotNull(question["closed"]); - Assert.Equal(new[] { 1, 0 }, Counts(question)); - var told = await DB.Default.Find().Match(n => n.Type == NotificationType.Poll && n.PostId == poll.ID).ExecuteAsync(token); - Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order()); - } - - [Fact] - public async Task Closing_a_remote_poll_tells_the_local_voters_and_sends_nothing() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var question = PublicNote(mallory, "

pick

", alice.Uri); - question["type"] = "Question"; - question["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }); - question["endTime"] = DateTime.UtcNow.AddMinutes(10).ToString("O"); - question["oneOf"] = new JsonArray( - new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } }, - new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } }); - await _harness.Deliver(mallory, "/human-centipede", Create(mallory, question)); - var poll = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(question)).ExecuteSingleAsync(token); - Assert.True((await _harness.Polls.Vote(alice, poll, new[] { 1 }, token)).Ok); - var job = await DB.Default.Find().Match(j => j.Kind == JobKind.PollClose && j.Payload == poll.ID).ExecuteSingleAsync(token); - - Assert.Equal(JobResult.Done, (await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result); - - var told = Assert.Single(await DB.Default.Find().Match(n => n.Type == NotificationType.Poll && n.PostId == poll.ID).ExecuteAsync(token)); - Assert.Equal(alice.Id, told.AvatarId); - Assert.Equal(poll.AuthorAccountId, told.FromAccountId); - Assert.Empty(await Updates(mallory)); - Assert.Null((await DB.Default.Find().OneAsync(poll.ID, token)).Poll.ClosedAt); - } - - [Fact] - public async Task A_server_is_described_from_its_nodeinfo_and_at_most_once_a_week() - { - var token = TestContext.Current.CancellationToken; - var host = $"describe{Guid.NewGuid():N}.example"; - var peer = _harness.Peer; - peer.ServeText("/.well-known/nodeinfo", new JsonObject - { - ["links"] = new JsonArray( - new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0", ["href"] = $"https://{host}/nodeinfo/2.0" }, - new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1", ["href"] = $"https://{host}/nodeinfo/2.1" }) - }.ToJsonString(), "application/json"); - peer.ServeText("/nodeinfo/2.1", new JsonObject - { - ["version"] = "2.1", - ["software"] = new JsonObject { ["name"] = "gotosocial", ["version"] = "0.20.1" }, - ["protocols"] = new JsonArray("activitypub", 42), - ["openRegistrations"] = false, - ["usage"] = new JsonObject { ["users"] = new JsonObject { ["total"] = 3 } }, - ["metadata"] = new JsonObject { ["nodeName"] = "A small place" } - }.ToJsonString(), "application/json"); - var describer = new PeerDescriber(Peer.Http(), peer.A); - - async Task Record() - { - var note = NoteParser.Parse(new JsonObject - { - ["id"] = $"https://{host}/notes/{Guid.NewGuid():N}", ["type"] = "Note", ["attributedTo"] = $"https://{host}/users/x", - ["content"] = "

hi

", ["to"] = new JsonArray(Addressing.Public) - }); - var post = new Post { ObjectURI = note.Id, IsFederatedCopy = true }; - await DB.Default.SaveAsync(post, token); - await _harness.Records.Record(note, post, ObjectPath.Fetched, refetched: false, token); - } - Task> Jobs() => DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == host).ExecuteAsync(token); - - await Record(); - await Record(); - var job = Assert.Single(await Jobs()); - Assert.StartsWith($"describe|{host}|", job.DedupeKey); - - Assert.Equal(JobResult.Done, (await describer.Handle(job, token)).Result); - - var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteSingleAsync(token); - Assert.Equal(("gotosocial", "0.20.1", "A small place"), (instance.Software, instance.SoftwareVersion, instance.NodeName)); - Assert.Equal(new[] { "activitypub" }, instance.Protocols); - Assert.False(instance.OpenRegistrations); - Assert.Equal(3, JsonNode.Parse(instance.NodeInfo)!["usage"]!["users"]!["total"]!.GetValue()); - Assert.Null(instance.DescriptionError); - Assert.InRange(instance.DescribedAt!.Value, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1)); - Assert.DoesNotContain(peer.Requests, r => r.Path == "/nodeinfo/2.0"); - - await DB.Default.DeleteAsync(job.ID); - await Record(); - Assert.Empty(await Jobs()); - - await DB.Default.Update().MatchID(instance.ID).Modify(i => i.DescribedAt, DateTime.UtcNow.AddDays(-8)).ExecuteAsync(token); - await Record(); - Assert.Single(await Jobs()); - } - - [Fact] - public async Task A_server_without_usable_nodeinfo_is_described_as_such() - { - var token = TestContext.Current.CancellationToken; - var plain = $"plain{Guid.NewGuid():N}.example"; - var describer = new PeerDescriber(Peer.Http(), _harness.Peer.A + "/" + plain); - _harness.Peer.ServeText($"/{plain}/.well-known/nodeinfo", new JsonObject - { - ["links"] = new JsonArray(new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1", ["href"] = $"http://{plain}/nodeinfo/2.1" }) - }.ToJsonString(), "application/json"); - var silent = $"silent{Guid.NewGuid():N}.example"; - - await describer.Handle(new Job { Kind = JobKind.DescribeInstance, Payload = plain }, token); - await new PeerDescriber(Peer.Http(), _harness.Peer.A + "/" + silent).Handle(new Job { Kind = JobKind.DescribeInstance, Payload = silent }, token); - - Assert.Equal("no NodeInfo", (await DB.Default.Find().Match(i => i.Host == plain).ExecuteSingleAsync(token)).DescriptionError); - Assert.Equal("no NodeInfo", (await DB.Default.Find().Match(i => i.Host == silent).ExecuteSingleAsync(token)).DescriptionError); - } - - LinkPreviews Previews() => new(_harness.Db, _harness.Local, Peer.Http(), _harness.Queue, - new StaticOptions(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true })); - - async Task Linking(PostVisibility visibility, string path) - { - var post = new Post - { - ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", IsFederatedCopy = true, Visibility = visibility, - ContentHtml = $"

read this

" - }; - await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); - _harness.Peer.ServeText(path, """ - Fallback - - - - - """, "text/html; charset=utf-8"); - return post; - } - - [Fact] - public async Task A_link_preview_is_built_from_open_graph_for_a_public_post_only() - { - var token = TestContext.Current.CancellationToken; - var publicPath = $"/articles/{Guid.NewGuid():N}"; - var privatePath = $"/articles/{Guid.NewGuid():N}"; - var open = await Linking(PostVisibility.Public, publicPath); - var followersOnly = await Linking(PostVisibility.FollowersOnly, privatePath); - - Assert.Equal(JobResult.Done, (await Previews().Handle(new Job { Kind = JobKind.FetchPreview, Payload = open.ID }, token)).Result); - Assert.Equal(JobResult.Done, (await Previews().Handle(new Job { Kind = JobKind.FetchPreview, Payload = followersOnly.ID }, token)).Result); - - var card = (await DB.Default.Find().OneAsync(open.ID, token)).Link; - Assert.Equal(_harness.Peer.A + publicPath, card.Href); - Assert.Equal(("A headline", "What it says", _harness.Peer.A + "/cover.jpg"), (card.Title, card.Description, card.ImageURL)); - Assert.True(await DB.Default.Find().Match(p => p.Url == _harness.Peer.A + publicPath).ExecuteAnyAsync(token)); - Assert.Null((await DB.Default.Find().OneAsync(followersOnly.ID, token)).Link); - Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == privatePath); - Assert.False(await DB.Default.Find().Match(p => p.Url == _harness.Peer.A + privatePath).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task The_janitor_removes_stale_unattached_uploads_and_trims_the_proxy_cache_oldest_first() - { - var token = TestContext.Current.CancellationToken; - var root = Path.Combine(Path.GetTempPath(), $"privapub-janitor-{Guid.NewGuid():N}"); - var options = new StaticOptions(new MediaOptions { Root = root, ProxyCacheBytes = 800 }); - var media = new MediaService(options, _harness.Local, default, NullLogger.Instance); - var seeded = new List(); - try - { - MediaAttachment Upload(string name, DateTime created, string postId = default) - { - Directory.CreateDirectory(Path.Combine(root, "files")); - File.WriteAllBytes(Path.Combine(root, "files", name), new byte[] { 1, 2, 3 }); - File.WriteAllBytes(Path.Combine(root, "files", "small-" + name), new byte[] { 1 }); - return new MediaAttachment { FilePath = Path.Combine("files", name), PreviewPath = Path.Combine("files", "small-" + name), CreatedAt = created, PostId = postId }; - } - var stale = Upload("stale.webp", DateTime.UtcNow.AddDays(-2)); - var fresh = Upload("fresh.webp", DateTime.UtcNow.AddHours(-1)); - var attached = Upload("attached.webp", DateTime.UtcNow.AddDays(-3), "000000000000000000000001"); - await DB.Default.SaveAsync(new[] { stale, fresh, attached }, token); - seeded.AddRange(new[] { stale.ID, fresh.ID, attached.ID }); - Directory.CreateDirectory(media.ProxyRoot); - FileInfo Cached(string name, int bytes, int minutesAgo) - { - var path = Path.Combine(media.ProxyRoot, name); - File.WriteAllBytes(path, new byte[bytes]); - File.WriteAllText(path + ".type", "image/png"); - File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(-minutesAgo)); - return new FileInfo(path); - } - var oldest = Cached("a", 600, 30); - var older = Cached("b", 400, 20); - var newest = Cached("c", 300, 10); - - await new MediaJanitor(media, options, NullLogger.Instance).Sweep(token); - - // never posted for a day: trashed, its files out of what /media/files serves at once - Assert.NotNull((await DB.Default.Find().OneAsync(stale.ID, token)).TrashedAt); - Assert.False(File.Exists(Path.Combine(root, stale.FilePath))); - Assert.False(File.Exists(Path.Combine(root, stale.PreviewPath))); - Assert.True(File.Exists(Path.Combine(media.TrashRoot, stale.FilePath))); - // and once its grace has passed, deleted with its row - await DB.Default.Update().MatchID(stale.ID).Modify(m => m.TrashedAt, DateTime.UtcNow - MediaJanitor.TrashGrace - TimeSpan.FromMinutes(1)).ExecuteAsync(token); - await new MediaJanitor(media, options, NullLogger.Instance).Sweep(token); - Assert.False(await DB.Default.Find().Match(m => m.ID == stale.ID).ExecuteAnyAsync(token)); - Assert.False(File.Exists(Path.Combine(media.TrashRoot, stale.FilePath))); - Assert.False(File.Exists(Path.Combine(media.TrashRoot, stale.PreviewPath))); - Assert.True(await DB.Default.Find().Match(m => m.ID == fresh.ID).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(m => m.ID == attached.ID).ExecuteAnyAsync(token)); - Assert.True(File.Exists(Path.Combine(root, fresh.FilePath))); - Assert.False(File.Exists(oldest.FullName)); - Assert.False(File.Exists(oldest.FullName + ".type")); - Assert.True(File.Exists(older.FullName)); - Assert.True(File.Exists(older.FullName + ".type")); - Assert.True(File.Exists(newest.FullName)); - } - finally - { - await DB.Default.DeleteAsync(m => seeded.Contains(m.ID)); - foreach (var directory in new[] { root, media.ProxyRoot }.Where(Directory.Exists)) - Directory.Delete(directory, recursive: true); - } - } - } - - [Trait("Category", "Integration")] - public sealed class OAuthPrunerTests - { - [Fact] - public async Task Old_tokens_and_authorizations_that_are_no_longer_valid_are_pruned_and_the_rest_kept() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - var token = TestContext.Current.CancellationToken; - var host = await PrivaPubHost.Shared(); - var now = DateTimeOffset.UtcNow; - using var scope = host.Services.CreateScope(); - var tokens = scope.ServiceProvider.GetRequiredService(); - var authorizations = scope.ServiceProvider.GetRequiredService(); - var subject = $"pruned{Guid.NewGuid():N}"; - async Task Token(DateTimeOffset created, DateTimeOffset expires, string status) => - await tokens.GetIdAsync(await tokens.CreateAsync(new OpenIddictTokenDescriptor - { - CreationDate = created, ExpirationDate = expires, Status = status, Subject = subject, Type = OpenIddictConstants.TokenTypeHints.AccessToken - }, token), token); - async Task Authorization(DateTimeOffset created, string status) => - await authorizations.GetIdAsync(await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor - { - CreationDate = created, Status = status, Subject = subject, Type = OpenIddictConstants.AuthorizationTypes.Permanent - }, token), token); - var expired = await Token(now.AddDays(-30), now.AddDays(-29), OpenIddictConstants.Statuses.Valid); - var revoked = await Token(now.AddDays(-30), now.AddDays(1), OpenIddictConstants.Statuses.Revoked); - var current = await Token(now.AddMinutes(-5), now.AddHours(1), OpenIddictConstants.Statuses.Valid); - var recentlyRevoked = await Token(now.AddDays(-1), now.AddDays(-1).AddMinutes(1), OpenIddictConstants.Statuses.Revoked); - var oldRevoked = await Authorization(now.AddDays(-30), OpenIddictConstants.Statuses.Revoked); - var oldValid = await Authorization(now.AddDays(-30), OpenIddictConstants.Statuses.Valid); - - var (prunedTokens, prunedAuthorizations) = await new OAuthPruner(host.Services, NullLogger.Instance).Prune(now.AddDays(-14), token); - - Assert.True(prunedTokens >= 2, $"{prunedTokens} tokens pruned"); - Assert.True(prunedAuthorizations >= 1, $"{prunedAuthorizations} authorizations pruned"); - //a fresh scope: the managers cache what they created - using var after = host.Services.CreateScope(); - tokens = after.ServiceProvider.GetRequiredService(); - authorizations = after.ServiceProvider.GetRequiredService(); - Assert.Null(await tokens.FindByIdAsync(expired, token)); - Assert.Null(await tokens.FindByIdAsync(revoked, token)); - Assert.NotNull(await tokens.FindByIdAsync(current, token)); - Assert.NotNull(await tokens.FindByIdAsync(recentlyRevoked, token)); - Assert.Null(await authorizations.FindByIdAsync(oldRevoked, token)); - Assert.NotNull(await authorizations.FindByIdAsync(oldValid, token)); - } - } - - [Xunit.Collection(nameof(Exclusive))] - [Trait("Category", "Integration")] - public sealed class DeliveryOutcomeTests : IAsyncLifetime - { - static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; - - Harness _harness; - WebApplication _hinting; - string _hintingBase; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - var builder = WebApplication.CreateSlimBuilder(); - builder.WebHost.UseUrls("http://127.0.0.1:0"); - _hinting = builder.Build(); - //answers /{status}/{retry-after}: the peer cannot send a Retry-After - _hinting.Run(context => - { - var parts = context.Request.Path.Value!.Trim('/').Split('/'); - context.Response.StatusCode = int.Parse(parts[0]); - if (parts.Length > 1) - context.Response.Headers.RetryAfter = parts[1] == "date" - ? DateTimeOffset.UtcNow.AddMinutes(10).ToString("r") - : parts[1]; - return Task.CompletedTask; - }); - await _hinting.StartAsync(); - _hintingBase = _hinting.Urls.First(); - } - - public async ValueTask DisposeAsync() - { - if (_hinting != default) - await _hinting.DisposeAsync(); - if (_harness == default) - return; - await _harness.DisposeAsync(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - } - - async Task Attempt(LocalActor signer, string inbox, int attempts = 1) - { - var token = TestContext.Current.CancellationToken; - var id = $"{Harness.Base}/a/{Guid.NewGuid():N}"; - await _harness.Delivery.Enqueue(signer, new[] { inbox }, new JsonObject - { - ["id"] = id, ["type"] = "Create", ["actor"] = signer.Uri, ["to"] = new JsonArray(Addressing.Public), - ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" } - }, token); - var job = await DB.Default.Find().Match(j => j.DedupeKey == $"{id}|{inbox}").ExecuteSingleAsync(token); - job.Attempts = attempts; - var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), - NullLogger.Instance); - return await handler.Handle(job, token); - } - - [Fact] - public async Task A_delivery_carries_a_valid_digest_and_a_signature_by_the_signer() - { - var (_, alice) = await _harness.Persona("alice"); - _harness.Peer.Answer("/ok/inbox", 202); - - var outcome = await Attempt(alice, _harness.Peer.A + "/ok/inbox"); - - Assert.Equal(JobResult.Done, outcome.Result); - var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/ok/inbox"); - Assert.Equal("POST", received.Method); - Assert.Equal(alice.Uri, JsonNode.Parse(received.Body)!["actor"]!.GetValue()); - Assert.Equal(HttpSignatures.Digest(Encoding.UTF8.GetBytes(received.Body)), received.Headers["Digest"]); - var signature = HttpSignatures.Parse(received.Signature); - Assert.Equal(alice.KeyId, signature.KeyId); - Assert.Equal(new[] { "(request-target)", "host", "date", "digest" }, signature.Headers); - var signingString = $"(request-target): post /ok/inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}"; - using var key = RSA.Create(); - key.ImportFromPem(alice.PublicKeyPem); - Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - } - - [Fact] - public async Task Refusals_are_dead_hints_defer_and_failures_retry() - { - var (_, alice) = await _harness.Persona("alice"); - _harness.Peer.Answer("/gone/inbox", 410); - _harness.Peer.Answer("/missing/inbox", 404); - _harness.Peer.Answer("/broken/inbox", 500); - - var gone = await Attempt(alice, _harness.Peer.A + "/gone/inbox"); - var missing = await Attempt(alice, _harness.Peer.A + "/missing/inbox"); - var busy = await Attempt(alice, _hintingBase + "/429/120"); - var down = await Attempt(alice, _hintingBase + "/503/date"); - var unhinted = await Attempt(alice, _hintingBase + "/503"); - var broken = await Attempt(alice, _harness.Peer.A + "/broken/inbox"); - - Assert.Equal(JobResult.Dead, gone.Result); - Assert.StartsWith("410", gone.Error); - Assert.Equal(JobResult.Dead, missing.Result); - Assert.Equal(JobResult.Defer, busy.Result); - Assert.InRange(busy.RetryAt!.Value, DateTime.UtcNow.AddSeconds(100), DateTime.UtcNow.AddSeconds(125)); - Assert.Equal(JobResult.Defer, down.Result); - Assert.InRange(down.RetryAt!.Value, DateTime.UtcNow.AddMinutes(9), DateTime.UtcNow.AddMinutes(11)); - Assert.Equal(JobResult.Retry, unhinted.Result); - Assert.Equal(JobResult.Retry, broken.Result); - Assert.StartsWith("500", broken.Error); - } - - // a server that fails on one activity is up: its 500s are that delivery's to retry, and only a gateway's 502, 503 or - // 504 counts towards taking the host out (Smithereen's 500s on documents it could not read once held every delivery - // to it back for an hour) - [Fact] - public async Task Only_a_gateways_failures_take_the_host_out() - { - var (_, alice) = await _harness.Persona("alice"); - _harness.Peer.Answer("/broken/inbox", 500); - _harness.Peer.Answer("/gateway/inbox", 502); - - for (var i = 0; i < HostCircuitBreaker.Threshold; i++) - await Attempt(alice, _harness.Peer.A + "/broken/inbox"); - var stillTried = await Attempt(alice, _harness.Peer.A + "/broken/inbox"); - for (var i = 0; i < HostCircuitBreaker.Threshold; i++) - await Attempt(alice, _harness.Peer.A + "/gateway/inbox"); - var heldBack = await Attempt(alice, _harness.Peer.A + "/broken/inbox"); - - Assert.Equal((JobResult.Retry, "500"), (stillTried.Result, stillTried.Error[..3])); - Assert.Equal((JobResult.Defer, "the host is unavailable"), (heldBack.Result, heldBack.Error)); - } - - // Mastodon's 422 for two first contacts racing to create one account, and its 409 for a held lock, are retried a - // few times; the third refusal is final like any other - [Fact] - public async Task Conflicts_and_unprocessable_answers_are_retried_twice_then_dead() - { - var (_, alice) = await _harness.Persona("alice"); - _harness.Peer.Answer("/racing/inbox", 422); - _harness.Peer.Answer("/locked/inbox", 409); - - Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/racing/inbox")).Result); - Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/locked/inbox", attempts: 2)).Result); - var final = await Attempt(alice, _harness.Peer.A + "/racing/inbox", attempts: 3); - Assert.Equal(JobResult.Dead, final.Result); - Assert.StartsWith("422", final.Error); - } - } -} diff --git a/PrivaPub.Tests/Federation/MoveTests.cs b/PrivaPub.Tests/Federation/MoveTests.cs deleted file mode 100644 index 0484c3a..0000000 --- a/PrivaPub.Tests/Federation/MoveTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Api.Mastodon.Mappers; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // An account moving to another (Mastodon's Move), believed as Mastodon believes it: sent by the moving account about - // itself, and the new account naming it among its alsoKnownAs - [Trait("Category", "Integration")] - public sealed class MoveTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - // the new account, serving a document that names the old one (or not) - RemoteActor Target(string name, RemoteActor aliasOf) - { - var target = new RemoteActor(_harness.Peer, name); - var document = target.Document(); - if (aliasOf != default) - document["alsoKnownAs"] = new JsonArray(aliasOf.Id); - _harness.Peer.Serve(new Uri(target.Id).AbsolutePath, document.ToJsonString()); - return target; - } - - Task Move(RemoteActor sender, RemoteActor moved, RemoteActor target) => - _harness.Deliver(sender, "/human-centipede", new JsonObject - { - ["id"] = NewId(sender, "moves"), ["type"] = "Move", ["actor"] = sender.Id, ["object"] = moved.Id, ["target"] = target.Id - }); - - static Task Stored(RemoteActor actor) => - DB.Default.Find().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); - - // as Mastodon does it (owner decision 2026-10-05): the follow moves to the new account, in the same lists, and a mute - // or a block of the old account carries over - [Fact] - public async Task A_move_the_new_account_confirms_moves_the_personas_follows_lists_mutes_and_blocks() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var (_, bob) = await _harness.Persona("bob"); - var old = new RemoteActor(_harness.Peer, "old"); - await _harness.Remote.GetActor(old.Id, refresh: true, token); - await Follows(alice.Id, old); - var oldAccount = await Stored(old); - var list = new PersonaList { AvatarId = alice.Id, Title = "friends" }; - await DB.Default.SaveAsync(list, token); - await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = alice.Id, AccountId = oldAccount.ID }, token); - await _harness.Relationships.Mute(alice, old.Id, oldAccount.ID, hideNotifications: true, duration: default, token); - await _harness.Relationships.Block(bob, old.Id, oldAccount.ID, token); - var target = Target("new", aliasOf: old); - - await Move(old, old, target); - - var stored = await Stored(old); - Assert.Equal(target.Id, stored.MovedToURL); - var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token); - Assert.Equal(target.Name, account.Moved?.Username); - Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved"); - var newAccount = await Stored(target); - Assert.True(await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == old.Id).ExecuteAnyAsync(token)); - Assert.Contains(await _harness.Outgoing(target.Id + "/inbox"), a => a["type"]!.GetValue() == "Follow"); - Assert.Contains(await _harness.Outgoing(old.Id + "/inbox"), a => a["type"]!.GetValue() == "Undo"); - Assert.True(await DB.Default.Find().Match(m => m.ListId == list.ID && m.AccountId == newAccount.ID).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(m => m.AvatarId == alice.Id && m.TargetActorURI == target.Id).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(b => b.AvatarId == bob.Id && b.TargetActorURI == target.Id).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_move_the_new_account_does_not_confirm_or_someone_else_sends_changes_nothing() - { - var (_, alice) = await _harness.Persona("alice"); - var old = new RemoteActor(_harness.Peer, "old"); - var stranger = new RemoteActor(_harness.Peer, "stranger"); - await Follows(alice.Id, old); - var unconfirmed = Target("new", aliasOf: default); - var hijack = Target("hijack", aliasOf: stranger); - - await Move(old, old, unconfirmed); - await Move(stranger, old, hijack); - - Assert.Null((await Stored(old)).MovedToURL); - Assert.Equal(new[] { "not-aliased", "not-self" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Move").Select(e => e.Reason)); - } - } -} diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs deleted file mode 100644 index 1b40a4e..0000000 --- a/PrivaPub.Tests/Federation/NoteParserTests.cs +++ /dev/null @@ -1,220 +0,0 @@ -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class NoteParserTests - { - const string MastodonNote = """ - { - "id": "https://m.example/users/alice/statuses/1", - "type": "Note", - "summary": "spoilers", - "inReplyTo": "https://privapub.test/peasants/bob/posts/abc", - "published": "2026-09-30T10:00:00Z", - "url": "https://m.example/@alice/1", - "attributedTo": "https://m.example/users/alice", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://m.example/users/alice/followers", "https://privapub.test/peasants/bob"], - "sensitive": true, - "conversation": "tag:m.example,2026-09-30:objectId=1:objectType=Conversation", - "context": "https://m.example/contexts/1", - "content": "

@bob hi #Fedi

", - "contentMap": { "en": "

hi

" }, - "attachment": [{ - "type": "Document", "mediaType": "image/png", "url": "https://files.m.example/a.png", - "name": "a cat", "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH", "focalPoint": [0.5, -2.0], "width": 640, "height": 480 - }], - "tag": [ - { "type": "Mention", "href": "https://privapub.test/peasants/bob", "name": "@bob@privapub.test" }, - { "type": "Hashtag", "href": "https://m.example/tags/fedi", "name": "#Fedi" }, - { "type": "Emoji", "name": ":blob:", "icon": { "type": "Image", "url": "https://files.m.example/blob.png" } } - ] - } - """; - - // Castopod's episode: its words in description, its sound in audio, the sound's name for a title - [Fact] - public void A_podcast_episode_is_read_with_its_words_sound_and_cover() - { - var episode = NoteParser.Parse(JsonNode.Parse(""" - {"id":"https://castopod.test/@pod/episodes/one","type":"PodcastEpisode","published":"2026-10-06T09:08:17+00:00", - "to":["https://www.w3.org/ns/activitystreams#Public"],"attributedTo":"https://castopod.test/@pod", - "description":{"type":"Note","mediaType":"text/markdown","content":"Listen to the **meadow**","contentMap":{"en":"

Listen to the meadow

"}}, - "image":{"type":"Image","mediaType":"image/png","url":"https://castopod.test/media/cover.png"}, - "audio":{"id":"https://castopod.test/audio/one.mp3","type":"Audio","name":"Episode one","size":16791,"duration":4.02, - "url":{"href":"https://castopod.test/audio/one.mp3","type":"Link","mediaType":"audio/mpeg"}}} - """)); - - Assert.Equal(("PodcastEpisode", "Episode one", "https://castopod.test/media/cover.png"), (episode.Type, episode.Title, episode.CoverURL)); - Assert.Contains("meadow", episode.ContentHtml); - var sound = Assert.Single(episode.Attachments); - Assert.Equal(("https://castopod.test/audio/one.mp3", "audio/mpeg"), (sound.RemoteURL, sound.ContentType)); - Assert.Equal(4.02, episode.Audio.DurationSeconds); - } - - [Fact] - public void Reads_a_mastodon_note() - { - var note = NoteParser.Parse(JsonNode.Parse(MastodonNote)); - - Assert.Equal("https://m.example/users/alice", note.AttributedTo); - Assert.Equal("spoilers", note.SpoilerText); - Assert.True(note.Sensitive); - Assert.Equal("en", note.Language); - Assert.Equal("https://m.example/@alice/1", note.Url); - Assert.Equal("https://m.example/contexts/1", note.Context); - Assert.DoesNotContain("

","summary":"","published":"2026-10-06T08:00:00Z"}]}} - """)); - - Assert.Equal(["

first

", "

second

"], note.FormerRepresentations.Select(r => r.ContentHtml)); - Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)], - note.FormerRepresentations.Select(r => r.EditedAt)); - Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning))); - } - - [Fact] - public void Reads_a_lemmy_page_with_a_title() - { - var note = NoteParser.Parse(JsonNode.Parse(""" - { - "id": "https://l.example/post/7", "type": "Page", "name": "A title", - "attributedTo": "https://l.example/u/carol", "audience": "https://l.example/c/cats", - "content": "

body

", "to": ["https://l.example/c/cats", "https://www.w3.org/ns/activitystreams#Public"], - "published": "2026-09-30T10:00:00.123456+00:00" - } - """)); - - Assert.Equal("A title", note.Title); - Assert.Equal("https://l.example/c/cats", note.Audience); - Assert.False(note.Sensitive); - } - - [Fact] - public void Falls_back_to_misskey_content_and_takes_the_person_from_a_peertube_attribution() - { - var note = NoteParser.Parse(JsonNode.Parse(""" - { - "id": "https://k.example/notes/9", "type": "Note", - "attributedTo": [{ "type": "Group", "id": "https://k.example/c/chan" }, { "type": "Person", "id": "https://k.example/users/9" }], - "_misskey_content": "hello there\nnext", "_misskey_quote": "https://k.example/notes/8" - } - """)); - - Assert.Equal("https://k.example/users/9", note.AttributedTo); - Assert.Equal("hello <b>there</b>
next", note.ContentHtml); - Assert.Equal("https://k.example/notes/8", note.QuoteUri); - } - - [Theory] - [InlineData("Article", false, false)] - [InlineData("Event", false, false)] - [InlineData("Video", false, false)] - [InlineData("Page", false, false)] - [InlineData("Note", false, true)] - [InlineData("Question", false, true)] - [InlineData("Article", true, true)] - public void A_summary_warns_only_on_notes_or_when_marked_sensitive(string type, bool sensitive, bool warns) - { - var note = NoteParser.Parse(JsonNode.Parse($$""" - { - "id": "https://w.example/?p=12", "type": "{{type}}", "name": "Twelve", "summary": "Saturday, 10:00 at the library", - "sensitive": {{(sensitive ? "true" : "false")}}, "attributedTo": "https://w.example/author", "content": "

body

" - } - """)); - - Assert.Equal(warns, note.Sensitive); - Assert.Equal(warns ? "Saturday, 10:00 at the library" : null, note.SpoilerText); - Assert.Equal(warns ? null : "Saturday, 10:00 at the library", note.Excerpt); - } - - [Theory] - [InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")] - [InlineData("""{ "id": "not a uri", "type": "Note" }""")] - [InlineData("""[1, 2]""")] - public void Refuses_what_is_not_a_note(string json) => - Assert.Null(NoteParser.Parse(JsonNode.Parse(json))); - } - - public class AddressingTests - { - const string Followers = "https://m.example/users/alice/followers"; - - [Theory] - [InlineData(new[] { Addressing.Public }, new[] { Followers }, PostVisibility.Public)] - [InlineData(new[] { "as:Public" }, new string[0], PostVisibility.Public)] - [InlineData(new[] { Followers }, new[] { Addressing.Public }, PostVisibility.Unlisted)] - [InlineData(new[] { Followers }, new[] { "https://privapub.test/peasants/bob" }, PostVisibility.FollowersOnly)] - [InlineData(new[] { "https://privapub.test/peasants/bob" }, new string[0], PostVisibility.Direct)] - [InlineData(new[] { "https://m.example/users/mallory/followers" }, new string[0], PostVisibility.Direct)] - public void Classifies_like_mastodon(string[] to, string[] cc, PostVisibility expected) => - Assert.Equal(expected, Addressing.Classify(to, cc, Followers)); - } -} diff --git a/PrivaPub.Tests/Federation/ObjectRecordsTests.cs b/PrivaPub.Tests/Federation/ObjectRecordsTests.cs deleted file mode 100644 index 64fca45..0000000 --- a/PrivaPub.Tests/Federation/ObjectRecordsTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using PrivaPub.Federation.Objects; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class ObjectRecordsTests - { - [Fact] - public void An_object_larger_than_the_cap_keeps_only_its_hash() - { - var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) }; - - var (text, hash, bytes, truncated) = ObjectRecords.Capture(big); - - Assert.Null(text); - Assert.True(truncated); - Assert.True(bytes > ObjectRecords.MaxRawBytes); - Assert.Equal(64, hash.Length); - } - - [Fact] - public void A_small_object_is_kept_whole_with_its_hash() - { - var small = new JsonObject { ["type"] = "Note", ["content"] = "hi" }; - - var (text, hash, bytes, truncated) = ObjectRecords.Capture(small); - - Assert.Equal(small.ToJsonString(), text); - Assert.False(truncated); - Assert.Equal(text.Length, bytes); - Assert.Equal(64, hash.Length); - Assert.Equal(default, ObjectRecords.Capture(default)); - } - } -} diff --git a/PrivaPub.Tests/Federation/ObjectShapesTests.cs b/PrivaPub.Tests/Federation/ObjectShapesTests.cs deleted file mode 100644 index 9ee8e4f..0000000 --- a/PrivaPub.Tests/Federation/ObjectShapesTests.cs +++ /dev/null @@ -1,299 +0,0 @@ -using PrivaPub.Api.Mastodon.Mappers; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class ObjectShapesTests - { - static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json)); - - [Fact] - public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description() - { - var note = Parse(""" - { - "id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S", - "mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1, - "isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true, - "licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" }, - "attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }], - "icon": [ - { "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 }, - { "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 }, - { "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 } - ], - "subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }], - "url": [ - { "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" }, - { "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378, - "attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] }, - { "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" }, - { "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" } - ] - } - """); - - Assert.Equal("https://tube.example/w/abc", note.Url); - Assert.Equal("A *talk*", note.Title); - Assert.Contains("Bold", note.ContentHtml); - Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL); - Assert.Equal(6299, note.Video.DurationSeconds); - var file = Assert.Single(note.Video.Variants); - Assert.Equal(new[] { "audio", "video" }, file.Streams); - Assert.Equal(942586378, file.Size); - Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL); - Assert.Equal(2, note.Video.CommentsPolicy); - Assert.Equal("Attribution - Share Alike", note.Video.Licence); - Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI); - Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url); - Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo); - } - - [Fact] - public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link() - { - var note = Parse(""" - { - "id": "https://events.example/events/9", "type": "Event", "name": "Repair café", - "summary": "

Saturday at the library

", "content": "

Bring broken things.

", - "startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome", - "joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED", - "location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68, - "address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } }, - "attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }], - "attributedTo": "https://events.example/@club" - } - """); - - Assert.Null(note.SpoilerText); - Assert.False(note.Sensitive); - Assert.Equal("Saturday at the library", note.Excerpt); - Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start); - Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address); - Assert.Equal("https://meet.example/repair", note.Event.OnlineURL); - Assert.Equal(40, note.Event.Capacity); - Assert.Equal("CONFIRMED", note.Event.Status); - Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event)); - } - - [Fact] - public void Keeps_a_pixelfed_place_whose_coordinates_are_strings() - { - var note = Parse(""" - { - "id": "https://pixel.example/p/ann/1", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "

view

", - "location": { "type": "Place", "name": "Tokyo", "longitude": "139.6917", "latitude": "35.6895", "country": "Japan" }, - "commentsEnabled": true - } - """); - - Assert.Equal(("Tokyo", 35.6895, 139.6917, "Japan"), (note.Place.Name, note.Place.Latitude, note.Place.Longitude, note.Place.Country)); - Assert.Null(note.Event); - } - - [Theory] - [InlineData("null")] - [InlineData("""{ "type": "Place", "name": "Nowhere" }""")] - [InlineData("""{ "type": "Place", "name": "Off the map", "latitude": 123, "longitude": 9 }""")] - [InlineData("""{ "type": "Place", "latitude": "north", "longitude": "9.19" }""")] - [InlineData("""{ "type": "VirtualLocation", "url": "https://meet.example/a", "latitude": 45, "longitude": 9 }""")] - public void Has_no_place_without_a_place_with_coordinates(string location) - { - var note = Parse($$""" - { "id": "https://pixel.example/p/ann/2", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "

x

", "location": {{location}} } - """); - - Assert.Null(note.Place); - } - - [Fact] - public void An_events_location_stays_the_events_own() - { - var note = Parse(""" - { - "id": "https://events.example/events/10", "type": "Event", "name": "Picnic", "startTime": "2026-10-03T10:00:00+02:00", - "location": { "type": "Place", "name": "Park", "latitude": 45.07, "longitude": 7.68 }, - "attributedTo": "https://events.example/@club" - } - """); - - Assert.Null(note.Place); - Assert.Equal("Park", Assert.Single(note.Event.Places).Name); - } - - [Fact] - public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail() - { - var note = Parse(""" - { - "id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo", - "attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }], - "image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" }, - "language": { "identifier": "en", "name": "English" } - } - """); - - Assert.Equal("https://news.example/story", note.Link.Href); - Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL); - Assert.False(note.Link.FromPublisher); - Assert.Empty(note.Attachments); - } - - [Fact] - public void Takes_a_publisher_supplied_preview_from_a_link_attachment() - { - var note = Parse(""" - { - "id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "

look

", - "attachment": [{ "type": "Link", "href": "https://news.example/a", - "preview": { "type": "Article", "name": "Headline & more", "summary": "

What happened

", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }] - } - """); - - Assert.Equal("Headline & more", note.Link.Title); - Assert.Equal("What happened", note.Link.Description); - Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL); - Assert.True(note.Link.FromPublisher); - } - - [Fact] - public void Reads_media_in_every_shape_and_infers_what_is_missing() - { - var note = Parse(""" - { - "id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x", - "content": "

pics

", - "@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }], - "attachment": [ - { "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" }, - { "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }], - "icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true }, - { "type": "Image", "url": "https://cdn.example/nameless" } - ] - } - """); - - Assert.Equal("de", note.Language); - Assert.Equal("image/jpeg", note.Attachments[0].ContentType); - Assert.Equal("alt from summary", note.Attachments[0].Description); - Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL); - Assert.Equal("video/mp4", note.Attachments[1].ContentType); - Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL); - Assert.Equal(12.4, note.Attachments[1].DurationSeconds); - Assert.True(note.Attachments[1].Sensitive); - Assert.Equal("image/*", note.Attachments[2].ContentType); - } - - [Fact] - public void Keeps_a_funkwhale_track_and_a_loops_poster() - { - var track = Parse(""" - { - "id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band", - "summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/", - "url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" }, - { "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }] - } - """); - var loop = Parse(""" - { - "id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "", - "preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } }, - "attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }] - } - """); - - Assert.Equal("#rock #live", track.Excerpt); - Assert.Null(track.SpoilerText); - Assert.Equal(241, track.Audio.DurationSeconds); - Assert.Equal(3, track.Audio.Position); - Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url); - Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL); - } - - [Fact] - public void Keeps_custom_emoji_and_drops_ones_that_could_not_render() - { - var note = Parse(""" - { - "id": "https://k.example/notes/1", "type": "Note", "attributedTo": "https://k.example/users/1", "content": ":blobcat: hi :bad:", - "tag": [ - { "type": "Emoji", "name": ":blobcat:", "updated": "2026-01-01T00:00:00Z", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://k.example/emoji/blobcat.png" } }, - { "type": "Emoji", "name": ":blobcat:", "icon": { "type": "Image", "url": "https://k.example/emoji/duplicate.png" } }, - { "type": "Emoji", "name": ":bad name:", "icon": { "type": "Image", "url": "https://k.example/emoji/x.png" } }, - { "type": "Emoji", "name": ":nourl:", "icon": { "type": "Image", "url": "data:image/png;base64,AAAA" } } - ] - } - """); - - var emoji = Assert.Single(note.Emojis); - Assert.Equal("blobcat", emoji.Shortcode); - Assert.Equal("https://k.example/emoji/blobcat.png", emoji.URL); - } - - [Fact] - public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones() - { - var mastodon = Parse(""" - { "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7, - "oneOf": [{ "type": "Note", "name": "a", "replies": { "type": "Collection", "totalItems": 4 } }, { "type": "Note", "name": "b", "replies": { "type": "Collection", "totalItems": 3 } }] } - """); - var misskey = Parse(""" - { "id": "https://k.example/q/1", "type": "Question", "attributedTo": "https://k.example/u", "_misskey_content": "?", - "anyOf": [{ "type": "Note", "name": "x", "_misskey_votes": 2 }, { "type": "Note", "name": "y", "_misskey_votes": 5 }] } - """); - var closed = Parse(""" - { "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z", - "oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] } - """); - var akkoma = Parse(""" - { "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z", - "oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] } - """); - - Assert.False(mastodon.Poll.Multiple); - Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes)); - Assert.Equal(7, mastodon.Poll.VotersCount); - Assert.True(misskey.Poll.Multiple); - Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes)); - Assert.Null(misskey.Poll.VotersCount); - Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt); - Assert.Null(akkoma.Poll.ClosedAt); - Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt); - } - - [Fact] - public void Finds_playable_files_inside_an_hls_only_peertube_video_and_picks_one_with_sound() - { - var note = Parse(""" - { - "id": "https://tube.example/videos/watch/2", "type": "Video", "name": "HLS only", "attributedTo": "https://tube.example/accounts/ann", - "url": [ - { "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/def" }, - { "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8", "tag": [ - { "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/1080-fragmented.mp4", "height": 1080, - "attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] }, - { "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720-fragmented.mp4", "height": 720, - "attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] }, - { "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/480-fragmented.mp4", "height": 480, - "attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] } - ] } - ] - } - """); - - Assert.Equal(3, note.Video.Variants.Count); - Assert.Equal("https://tube.example/480-fragmented.mp4", MastodonMapper.BestVideo(note.Video.Variants).Url); - } - - [Fact] - public void Normalises_hashtags_like_mastodon() => - Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse(""" - { "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "", - "tag": [{ "type": "Hashtag", "name": "#FEDI" }] } - """).Tags)); - } -} diff --git a/PrivaPub.Tests/Federation/ParticipationTests.cs b/PrivaPub.Tests/Federation/ParticipationTests.cs deleted file mode 100644 index 7f8b90f..0000000 --- a/PrivaPub.Tests/Federation/ParticipationTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // a persona joins a remote event (owner decision 2026-10-05): Join, answered Accept or Reject; Leave - [Trait("Category", "Integration")] - public sealed class ParticipationTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Group, Post Event)> Announced(string joinMode = "free") - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var group = new RemoteActor(_harness.Peer, "group", type: "Group"); - await Follows(alice.Id, group); - var happening = new JsonObject - { - ["id"] = NewId(group, "events"), ["type"] = "Event", ["name"] = "A picnic", ["content"] = "

bring bread

", - ["attributedTo"] = group.Id, ["startTime"] = DateTime.UtcNow.AddDays(3).ToString("O"), ["joinMode"] = joinMode, - ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers") - }; - await _harness.Deliver(group, "/human-centipede", Create(group, happening)); - return (alice, group, await DB.Default.Find().Match(p => p.ObjectURI == IdOf(happening)).ExecuteSingleAsync(token)); - } - - Task Of(PrivaPub.Federation.Actors.LocalActor alice, Post happening) => - DB.Default.Find().Match(p => p.AvatarId == alice.Id && p.PostId == happening.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken); - - [Fact] - public async Task A_persona_joins_an_event_its_server_accepts_and_leaves_it() - { - var token = TestContext.Current.CancellationToken; - var (alice, group, happening) = await Announced(); - - Assert.True((await _harness.Participations.Join(alice, happening.ID, token)).Ok); - - var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Join"); - Assert.Equal((alice.Uri, happening.ObjectURI), (join["actor"]!.GetValue(), join["object"]!.GetValue())); - Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State); - - await _harness.Deliver(group, "/human-centipede", new JsonObject - { - ["id"] = NewId(group, "accepts"), ["type"] = "Accept", ["actor"] = group.Id, ["object"] = join["id"]!.GetValue() - }); - Assert.Equal(ParticipationState.Accepted, (await Of(alice, happening)).State); - - Assert.True((await _harness.Participations.Leave(alice, happening.ID, token)).Ok); - Assert.Null(await Of(alice, happening)); - var leave = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Leave"); - Assert.Equal(happening.ObjectURI, leave["object"]!.GetValue()); - } - - [Fact] - public async Task A_refusal_counts_an_answer_from_another_server_does_not_and_an_invitation_only_event_is_not_joined() - { - var token = TestContext.Current.CancellationToken; - var (alice, group, happening) = await Announced(); - await _harness.Participations.Join(alice, happening.ID, token); - var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Join"); - - var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B); - await _harness.Deliver(stranger, "/human-centipede", new JsonObject - { - ["id"] = NewId(stranger, "accepts"), ["type"] = "Accept", ["actor"] = stranger.Id, ["object"] = join["id"]!.GetValue() - }); - Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State); - - await _harness.Deliver(group, "/human-centipede", new JsonObject - { - ["id"] = NewId(group, "rejects"), ["type"] = "Reject", ["actor"] = group.Id, - ["object"] = new JsonObject { ["id"] = join["id"]!.GetValue(), ["type"] = "Join", ["actor"] = alice.Uri, ["object"] = happening.ObjectURI } - }); - Assert.Equal(ParticipationState.Rejected, (await Of(alice, happening)).State); - - var (carol, _, invitationOnly) = await Announced("invite"); - Assert.Equal(422, (await _harness.Participations.Join(carol, invitationOnly.ID, token)).Status); - } - } -} diff --git a/PrivaPub.Tests/Federation/PollTests.cs b/PrivaPub.Tests/Federation/PollTests.cs deleted file mode 100644 index 88669b6..0000000 --- a/PrivaPub.Tests/Federation/PollTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class PollTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; - - async Task Follows(RemoteActor follower, string target) => - await _harness.Deliver(follower, "/human-centipede", new JsonObject - { - ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target - }); - - JsonObject Vote(RemoteActor voter, string question, string owner, string choice) => new() - { - ["id"] = NewId(voter, "activities"), ["type"] = "Create", ["actor"] = voter.Id, ["to"] = new JsonArray(owner), - ["object"] = new JsonObject - { - ["id"] = NewId(voter, "votes"), ["type"] = "Note", ["name"] = choice, ["attributedTo"] = voter.Id, - ["inReplyTo"] = question, ["to"] = new JsonArray(owner) - } - }; - - [Fact] - public async Task A_local_poll_goes_out_as_a_question_and_counts_each_remote_voter_once() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - await Follows(mallory, alice.Uri); - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token); - Assert.True(outcome.Ok); - var question = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue() == "Create")["object"]!; - Assert.Equal("Question", question["type"]!.GetValue()); - Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue())); - - await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "coffee")); - await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "tea")); - - var poll = (await DB.Default.Find().OneAsync(outcome.Post.ID, token)).Poll; - Assert.Equal(new[] { 0, 1 }, poll.Options.Select(o => o.Votes)); - Assert.Equal(1, poll.VotersCount); - Assert.False(await DB.Default.Find().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_persona_votes_on_a_remote_poll_and_a_count_refresh_is_not_an_edit() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var questionId = NewId(mallory, "questions"); - JsonObject Question(int first, int second) => new() - { - ["id"] = questionId, ["type"] = "Question", ["attributedTo"] = mallory.Id, ["content"] = "

pick

", - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }), - ["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"), - ["anyOf"] = new JsonArray( - new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = first } }, - new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = second } }) - }; - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = Question(3, 1) - }); - var post = await DB.Default.Find().Match(p => p.ObjectURI == questionId).ExecuteFirstAsync(token); - Assert.True(post.Poll.Multiple); - - Assert.True((await _harness.Polls.Vote(alice, post, new[] { 0, 1 }, token)).Ok); - Assert.False((await _harness.Polls.Vote(alice, post, new[] { 0 }, token)).Ok); - - var votes = (await _harness.Outgoing(mallory.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Create").Select(a => a["object"]!).ToList(); - Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue()).Order().Reverse()); - Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue())); - Assert.All(votes, v => Assert.Null(v["published"])); - Assert.Equal(new[] { 0, 1 }, (await _harness.Polls.OwnVotes(post.ID, alice.Id, token)).Order()); - - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "activities"), ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = Question(9, 5) - }); - var refreshed = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal(new[] { 9, 5 }, refreshed.Poll.Options.Select(o => o.Votes)); - Assert.Empty(refreshed.Revisions); - Assert.Null(refreshed.EditedAt); - } - - [Fact] - public async Task Closing_a_poll_tells_its_voters_and_its_author() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var (_, bob) = await _harness.Persona("bob"); - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quick", Poll = new PollDraft(new[] { "yes", "no" }, 300, false, false) }, token); - Assert.True((await _harness.Polls.Vote(bob, outcome.Post, new[] { 0 }, token)).Ok); - Assert.False((await _harness.Polls.Vote(alice, outcome.Post, new[] { 0 }, token)).Ok); - - var job = await DB.Default.Find().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteFirstAsync(token); - await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token); - - Assert.NotNull((await DB.Default.Find().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt); - var told = await DB.Default.Find().Match(n => n.Type == NotificationType.Poll && n.PostId == outcome.Post.ID).ExecuteAsync(token); - Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order()); - Assert.False((await _harness.Polls.Vote(bob, await DB.Default.Find().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok); - } - } -} diff --git a/PrivaPub.Tests/Federation/ProvenanceTests.cs b/PrivaPub.Tests/Federation/ProvenanceTests.cs deleted file mode 100644 index 031ffef..0000000 --- a/PrivaPub.Tests/Federation/ProvenanceTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class ProvenanceTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - [Fact] - public async Task A_delivered_post_keeps_its_raw_object_how_it_came_and_every_edit() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var origin = new Uri(mallory.Id).GetLeftPart(UriPartial.Authority); - var note = new JsonObject - { - ["id"] = $"{origin}/notes/{Guid.NewGuid():N}", - ["type"] = "Note", - ["attributedTo"] = mallory.Id, - ["content"] = "

hi

", - ["_misskey_quote"] = $"{origin}/notes/elsewhere", - ["to"] = new JsonArray(Addressing.Public), - ["cc"] = new JsonArray(alice.Uri), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }) - }; - var create = new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = note.DeepClone() }; - - await _harness.Deliver(mallory, "/human-centipede", create); - var edited = note.DeepClone().AsObject(); - edited["content"] = "

hi again

"; - edited["updated"] = DateTime.UtcNow.ToString("O"); - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = edited - }); - - var record = await DB.Default.Find().Match(r => r.ObjectURI == note["id"]!.GetValue()).ExecuteFirstAsync(token); - Assert.NotNull(record); - Assert.Equal(ObjectPath.Delivered, record.Path); - Assert.False(record.Refetched); - Assert.Equal("shared", record.Inbox); - Assert.Equal(mallory.KeyId, record.KeyId); - Assert.Equal(create["id"]!.GetValue(), record.ActivityId); - Assert.Contains("digest", record.SignedHeaders); - Assert.Equal("

hi

", JsonNode.Parse(record.Raw)!["content"]!.GetValue()); - Assert.Contains("legacy-quote", ObjectFeatures.Detect(JsonNode.Parse(record.Raw)!.AsObject())); - Assert.Equal("

hi again

", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue()); - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/QuoteTests.cs b/PrivaPub.Tests/Federation/QuoteTests.cs deleted file mode 100644 index f6bf0ce..0000000 --- a/PrivaPub.Tests/Federation/QuoteTests.cs +++ /dev/null @@ -1,221 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Post; -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class QuoteTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; - - async Task Delivered(RemoteActor author, string mentioned, Action shape = default) - { - var note = new JsonObject - { - ["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "

hello

", - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(mentioned), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned, ["name"] = "@someone" }) - }; - shape?.Invoke(note); - await _harness.Deliver(author, "/human-centipede", new JsonObject { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }); - return await DB.Default.Find().Match(p => p.ObjectURI == note["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); - } - - string Stamp(RemoteActor quotedAuthor, string quoting, string quoted) - { - var path = $"/stamps/{Guid.NewGuid():N}"; - var id = Origin(quotedAuthor) + path; - _harness.Peer.Serve(path, new JsonObject - { - ["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = id, ["type"] = "QuoteAuthorization", - ["attributedTo"] = quotedAuthor.Id, ["interactingObject"] = quoting, ["interactionTarget"] = quoted - }.ToJsonString()); - return id; - } - - [Fact] - public async Task A_misskey_quote_of_a_public_post_is_shown_and_counted() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var ann = new RemoteActor(_harness.Peer, "ann"); - var kitty = new RemoteActor(_harness.Peer, "kitty"); - var original = await Delivered(ann, alice.Uri); - - var quoting = await Delivered(kitty, alice.Uri, n => - { - n["_misskey_quote"] = original.ObjectURI; - n["content"] = $"

look
RE: {original.ObjectURI}

"; - }); - - Assert.Equal(QuoteState.Accepted, quoting.QuoteState); - Assert.Equal(original.ID, quoting.QuotedPostId); - Assert.Equal(1, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); - var status = await new PrivaPub.Api.Mastodon.Mappers.MastodonMapper(_harness.Db, _harness.Local).Status(quoting, alice.Id, token); - Assert.Equal("accepted", status.Quote.State); - Assert.Equal(original.ID, status.Quote.QuotedStatus.Id); - Assert.DoesNotContain("RE:", status.Content); - } - - [Fact] - public async Task A_stamped_quote_is_accepted_a_forged_one_is_not_and_revoking_takes_it_back() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var ann = new RemoteActor(_harness.Peer, "ann"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var original = await Delivered(ann, alice.Uri); - var quotingId = NewId(bob, "notes"); - var stamp = Stamp(ann, quotingId, original.ObjectURI); - var forged = Stamp(ann, NewId(bob, "notes"), original.ObjectURI); - - var quoting = await Delivered(bob, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = stamp; }); - var cheat = await Delivered(bob, alice.Uri, n => { n["quote"] = original.ObjectURI; n["quoteAuthorization"] = forged; }); - var waiting = await Delivered(bob, alice.Uri, n => n["quote"] = original.ObjectURI); - - Assert.Equal(QuoteState.Accepted, quoting.QuoteState); - Assert.Equal(QuoteState.Unauthorized, cheat.QuoteState); - Assert.Equal(QuoteState.Pending, waiting.QuoteState); - - await _harness.Deliver(ann, "/human-centipede", new JsonObject - { - ["id"] = NewId(ann, "activities"), ["type"] = "Delete", ["actor"] = ann.Id, ["object"] = stamp - }); - Assert.Equal(QuoteState.Revoked, (await DB.Default.Find().OneAsync(quoting.ID, token)).QuoteState); - Assert.Equal(0, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); - } - - [Fact] - public async Task A_persona_asks_before_quoting_and_is_stamped_when_accepted() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var ann = new RemoteActor(_harness.Peer, "ann"); - var original = await Delivered(ann, alice.Uri, n => n["interactionPolicy"] = new JsonObject - { - ["canQuote"] = new JsonObject { ["automaticApproval"] = new JsonArray(Addressing.Public) } - }); - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "this", QuotedStatusId = original.ID }, token); - Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState); - var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue() == "QuoteRequest"); - Assert.Equal(original.ObjectURI, request["object"]!.GetValue()); - Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue()); - - await _harness.Deliver(ann, "/human-centipede", new JsonObject - { - ["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id, - ["object"] = request["id"]!.GetValue(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI) - }); - - var quoting = await DB.Default.Find().OneAsync(outcome.Post.ID, token); - Assert.Equal(QuoteState.Accepted, quoting.QuoteState); - Assert.NotNull(quoting.QuoteAuthorizationURI); - Assert.Contains(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue() == "Update" - && a["object"]!["quoteAuthorization"]?.GetValue() == quoting.QuoteAuthorizationURI); - } - - [Fact] - public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted() - { - var token = TestContext.Current.CancellationToken; - var (aliceRoot, alice) = await _harness.Persona("alice"); - var (bobRoot, bob) = await _harness.Persona("bob"); - var kitty = new RemoteActor(_harness.Peer, "kitty"); - var legacy = await Delivered(kitty, alice.Uri); - bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody; - var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post; - - var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token); - Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState); - Assert.False(outcome.Post.QuoteByConsent); - Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok); - Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok); - } - - JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new() - { - ["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri, - ["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "

so true

" } - }; - - [Fact] - public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri }); - var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post; - var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue() == "Create")["object"]!; - Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue()); - - var quotingId = NewId(mallory, "notes"); - await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId)); - var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue() == "Accept" && a["result"] != null); - var licence = accept["result"]!.GetValue(); - Assert.StartsWith(alice.Uri + "/parrot-licences/", licence); - - var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; }); - Assert.Equal(QuoteState.Accepted, quoting.QuoteState); - Assert.Equal(1, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); - - Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token)); - Assert.Equal(QuoteState.Revoked, (await DB.Default.Find().OneAsync(quoting.ID, token)).QuoteState); - Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue() == "Delete" && a["object"]!.GetValue() == licence); - } - - [Fact] - public async Task A_followers_only_persona_turns_a_stranger_down() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers; - var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post; - var stranger = new RemoteActor(_harness.Peer, "stranger"); - - await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes"))); - - Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue()); - } - - [Fact] - public async Task One_persona_quotes_another_with_a_licence() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var (_, bob) = await _harness.Persona("bob"); - var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post; - - var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token); - - Assert.True(outcome.Ok); - Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState); - Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI); - Assert.Equal(1, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); - } - } -} diff --git a/PrivaPub.Tests/Federation/ReactionTests.cs b/PrivaPub.Tests/Federation/ReactionTests.cs deleted file mode 100644 index 835f589..0000000 --- a/PrivaPub.Tests/Federation/ReactionTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels.Post; -using PrivaPub.Domain.Social; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class ReactionNamesTests - { - [Theory] - [InlineData(":blobcat@k.example:", "blobcat")] - [InlineData("👍🏽", "👍🏽")] - [InlineData("❤️", "❤️")] - [InlineData("❤", "❤️")] - [InlineData("✌", "✌️")] - [InlineData("🔥\uFE0F", "🔥")] - [InlineData("nope", null)] - [InlineData("👍👍", null)] - [InlineData(":unknown:", null)] - public void Normalises_what_peers_send(string content, string expected) => - Assert.Equal(expected, Reactions.Normalise(content, new List { new() { Shortcode = "blobcat", URL = "https://k.example/e.png" } })?.Name); - } - - [Trait("Category", "Integration")] - public sealed class ReactionTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; - - async Task LocalPost() - { - var (root, alice) = await _harness.Persona("alice"); - await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "react to me" }, TestContext.Current.CancellationToken); - return await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Misskey_and_pleroma_reactions_are_kept_apart_from_favourites() - { - var token = TestContext.Current.CancellationToken; - var post = await LocalPost(); - var misskey = new RemoteActor(_harness.Peer, "kitty"); - var pleroma = new RemoteActor(_harness.Peer, "pleroma"); - - await _harness.Deliver(misskey, "/human-centipede", new JsonObject - { - ["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = ":blobcat:", - ["_misskey_reaction"] = ":blobcat:", - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Emoji", ["name"] = ":blobcat:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = $"{Origin(misskey)}/emoji/blobcat.png" } }) - }); - await _harness.Deliver(misskey, "/human-centipede", new JsonObject - { - ["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = "❤" - }); - var fire = new JsonObject { ["id"] = NewId(pleroma, "activities"), ["type"] = "EmojiReact", ["actor"] = pleroma.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" }; - await _harness.Deliver(pleroma, "/human-centipede", fire); - - var kept = await DB.Default.Find().Match(r => r.PostId == post.ID).ExecuteAsync(token); - Assert.Equal(new HashSet { "blobcat", "🔥" }, kept.Select(r => r.Emoji).ToHashSet()); - Assert.Equal($"{Origin(misskey)}/emoji/blobcat.png", kept.Single(r => r.Emoji == "blobcat").EmojiURL); - Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); - Assert.True(await DB.Default.Find().Match(n => n.Type == NotificationType.Reaction && n.Emoji == "🔥").ExecuteAnyAsync(token)); - - await _harness.Deliver(pleroma, "/human-centipede", new JsonObject - { - ["id"] = NewId(pleroma, "activities"), ["type"] = "Undo", ["actor"] = pleroma.Id, ["object"] = fire.DeepClone() - }); - Assert.Equal("blobcat", Assert.Single(await DB.Default.Find().Match(r => r.PostId == post.ID).ExecuteAsync(token)).Emoji); - } - - [Fact] - public async Task A_persona_reacts_to_a_remote_post_and_takes_it_back() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - var noteId = NewId(mallory, "notes"); - await _harness.Deliver(mallory, "/human-centipede", new JsonObject - { - ["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, - ["object"] = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "

hi

", - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }) - } - }); - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token); - - Assert.True(await _harness.Reactions.React(alice, post, "🎉", token)); - Assert.False(await _harness.Reactions.React(alice, post, "not an emoji", token)); - Assert.True(await _harness.Reactions.Unreact(alice, post, "🎉", token)); - - var sent = await _harness.Outgoing(mallory.Id + "/inbox"); - var react = Assert.Single(sent, a => a["type"]!.GetValue() == "EmojiReact"); - Assert.Equal("🎉", react["content"]!.GetValue()); - Assert.Equal(noteId, react["object"]!.GetValue()); - Assert.Equal(react["id"]!.GetValue(), Assert.Single(sent, a => a["type"]!.GetValue() == "Undo")["object"]!["id"]!.GetValue()); - } - } -} diff --git a/PrivaPub.Tests/Federation/RelayTests.cs b/PrivaPub.Tests/Federation/RelayTests.cs deleted file mode 100644 index 81c4026..0000000 --- a/PrivaPub.Tests/Federation/RelayTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // relays the configuration names: the instance actor follows Public there, and the public posts the relay passes on, - // forwarded or announced, come to the federated timeline as their authors' posts; nothing else is taken from it - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class RelayTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - { - _harness.RelayOptions.Relays.Clear(); - await _harness.Relays.Reconcile(TestContext.Current.CancellationToken); - await _harness.DisposeAsync(); - } - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - string Note(RemoteActor author, string text, string to = "https://www.w3.org/ns/activitystreams#Public") - { - var path = $"/notes/{Guid.NewGuid():N}"; - _harness.Peer.Serve(path, new JsonObject - { - ["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", - ["to"] = new JsonArray(to), ["published"] = DateTime.UtcNow.ToString("O") - }.ToJsonString()); - return Origin(author) + path; - } - - JsonObject Create(RemoteActor author, string noteId) => new() - { - ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id, - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), - ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id } - }; - - // a relay the instance actor subscribes to, which accepts - async Task Subscribed() - { - var relay = new RemoteActor(_harness.Peer, "relay", type: "Service"); - _harness.RelayOptions.Relays.Add(relay.Id); - await _harness.Relays.Reconcile(Token); - var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue() == "Follow"); - await _harness.Deliver(relay, "/human-centipede", new JsonObject - { - ["id"] = $"{relay.Id}#accept-{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = relay.Id, ["object"] = follow.DeepClone() - }); - return relay; - } - - [Fact] - public async Task The_instance_actor_follows_public_at_a_configured_relay_and_takes_its_answer() - { - var relay = await Subscribed(); - - var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox")); - Assert.Equal(("Follow", "https://www.w3.org/ns/activitystreams#Public"), (follow["type"]!.GetValue(), follow["object"]!.GetValue())); - Assert.Equal((await _harness.Local.GetInstanceActor(Token)).Uri, follow["actor"]!.GetValue()); - Assert.Equal(RelayState.Accepted, (await DB.Default.Find().Match(s => s.ActorURI == relay.Id).ExecuteSingleAsync(Token)).State); - Assert.True(await _harness.Relays.Passes(relay.Id, Token)); - - _harness.RelayOptions.Relays.Clear(); - await _harness.Relays.Reconcile(Token); - - Assert.Contains(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue() == "Undo"); - Assert.False(await DB.Default.Find().Match(s => s.ActorURI == relay.Id).ExecuteAnyAsync(Token)); - } - - [Fact] - public async Task A_public_post_a_subscribed_relay_forwards_is_kept_and_one_from_anyone_else_is_not() - { - var relay = await Subscribed(); - var stranger = new RemoteActor(_harness.Peer, "stranger", type: "Service"); - var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B); - var relayed = Note(author, "through the relay"); - var forwarded = Note(author, "through a stranger"); - var followersOnly = Note(author, "for followers", author.Id + "/followers"); - - await _harness.Deliver(relay, "/human-centipede", Create(author, relayed)); - await _harness.Deliver(stranger, "/human-centipede", Create(author, forwarded)); - await _harness.Deliver(relay, "/human-centipede", Create(author, followersOnly)); - - var kept = await DB.Default.Find().Match(p => p.ActorURI == author.Id).ExecuteAsync(Token); - Assert.Equal(new[] { relayed }, kept.Select(p => p.ObjectURI)); - Assert.Equal(PostVisibility.Public, kept[0].Visibility); - } - - // owner decision 2026-10-06: a persona's public post, its edit and its deletion go to the relays too; nothing less - // public does, nor a boost - [Fact] - public async Task A_personas_public_post_goes_to_the_relays_and_nothing_less_public() - { - var relay = await Subscribed(); - var (_, alice) = await _harness.Persona("alice"); - var token = Token; - - var open = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for everyone", Visibility = PostVisibility.Public }, token); - await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "quietly", Visibility = PostVisibility.Unlisted }, token); - await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for followers", Visibility = PostVisibility.FollowersOnly }, token); - - var sent = (await _harness.Outgoing(relay.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Create").ToList(); - var create = Assert.Single(sent); - Assert.Equal(alice.PostUri(open.Post.ID), create["object"]!["id"]!.GetValue()); - } - - [Fact] - public async Task A_post_a_relay_announces_is_its_authors_never_the_relays_boost() - { - var relay = await Subscribed(); - var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B); - var noteId = Note(author, "announced by the relay"); - - await _harness.Deliver(relay, "/human-centipede", new JsonObject - { - ["id"] = $"{relay.Id}/activities/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = relay.Id, - ["to"] = new JsonArray(relay.Id + "/followers"), ["object"] = noteId - }); - - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token); - Assert.Equal(author.Id, post.ActorURI); - Assert.False(await DB.Default.Find().Match(p => p.ReblogOfPostId == post.ID).ExecuteAnyAsync(Token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs b/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs deleted file mode 100644 index 380c07c..0000000 --- a/PrivaPub.Tests/Federation/RemoteActorServiceTests.cs +++ /dev/null @@ -1,254 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; - -using PrivaPub.Federation.Actors; -using PrivaPub.Models; -using PrivaPub.StaticServices; -using PrivaPub.Tests.Support; - -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - [Trait("Category", "Integration")] - public sealed class RemoteActorServiceTests : IAsyncLifetime - { - Peer _peer; - RemoteActorService _service; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _peer = await Peer.Start(); - var cache = new MemoryCache(new MemoryCacheOptions()); - var local = new LocalActorService(new DbEntities(), - new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })); - _service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities()); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task A_stored_actor_remembers_the_extensions_its_document_uses() - { - var bob = new RemoteActor(_peer, "bob"); - - var stored = await _service.GetActor(bob.Id, refresh: true, TestContext.Current.CancellationToken); - - Assert.Contains("key:rsa-2048", stored.Features); - } - - static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") => - new JsonObject - { - ["id"] = id, - ["type"] = type, - ["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..], - ["inbox"] = id + "/inbox", - ["publicKey"] = new JsonObject - { - ["id"] = keyId ?? id + "#main-key", - ["owner"] = owner ?? id, - ["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem - } - }.ToJsonString(); - - string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}"; - - [Fact] - public async Task Accepts_an_actor_whose_document_names_its_own_address() - { - var path = Unique("alice"); - _peer.Serve(path, Actor("{A}" + path)); - - var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken); - - Assert.NotNull(actor); - Assert.Equal(_peer.A + path, actor.ActorURI); - Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId); - } - - [Fact] - public async Task Refuses_a_document_that_claims_another_origin() - { - var victim = Unique("bob"); - var mallory = Unique("mallory"); - _peer.Serve(mallory, Actor("{B}" + victim)); - - var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken); - var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken); - - Assert.Null(actor); - Assert.Null(byKey); - } - - [Fact] - public async Task Refuses_a_key_owned_by_someone_else() - { - var eve = Unique("eve"); - _peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice")); - - Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Refuses_a_key_document_whose_owner_is_on_another_origin() - { - var alice = Unique("alice"); - var key = $"/keys/{Guid.NewGuid():N}"; - _peer.Serve(alice, Actor("{B}" + alice)); - _peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString()); - - Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Refuses_a_key_the_actor_does_not_list() - { - var alice = Unique("alice"); - _peer.Serve(alice, Actor("{A}" + alice)); - - Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Follows_a_same_origin_alias_to_the_actor_it_names() - { - var alice = Unique("alice"); - var alias = $"/@alias{Guid.NewGuid():N}"; - _peer.Serve(alice, Actor("{A}" + alice)); - _peer.Serve(alias, Actor("{A}" + alice)); - - var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken); - - Assert.NotNull(actor); - Assert.Equal(_peer.A + alice, actor.ActorURI); - } - - [Fact] - public async Task Resolves_a_gotosocial_style_key_address() - { - var gts = Unique("gts"); - var keyPath = gts + "/main-key"; - var pem = Keys.NewKeyPair().PublicKeyPem; - _peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); - _peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); - - var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken); - - Assert.NotNull(actor); - Assert.Equal(_peer.A + gts, actor.ActorURI); - Assert.Equal(pem, actor.PublicKey); - } - - [Fact] - public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval() - { - var alice = Unique("alice"); - var keyId = $"{_peer.A}{alice}#main-key"; - var first = Keys.NewKeyPair().PublicKeyPem; - var second = Keys.NewKeyPair().PublicKeyPem; - var third = Keys.NewKeyPair().PublicKeyPem; - _peer.Serve(alice, Actor("{A}" + alice, pem: first)); - var token = TestContext.Current.CancellationToken; - - Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey); - _peer.Serve(alice, Actor("{A}" + alice, pem: second)); - Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); - - var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(), - new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })), - new MemoryCache(new MemoryCacheOptions()), new DbEntities()); - Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); - _peer.Serve(alice, Actor("{A}" + alice, pem: third)); - Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); - Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token)); - } - - [Fact] - public async Task Signs_every_fetch() - { - var alice = Unique("alice"); - _peer.Serve(alice, Actor("{A}" + alice)); - - await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken); - - var request = Assert.Single(_peer.Requests, r => r.Path == alice); - Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature); - } - } - - public class ActorDocumentTests - { - [Fact] - public void Reads_a_mastodon_actor() - { - var document = ActorDocument.Parse(JsonDocument.Parse(""" - { - "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], - "id": "https://mastodon.example/users/alice", - "type": "Person", - "preferredUsername": "alice", - "name": "Alice", - "inbox": "https://mastodon.example/users/alice/inbox", - "outbox": "https://mastodon.example/users/alice/outbox", - "url": "https://mastodon.example/@alice", - "discoverable": false, - "endpoints": { "sharedInbox": "https://mastodon.example/inbox" }, - "icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" }, - "publicKey": { - "id": "https://mastodon.example/users/alice#main-key", - "owner": "https://mastodon.example/users/alice", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n" - } - } - """).RootElement); - - Assert.Equal("alice", document.PreferredUsername); - Assert.Equal("https://mastodon.example/inbox", document.SharedInbox); - Assert.Equal("https://files.mastodon.example/a.png", document.Icon); - Assert.False(document.Discoverable); - Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key")); - } - - [Fact] - public void Keeps_only_keys_owned_by_the_actor_on_its_origin() - { - var document = ActorDocument.Parse(JsonDocument.Parse(""" - { - "id": "https://a.example/users/alice", - "type": "Person", - "publicKey": [ - { "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" }, - { "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" }, - { "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" }, - { "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" } - ] - } - """).RootElement); - - Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id); - } - - [Fact] - public void Refuses_an_inbox_on_another_origin() - { - var document = ActorDocument.Parse(JsonDocument.Parse(""" - { "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" } - """).RootElement); - - Assert.Null(document.Inbox); - } - - [Theory] - [InlineData("Note")] - [InlineData("Tombstone")] - [InlineData("Collection")] - public void Refuses_non_actor_types(string type) => - Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement)); - } -} diff --git a/PrivaPub.Tests/Federation/RemoteEditsTests.cs b/PrivaPub.Tests/Federation/RemoteEditsTests.cs deleted file mode 100644 index 3198232..0000000 --- a/PrivaPub.Tests/Federation/RemoteEditsTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Post; - -namespace PrivaPub.Tests.Federation -{ - public class RemoteEditsTests - { - static readonly DateTime Published = new(2026, 10, 3, 10, 40, 1, DateTimeKind.Utc); - - static Post Stored(DateTime? editedAt = default) => new() { CreationDate = Published, EditedAt = editedAt, ContentHtml = "

before

" }; - - [Fact] - public void A_newer_update_is_an_edit_and_an_update_without_a_date_is_a_refresh() - { - Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(1), ContentHtml = "

before

" }, Stored())); - Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "

after

" }, Stored())); - Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "

after

" }, Stored())); - } - - [Fact] - public void An_edit_in_the_second_the_post_was_published_counts_when_the_text_changed() - { - Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "

after

" }, Stored())); - Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "

before

", SpoilerText = "now with a warning" }, Stored())); - Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "

before

" }, Stored())); - } - - [Fact] - public void After_a_first_edit_only_a_newer_one_counts() - { - var edited = Published.AddMinutes(1); - - Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = edited, ContentHtml = "

again

" }, Stored(edited))); - Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "

again

" }, Stored(edited))); - } - } -} diff --git a/PrivaPub.Tests/Federation/RemoteHandleTests.cs b/PrivaPub.Tests/Federation/RemoteHandleTests.cs deleted file mode 100644 index 4ac6990..0000000 --- a/PrivaPub.Tests/Federation/RemoteHandleTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; - -using MongoDB.Entities; - -using PrivaPub.Domain.Relationships; -using PrivaPub.Federation.Actors; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // an account's handle: user@host, or the domain its `webfinger` names (FEP-2c59; Mastodon's LOCAL_DOMAIN apart from - // its WEB_DOMAIN) once that domain's WebFinger says the handle is this account - [Trait("Category", "Integration")] - public sealed class RemoteHandleTests : IAsyncLifetime - { - Harness _harness; - RemoteActorService _remote; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - _remote = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db, - new StaticOptions(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true })); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - // the actor at the peer's first address, its handle at the second - RemoteActor Actor(string name) => Serve(new RemoteActor(_harness.Peer, name)); - - RemoteActor Serve(RemoteActor actor, string preferredUsername = default) - { - var document = actor.Document(); - if (preferredUsername != default) - document["preferredUsername"] = preferredUsername; - document["webfinger"] = $"acct:{preferredUsername ?? actor.Name}@{new Uri(_harness.Peer.B).Authority}"; - _harness.Peer.Serve(new Uri(actor.Id).AbsolutePath, document.ToJsonString()); - return actor; - } - - void WebFinger(string self) => _harness.Peer.ServeText("/.well-known/webfinger", new JsonObject - { - ["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = self }) - }.ToJsonString(), "application/jrd+json"); - - [Fact] - public async Task A_handle_on_another_domain_is_kept_once_its_webfinger_agrees() - { - var actor = Actor("split"); - WebFinger(actor.Id); - - var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); - - Assert.Equal(new Uri(_harness.Peer.B).Authority, stored.Domain); - } - - [Fact] - public async Task A_handle_its_webfinger_does_not_confirm_stays_on_the_actors_host() - { - var actor = Actor("claims"); - WebFinger("http://localhost/users/someone-else"); - - var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); - - Assert.Equal(new Uri(actor.Id).Authority, stored.Domain); - } - - [Fact] - public async Task A_renamed_account_is_asked_again() - { - var actor = Actor("renames"); - WebFinger(actor.Id); - await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); - - Serve(actor, "renamed"); - WebFinger("http://localhost/users/someone-else"); - // (another cache: the first fetch holds the actor back for a while) - var later = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db, - new StaticOptions(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true })); - var stored = await later.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); - - Assert.Equal("renamed", stored.UserName); - Assert.Equal(new Uri(actor.Id).Authority, stored.Domain); - } - - [Fact] - public async Task A_persona_blocking_the_handles_server_hides_the_account() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var split = Actor("split"); - WebFinger(split.Id); - await _remote.GetActor(split.Id, refresh: true, token); - var claims = Actor("claims"); - WebFinger("http://localhost/users/someone-else"); - await new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db, - new StaticOptions(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true })) - .GetActor(claims.Id, refresh: true, token); - - await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = alice.Id, Domain = new Uri(_harness.Peer.B).Host }, token); - - Assert.Equal([split.Id], await Hidden.AuthorsHiddenFrom(alice.Id, new[] { split.Id, claims.Id }, forNotifications: false, token)); - Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, split.Id, token)); - Assert.Empty(await Hidden.RecipientsHiding(new[] { alice.Id }, claims.Id, token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/RendererTests.cs b/PrivaPub.Tests/Federation/RendererTests.cs deleted file mode 100644 index f80e50c..0000000 --- a/PrivaPub.Tests/Federation/RendererTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Rendering; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - public class RendererTests - { - static readonly LocalActor Alice = new() - { - Id = "a1", - Kind = LocalActorKind.Person, - UserName = "alice", - Name = "Alice", - Summary = "I **like** cats", - PublicKeyPem = Keys.NewKeyPair().PublicKeyPem, - Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc), - Fields = new Dictionary { ["site"] = "https://alice.example", ["mood"] = "ok" }, - BaseAddress = "https://privapub.test" - }; - - [Fact] - public void An_actor_looks_like_a_mastodon_actor_with_our_route_names() - { - var actor = ActivityPubRenderer.Actor(Alice); - - Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue()); - Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue()); - Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue()); - Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue()); - Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue()); - Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue()); - Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue()); - Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue()); - Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue()); - Assert.False(actor["indexable"]!.GetValue()); - Assert.Equal("

I like <b>cats</b>

", actor["summary"]!.GetValue()); - var fields = actor["attachment"]!.AsArray(); - Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue()); - Assert.Equal("<i>ok</i>", fields[1]!["value"]!.GetValue()); - Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue()); - Assert.Equal(actor["id"]!.GetValue(), actor["publicKey"]!["owner"]!.GetValue()); - } - - [Fact] - public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon() - { - var post = new Post - { - ID = "p1", - Title = "On ", - ContentHtml = "

body

", - HasContentWarning = true, - Language = "en", - Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } }, - Tags = new() { "cats" }, - CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc) - }; - - var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1"); - - Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue()); - Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue()); - Assert.Equal("

On <cats>

body

", note["content"]!.GetValue()); - Assert.Equal("On ", note["summary"]!.GetValue()); - Assert.True(note["sensitive"]!.GetValue()); - Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue()); - Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue())); - Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue())); - var tags = note["tag"]!.AsArray(); - Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue()); - Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue()); - Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue()); - Assert.NotNull(note["contentMap"]!["en"]); - } - - [Fact] - public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary() - { - var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "

x

", HasContentWarning = true }, Alice, default, default); - - Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue()); - } - - [Fact] - public void A_direct_note_is_addressed_to_its_recipients_only() - { - var post = new Post { ID = "p3", ContentHtml = "

psst

", Visibility = PostVisibility.Direct }; - - var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") }, - Alice.ConversationUri("c1")); - - Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue())); - Assert.Empty(note["cc"]!.AsArray()); - Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue()); - Assert.StartsWith("

()); - Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue()); - } - } -} diff --git a/PrivaPub.Tests/Federation/ReplyRelayTests.cs b/PrivaPub.Tests/Federation/ReplyRelayTests.cs deleted file mode 100644 index 8409a31..0000000 --- a/PrivaPub.Tests/Federation/ReplyRelayTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers, - // who are not among those its author's server sends it to - [Trait("Category", "Integration")] - public sealed class ReplyRelayTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - // alice's post; bob replies from his server, fan follows alice from another, pal from bob's - async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B); - var pal = new RemoteActor(_harness.Peer, "pal"); - await _harness.FollowedBy(alice, fan); - await _harness.FollowedBy(alice, pal); - var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post; - return (alice, post, bob, fan, pal); - } - - JsonObject Reply(RemoteActor bob, Post post, string visibility = "public") - { - var reply = PublicNote(bob, "

I think so

", post.ActorURI); - reply["inReplyTo"] = post.ObjectURI; - if (visibility == "followers") - { - reply["to"] = new JsonArray(bob.Id + "/followers"); - reply["cc"] = new JsonArray(post.ActorURI); - } - return reply; - } - - Task> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox); - - [Fact] - public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server() - { - var (_, post, bob, fan, pal) = await Thread(); - var create = Create(bob, Reply(bob, post)); - create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" }; - - await _harness.Deliver(bob, "/human-centipede", create); - - var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); - Assert.Equal(bob.Id, relayed["actor"]!.GetValue()); - Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue()); - Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue() == IdOf(create)); - } - - [Fact] - public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent() - { - var (_, post, bob, fan, _) = await Thread(); - var reply = Reply(bob, post); - await _harness.Deliver(bob, "/human-centipede", Create(bob, reply)); - var edited = (JsonObject)reply.DeepClone(); - edited["content"] = "

I think so, on second thought

"; - edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); - var update = Activity(bob, "Update", edited); - await _harness.Deliver(bob, "/human-centipede", update); - var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!); - await _harness.Deliver(bob, "/human-centipede", delete); - - var relayed = (await At(fan)).Select(a => a["id"]!.GetValue()).ToList(); - Assert.Contains(IdOf(update), relayed); - Assert.Contains(IdOf(delete), relayed); - - var quiet = Create(bob, Reply(bob, post, "followers")); - await _harness.Deliver(bob, "/human-centipede", quiet); - Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(quiet)); - } - - [Fact] - public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again() - { - var (_, post, bob, fan, _) = await Thread(); - var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B); - var reply = Reply(bob, post); - _harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString()); - var create = Create(bob, reply); - - var result = await _harness.Deliver(other, "/human-centipede", create); - - Assert.Equal("forwarded", result.Reason); - Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken)); - Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); - } - } -} diff --git a/PrivaPub.Tests/Federation/ReusedIdTests.cs b/PrivaPub.Tests/Federation/ReusedIdTests.cs deleted file mode 100644 index 0880680..0000000 --- a/PrivaPub.Tests/Federation/ReusedIdTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.FederatedSeeds; - -namespace PrivaPub.Tests.Federation -{ - // A deleted remote post is never brought back by its own Create, however late it comes; an id its server gives a new - // post afterwards (Gancio numbers an event after the last one it keeps) is that new post - [Trait("Category", "Integration")] - public sealed class ReusedIdTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - [Fact] - public async Task A_deleted_posts_create_stays_dropped_and_a_new_post_under_its_id_is_kept() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var carol = new RemoteActor(_harness.Peer, "carol"); - await Follows(alice.Id, carol); - var first = PublicNote(carol, "

the first event

"); - first["published"] = DateTime.UtcNow.AddMinutes(-10).ToString("O"); - await _harness.Deliver(carol, "/human-centipede", Create(carol, first)); - await _harness.Deliver(carol, "/human-centipede", new JsonObject - { - ["id"] = NewId(carol, "deletes"), ["type"] = "Delete", ["actor"] = carol.Id, - ["object"] = new JsonObject { ["id"] = IdOf(first), ["type"] = "Tombstone" } - }); - Assert.True(await DB.Default.Find().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token)); - - // the first Create again, late - await _harness.Deliver(carol, "/human-centipede", Create(carol, (JsonObject)first.DeepClone())); - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteAnyAsync(token)); - - var second = PublicNote(carol, "

a new event, numbered as the first

"); - second["id"] = IdOf(first); - second["published"] = DateTime.UtcNow.ToString("O"); - await _harness.Deliver(carol, "/human-centipede", Create(carol, second)); - - var kept = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); - Assert.Contains("a new event", kept?.ContentHtml); - Assert.False(await DB.Default.Find().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/ServiceReportTests.cs b/PrivaPub.Tests/Federation/ServiceReportTests.cs deleted file mode 100644 index 466f31b..0000000 --- a/PrivaPub.Tests/Federation/ServiceReportTests.cs +++ /dev/null @@ -1,252 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Outbox; -using PrivaPub.Federation.Signing; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; - -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner - // decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post. - // Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there. - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class ServiceReportTests : IAsyncLifetime - { - static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; - - // (a test sees only the deliveries queued since it began: the others' share the database) - readonly DateTime _started = DateTime.UtcNow; - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - } - - public async ValueTask DisposeAsync() - { - if (_harness == default) - return; - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - await _harness.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - // what NodeInfo said the server at that host runs - static async Task Runs(string host, string software) - { - await DB.Default.DeleteAsync(i => i.Host == host); - await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token); - } - - async Task Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token); - - static async Task Held(RemoteActor author, string community = default, Post parent = default) - { - var post = new Post - { - ObjectURI = $"{Origin(author)}/post/{Guid.NewGuid():N}", - ActorURI = author.Id, - AudienceURI = community, - AnsweringToPostId = parent?.ID, - ContentHtml = "

reported

" - }; - await DB.Default.SaveAsync(post, Token); - return post; - } - - async Task> Queued() => - (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync(Token)) - .Select(j => JsonSerializer.Deserialize(j.Payload)) - .ToList(); - - // a community on a Lemmy, or on a server that takes reports as Lemmy does (the peer as localhost), and one of its posters there - async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy(string software = "lemmy") - { - var community = new RemoteActor(_harness.Peer, "cats", _harness.Peer.B, type: "Group", sharedInbox: true); - var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); - await Known(community); - await Known(poster); - await Runs("localhost", software); - return (community, poster); - } - - // PieFed and Mbin take it too, proven live (2026-10-06); NodeInfo names Lemmy as "lemmy" and kbin's heir as "mbin" - [Theory] - [InlineData("lemmy")] - [InlineData("Lemmy")] - [InlineData("piefed")] - [InlineData("mbin")] - public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else(string software) - { - var (_, alice) = await _harness.Persona("alice"); - var (community, poster) = await OnLemmy(software); - var post = await Held(poster, community.Id); - var reporter = await _harness.Local.GetReporterActor(Token); - - var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, " a slur in the title ", "violation", forward: true, Token); - - Assert.True(report.Forwarded); - var flag = Assert.Single(await _harness.Outgoing(community.SharedInbox)); - Assert.Equal("Flag", flag["type"]!.GetValue()); - Assert.Equal(reporter.Uri, flag["actor"]!.GetValue()); - Assert.EndsWith("/peasants/privapub_reports", reporter.Uri); - Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue()); - Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue())); - Assert.Equal(community.Id, flag["audience"]!.GetValue()); - Assert.Equal(post.ObjectURI, flag["object"]!.GetValue()); - Assert.Equal("a slur in the title", flag["summary"]!.GetValue()); - Assert.Equal("a slur in the title", flag["content"]!.GetValue()); - Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); - Assert.DoesNotContain(await Queued(), q => q.Inbox != community.SharedInbox && q.Body.Contains(post.ObjectURI)); - Assert.DoesNotContain(alice.UserName, flag.ToJsonString()); - Assert.DoesNotContain(alice.Id, flag.ToJsonString()); - var queued = Assert.Single(await Queued(), q => q.Inbox == community.SharedInbox); - Assert.Equal(LocalActorKind.Reporter, queued.SignerKind); - Assert.Equal(reporter.Id, queued.SignerId); - } - - [Fact] - public async Task The_reporter_signs_its_flag_with_its_own_key() - { - var (_, alice) = await _harness.Persona("alice"); - var (community, poster) = await OnLemmy(); - var post = await Held(poster, community.Id); - var reporter = await _harness.Local.GetReporterActor(Token); - var instance = await _harness.Local.GetInstanceActor(Token); - _harness.Peer.Answer("/inbox", 202); - await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token); - var job = await DB.Default.Find() - .Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(reporter.ActivityUri($"flag-"))) - .Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(Token); - - var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), - NullLogger.Instance); - var outcome = await handler.Handle(job, Token); - - Assert.Equal(JobResult.Done, outcome.Result); - var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/inbox" && r.Method == "POST"); - // no words: Lemmy takes no report without a reason, so the category is it - Assert.Equal("spam", JsonNode.Parse(received.Body)!["summary"]!.GetValue()); - var signature = HttpSignatures.Parse(received.Signature); - Assert.Equal(reporter.KeyId, signature.KeyId); - Assert.NotEqual(instance.PublicKeyPem, reporter.PublicKeyPem); - var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}"; - using var key = RSA.Create(); - key.ImportFromPem(reporter.PublicKeyPem); - Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - } - - [Fact] - public async Task A_comment_finds_its_community_through_its_thread_and_two_posts_are_two_flags() - { - var (_, alice) = await _harness.Persona("alice"); - var (community, poster) = await OnLemmy(); - var thread = await Held(poster, community.Id); - var comment = await Held(poster, parent: thread); - var answer = await Held(poster, parent: comment); - - var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { comment.ID, answer.ID }, "harassment", "other", forward: true, Token); - - Assert.True(report.Forwarded); - var flags = await _harness.Outgoing(community.SharedInbox); - Assert.Equal(2, flags.Count); - Assert.Equal(new[] { comment.ObjectURI, answer.ObjectURI }.Order(), flags.Select(f => f["object"]!.GetValue()).Order()); - Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue())); - Assert.Equal(2, flags.Select(f => f["id"]!.GetValue()).Distinct().Count()); - } - - [Fact] - public async Task An_account_alone_on_a_lemmy_is_reported_to_nobody() - { - var (_, alice) = await _harness.Persona("alice"); - var (community, poster) = await OnLemmy(); - - var report = await _harness.Reports.File(alice, (await Known(poster)).ID, Array.Empty(), "a spammer", "spam", forward: true, Token); - - Assert.False(report.Forwarded); - Assert.False((await DB.Default.Find().OneAsync(report.ID, Token)).Forwarded); - Assert.Empty(await _harness.Outgoing(community.SharedInbox)); - Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); - } - - [Fact] - public async Task Any_other_server_still_gets_the_instance_flag_and_a_lemmy_community_its_own() - { - var (_, alice) = await _harness.Persona("alice"); - var (community, _) = await OnLemmy(); - // an account on a Mastodon (the peer as 127.0.0.1) that wrote in the community on the Lemmy, and elsewhere - var mastodonian = new RemoteActor(_harness.Peer, "masto"); - await Runs("127.0.0.1", "mastodon"); - var inCommunity = await Held(mastodonian, community.Id); - var elsewhere = await Held(mastodonian); - var instance = await _harness.Local.GetInstanceActor(Token); - - var report = await _harness.Reports.File(alice, (await Known(mastodonian)).ID, new[] { inCommunity.ID, elsewhere.ID }, "spam", "spam", forward: true, Token); - - Assert.True(report.Forwarded); - var toCommunity = Assert.Single(await _harness.Outgoing(community.SharedInbox)); - Assert.Equal(inCommunity.ObjectURI, toCommunity["object"]!.GetValue()); - var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox")); - Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue()); - Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(), - toAuthor["object"]!.AsArray().Select(o => o!.GetValue()).Order()); - Assert.Null(toAuthor["to"]); - } - - [Fact] - public async Task A_community_on_a_server_that_takes_no_service_reports_gets_nothing_new() - { - var (_, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "forum", _harness.Peer.B, type: "Group", sharedInbox: true); - var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); - await Known(community); - var foreign = await Known(poster); - await Runs("localhost", "friendica"); - var post = await Held(poster, community.Id); - - var report = await _harness.Reports.File(alice, foreign.ID, new[] { post.ID }, "spam", "spam", forward: true, Token); - - Assert.True(report.Forwarded); - Assert.Empty(await _harness.Outgoing(community.SharedInbox)); - var flag = Assert.Single(await _harness.Outgoing(poster.Id + "/inbox")); - Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue()); - } - - [Fact] - public async Task Nobody_follows_the_reporter() - { - var follower = new RemoteActor(_harness.Peer, "follower"); - var reporter = await _harness.Local.GetReporterActor(Token); - - var result = await _harness.Deliver(follower, "/human-centipede", new JsonObject - { - ["id"] = $"{Origin(follower)}/follow/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = reporter.Uri - }); - - Assert.Equal(404, result.StatusCode); - Assert.False(await DB.Default.Find().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token)); - } - } -} diff --git a/PrivaPub.Tests/Federation/SharedInboxTests.cs b/PrivaPub.Tests/Federation/SharedInboxTests.cs deleted file mode 100644 index 5923697..0000000 --- a/PrivaPub.Tests/Federation/SharedInboxTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using PrivaPub.Domain.Statuses; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Federation -{ - // one delivery a server: a reply to a follower's post goes to its server's shared inbox once, not also to the - // follower's own inbox (BookWyrm kept two copies of a post that reached both at once) - [Trait("Category", "Integration")] - public sealed class SharedInboxTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - [Fact] - public async Task A_reply_to_a_followers_post_reaches_its_server_once_through_the_shared_inbox() - { - var token = TestContext.Current.CancellationToken; - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob", sharedInbox: true); - await _harness.FollowedBy(alice, bob); - var path = $"/notes/{Guid.NewGuid():N}"; - var noteId = new Uri(bob.Id).GetLeftPart(UriPartial.Authority) + path; - _harness.Peer.Serve(path, new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["content"] = "

a question

", - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O") - }.ToJsonString()); - var parent = await _harness.RemotePosts.StoreContext(noteId, 0, token); - - var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an answer", InReplyTo = parent.ID }, token); - - Assert.True(reply.Ok, reply.Error); - Assert.Single(await _harness.Outgoing(bob.SharedInbox), a => a["type"]!.GetValue() == "Create"); - Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox")); - } - } -} diff --git a/PrivaPub.Tests/Federation/WallTests.cs b/PrivaPub.Tests/Federation/WallTests.cs deleted file mode 100644 index 9ce8a50..0000000 --- a/PrivaPub.Tests/Federation/WallTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Rendering; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.Host.FederationHelpers; - -using PostEntity = PrivaPub.Models.Post.Post; - -namespace PrivaPub.Tests.Federation -{ - // walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what - // they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere - // that tells of a post on its own wall has it shown to its followers here - [Trait("Category", "Integration")] - public sealed class WallTests : IAsyncLifetime - { - const string Public = "https://www.w3.org/ns/activitystreams#Public"; - - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - // a public note by author, written on the wall of owner (whose actor and wall are given), served at its id - string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public) - { - var path = $"/notes/{Guid.NewGuid():N}"; - var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority); - var note = new JsonObject - { - ["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", - ["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"), - ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri } - }; - if (inReplyTo != default) - note["inReplyTo"] = inReplyTo; - _harness.Peer.Serve(path, note.ToJsonString()); - _notes[origin + path] = note; - return origin + path; - } - - readonly Dictionary _notes = new(); - - // as Smithereen sends it: the whole note, to the wall's owner - JsonObject Create(RemoteActor author, string noteId) => new() - { - ["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(), - ["object"] = _notes[noteId].DeepClone() - }; - - Task Post(string objectUri) => DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token); - - [Fact] - public void A_persona_names_its_wall_and_lets_its_followers_write_on_it() - { - var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person }); - var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true }); - - Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue()); - Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue()); - Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue()); - Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue() == "sm:wall"); - Assert.Null(circle["wall"]); - } - - [Fact] - public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers() - { - var (_, alice) = await _harness.Persona("alice"); - var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true); - await _harness.FollowedBy(alice, smuser); - var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall"); - - await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId)); - - var post = await Post(noteId); - Assert.NotNull(post); - Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId)); - Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); - Assert.True(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token)); - var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); - Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue(), add["object"]!.GetValue(), add["target"]!["id"]!.GetValue())); - Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue() == "Add"); - - Assert.True(await _harness.Walls.Remove(alice, post, Token)); - - Assert.Null(await Post(noteId)); - var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Remove"); - Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue(), remove["target"]!.GetValue())); - } - - [Fact] - public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused() - { - var (_, alice) = await _harness.Persona("alice"); - var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true); - var follower = new RemoteActor(_harness.Peer, "follower", wall: true); - await _harness.FollowedBy(alice, follower); - var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger"); - var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1"); - var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers"); - - await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger)); - await _harness.Deliver(follower, "/human-centipede", Create(follower, reply)); - await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet)); - - Assert.Null(await Post(fromStranger)); - Assert.Null(await Post(reply)); - Assert.Null(await Post(quiet)); - Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); - } - - [Fact] - public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off() - { - var (_, alice) = await _harness.Persona("alice"); - var owner = new RemoteActor(_harness.Peer, "owner", wall: true); - var writer = new RemoteActor(_harness.Peer, "writer"); - await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); - var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall"); - // (kept before PrivaPub read walls: its document is read again when it names one) - await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar - { - ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox", - FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2) - }, Token); - - await _harness.Deliver(owner, "/human-centipede", new JsonObject - { - ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId, - ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id }, - ["to"] = new JsonArray(Public, owner.Id + "/followers") - }); - - var post = await Post(noteId); - Assert.NotNull(post); - Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI)); - Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); - Assert.False(await DB.Default.Find().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token)); - - await _harness.Deliver(owner, "/human-centipede", new JsonObject - { - ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall - }); - - Assert.Null(await Post(noteId)); - } - } - - [Trait("Category", "Integration")] - public sealed class WallCollectionTests : IAsyncLifetime - { - PrivaPubHost _host; - HttpClient _client; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - } - - public ValueTask DisposeAsync() - { - _client?.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it() - { - var token = TestContext.Current.CancellationToken; - var persona = await _host.Persona(await _host.SignUp(), "wall"); - var open = await _host.Publish(persona, "for everyone", PostVisibility.Public); - var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly); - var wall = persona.ActorUri() + "/graffiti"; - var written = new PostEntity - { - ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true, - Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id - }; - await DB.Default.SaveAsync(written, token); - - var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti"); - var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true"); - - Assert.Equal(HttpStatusCode.OK, collection.Status); - Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue(), collection.Json["totalItems"]!.GetValue())); - var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToList(); - Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items); - Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID)); - } - } -} diff --git a/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json b/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json deleted file mode 100644 index b304551..0000000 --- a/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json +++ /dev/null @@ -1 +0,0 @@ -{"attributedTo":"https://gts.test/users/ines_gt59","cc":"https://privapub.test/peasants/flor_pp98","content":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E","contentMap":{"en":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY","inReplyTo":"https://privapub.test/peasants/flor_pp98/scribbles/6ac2beaf092dc0af8ce6035e","interactionPolicy":{"canAnnounce":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canLike":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]},"canQuote":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canReply":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]}},"published":"2026-10-04T21:02:31Z","replies":{"first":{"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true","next":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true\u0026only_other_accounts=false","partOf":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"CollectionPage"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"Collection"},"tag":{"href":"https://privapub.test/peasants/flor_pp98","name":"@flor_pp98@privapub.test","type":"Mention"},"to":"https://gts.test/users/ines_gt59/followers","type":"Note","url":"https://gts.test/@ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY"} diff --git a/PrivaPub.Tests/Fixtures/pleroma/edited.json b/PrivaPub.Tests/Fixtures/pleroma/edited.json deleted file mode 100644 index 2c6c9d0..0000000 --- a/PrivaPub.Tests/Fixtures/pleroma/edited.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "actor": "https://pleroma.test/users/flor_pl97", - "attachment": [], - "attributedTo": "https://pleroma.test/users/flor_pl97", - "cc": [ - "https://pleroma.test/users/flor_pl97/followers" - ], - "content": "mountain late window map bright postcard (p10) (edited)", - "context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", - "conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", - "formerRepresentations": { - "orderedItems": [ - { - "actor": "https://pleroma.test/users/flor_pl97", - "attachment": [], - "attributedTo": "https://pleroma.test/users/flor_pl97", - "cc": [ - "https://pleroma.test/users/flor_pl97/followers" - ], - "content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)", - "contentMap": { - "en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)" - }, - "context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", - "conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", - "published": "2026-10-05T00:49:58.860071Z", - "repliesCount": 2, - "source": { - "content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)", - "mediaType": "text/plain" - }, - "summary": "", - "tag": [], - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "type": "Note" - } - ], - "totalItems": 1, - "type": "OrderedCollection" - }, - "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da", - "published": "2026-10-05T00:49:58.860071Z", - "replies": { - "first": { - "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true", - "orderedItems": [ - "https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887", - "https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35" - ], - "partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies", - "type": "OrderedCollectionPage" - }, - "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies", - "totalItems": 2, - "type": "OrderedCollection" - }, - "repliesCount": 2, - "source": { - "content": "mountain late window map bright postcard (p10) (edited)", - "mediaType": "text/plain" - }, - "summary": "", - "tag": [], - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "type": "Note", - "updated": "2026-10-05T00:52:40.105051Z" -} \ No newline at end of file diff --git a/PrivaPub.Tests/Http/BackupEndpointTests.cs b/PrivaPub.Tests/Http/BackupEndpointTests.cs deleted file mode 100644 index c0ff2bf..0000000 --- a/PrivaPub.Tests/Http/BackupEndpointTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.ClientModels; -using PrivaPub.Infrastructure.Backup; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Formats.Tar; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // The administrator's page backs up, downloads, uploads and restores (owner decision 2026-10-07): only an - // administrator the database still says is one; a download and a restore ask for the password again, a restore for - // the host typed out; an upload resumes where it stopped, and one that would write outside its backup is refused. - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class BackupEndpointTests : IAsyncLifetime - { - const string Base = "/clientapi/admin/backups"; - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() - { - RestoreMarker.Clear(_host?.Get().Root ?? Path.GetTempPath()); - return ValueTask.CompletedTask; - } - - async Task BackedUp(HttpClient admin) - { - var before = _host.Get().List().Select(b => b.Id).ToHashSet(); - Assert.Equal(HttpStatusCode.Accepted, (await admin.PostAsync(Base, null, Token)).StatusCode); - var made = default(string); - Assert.True(await Wait(async () => - { - var list = (await admin.GetFromJsonAsync(Base, Token))!; - made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).FirstOrDefault(id => !before.Contains(id)); - return made != default && list["running"] is null; - }), "the backup was never made"); - return made; - } - - static async Task Wait(Func> done) - { - for (var i = 0; i < 120; i++) - { - if (await done()) - return true; - await Task.Delay(250, Token); - } - return false; - } - - [Fact] - public async Task Only_an_administrator_the_database_still_knows() - { - var root = await _host.SignUp("plain"); - using (var plain = _host.As(root.Jwt)) - Assert.Equal(HttpStatusCode.Forbidden, (await plain.GetAsync(Base, Token)).StatusCode); - - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(Base, Token)).StatusCode); - await DB.Default.Update().MatchID(admin.Id).Modify(u => u.Policies, [Policies.IsUser]).ExecuteAsync(Token); - Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync(Base, Token)).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync(Base, null, Token)).StatusCode); - } - - [Fact] - public async Task A_backup_is_made_downloaded_whole_or_in_part_and_uploaded_again() - { - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - var id = await BackedUp(client); - var listed = (await client.GetFromJsonAsync(Base, Token))!; - Assert.Equal("privapub.test", listed["host"]!.GetValue()); - Assert.Equal("manual", listed["backups"]!.AsArray().Single(b => b!["id"]!.GetValue() == id)!["kind"]!.GetValue()); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = "wrong" }, Token)).StatusCode); - var ticket = (await (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = admin.Password }, Token)).Content.ReadFromJsonAsync(Token))!; - var url = ticket["url"]!.GetValue(); - - // a plain link: no token needed, the ticket is the credential - using var anonymous = _host.Client(); - var whole = await anonymous.GetAsync(url, Token); - Assert.Equal(HttpStatusCode.OK, whole.StatusCode); - var bytes = await whole.Content.ReadAsByteArrayAsync(Token); - Assert.Equal(ticket["bytes"]!.GetValue(), bytes.Length); - Assert.Equal(bytes.Length, whole.Content.Headers.ContentLength); - var names = new List(); - await using (var reader = new TarReader(new MemoryStream(bytes))) - while (await reader.GetNextEntryAsync(cancellationToken: Token) is { } entry) - names.Add(entry.Name); - Assert.Contains($"{id}/manifest.json", names); - Assert.Contains($"{id}/db/Avatar.jsonl.gz", names); - - var part = new HttpRequestMessage(HttpMethod.Get, url); - part.Headers.Range = new RangeHeaderValue(700, 2747); - var partial = await anonymous.SendAsync(part, Token); - Assert.Equal(HttpStatusCode.PartialContent, partial.StatusCode); - Assert.Equal($"bytes 700-2747/{bytes.Length}", partial.Content.Headers.ContentRange!.ToString()); - Assert.Equal(bytes.AsSpan(700, 2048).ToArray(), await partial.Content.ReadAsByteArrayAsync(Token)); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"{Base}/download/{new string('0', 64)}", Token)).StatusCode); - - // gone, then uploaded again in three pieces, one sent at the wrong place and refused with where to go on - Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); - var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - var third = bytes.Length / 3; - Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, bytes[..third])).StatusCode); - var gap = await Piece(client, upload, third + 10, bytes[(third + 10)..]); - Assert.Equal(HttpStatusCode.Conflict, gap.StatusCode); - Assert.Equal(third, (await gap.Content.ReadFromJsonAsync(Token))!["received"]!.GetValue()); - Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, third, bytes[third..(2 * third)])).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 2 * third, bytes[(2 * third)..])).StatusCode); - var finished = await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token); - Assert.Equal(HttpStatusCode.OK, finished.StatusCode); - Assert.Equal("uploaded", (await finished.Content.ReadFromJsonAsync(Token))!["kind"]!.GetValue()); - Assert.Empty(await _host.Get().Verify(id, Token)); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode); - _host.Get().Delete(id); - } - - Task Piece(HttpClient client, string upload, long offset, byte[] bytes) => - client.PutAsync($"{Base}/uploads/{upload}?offset={offset}", new ByteArrayContent(bytes), Token); - - [Theory] - [InlineData("escape")] - [InlineData("link")] - [InlineData("elsewhere")] - [InlineData("junk")] - public async Task An_upload_that_is_not_a_plain_backup_is_refused_and_leaves_nothing(string how) - { - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - const string id = "20260101-000000-manual"; - var tar = new MemoryStream(); - if (how == "junk") - tar.Write("not a tar at all, not even close"u8); - else - await using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true)) - { - await writer.WriteEntryAsync(new PaxTarEntry(TarEntryType.RegularFile, $"{id}/manifest.json") { DataStream = new MemoryStream("{}"u8.ToArray()) }, Token); - await writer.WriteEntryAsync(how switch - { - "escape" => new PaxTarEntry(TarEntryType.RegularFile, $"{id}/../../evil") { DataStream = new MemoryStream("x"u8.ToArray()) }, - "link" => new PaxTarEntry(TarEntryType.SymbolicLink, $"{id}/media/x.jpg") { LinkName = "/etc/passwd" }, - _ => new PaxTarEntry(TarEntryType.RegularFile, "20260101-000000-nightly/db/Post.jsonl.gz") { DataStream = new MemoryStream("x"u8.ToArray()) } - }, Token); - } - var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, tar.ToArray())).StatusCode); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode); - var root = _host.Get().Root; - Assert.False(Directory.Exists(Path.Combine(root, id))); - Assert.False(Directory.Exists(Path.Combine(root, id + ServerBackup.PartialSuffix))); - Assert.False(File.Exists(Path.Combine(root, "..", "evil"))); - } - - // asked for: written for the next start (the test host has no watcher to stop it, and the marker is taken away) - [Fact] - public async Task A_restore_asks_for_the_password_and_the_host_typed_out() - { - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - var id = await BackedUp(client); - var root = _host.Get().Root; - try - { - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = "wrong", host = "privapub.test" }, Token)).StatusCode); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "elsewhere.test" }, Token)).StatusCode); - Assert.Null(RestoreMarker.Read(root)); - - Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "PrivaPub.test" }, Token)).StatusCode); - var marker = RestoreMarker.Read(root); - Assert.Equal((id, "pending", admin.UserName), (marker.Backup, marker.State, marker.RequestedBy)); - Assert.Equal(id, (await client.GetFromJsonAsync(Base, Token))!["waiting"]!["backup"]!.GetValue()); - Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); - } - finally - { - RestoreMarker.Clear(root); - _host.Get().Delete(id); - } - } - } -} diff --git a/PrivaPub.Tests/Http/ClientApiAccountsTests.cs b/PrivaPub.Tests/Http/ClientApiAccountsTests.cs deleted file mode 100644 index 8b000e3..0000000 --- a/PrivaPub.Tests/Http/ClientApiAccountsTests.cs +++ /dev/null @@ -1,492 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.IdentityModel.Tokens; - -using MongoDB.Entities; - -using PrivaPub.ClientModels; -using PrivaPub.Models.Group; -using PrivaPub.Models.User; -using PrivaPub.Services; -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.IdentityModel.Tokens.Jwt; -using System.Net; -using System.Security.Claims; -using System.Text; -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class ClientApiAccountsTests : IAsyncLifetime - { - const string NewPassword = "Other-Pass-2!"; - - 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 LogIn(string userName, string password) - { - using var client = _host.Client(); - return await client.PostJson("/clientapi/user/login", new { userName, password }); - } - - async Task Settings(string jwt) - { - using var client = _host.As(jwt); - return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode; - } - - static async Task Message(HttpResponseMessage response) - { - var body = await response.JsonBody(); - return body["errorMessage"]?.GetValue() ?? body["title"]?.GetValue(); - } - - string Jwt(string rootId, string userName, DateTime expires, string key = default) - { - var configuration = _host.Get(); - var signing = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key ?? configuration["AppConfiguration:Jwt:Key"]!)); - var token = new JwtSecurityToken(configuration["AppConfiguration:Jwt:Issuer"], configuration["AppConfiguration:Jwt:Audience"], - new[] { new Claim(ClaimTypes.UserData, rootId), new Claim(ClaimTypes.Name, userName), new Claim(Policies.IsUser, "true") }, - notBefore: expires.AddHours(-1), expires: expires, signingCredentials: new SigningCredentials(signing, SecurityAlgorithms.HmacSha512)); - return new JwtSecurityTokenHandler().WriteToken(token); - } - - [Fact] - public async Task Signing_up_answers_a_token_for_the_new_root() - { - var userName = Name("Signup"); - using var client = _host.Client(); - - var response = await client.PostJson("/clientapi/user/signup", new { userName, password = Accounts.Password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jwt = await response.JsonBody(); - Assert.Equal(userName.ToLowerInvariant(), jwt["username"]!.GetValue()); - Assert.Equal(new[] { Policies.IsUser }, jwt["policies"]!.AsArray().Select(p => p!.GetValue())); - Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue())); - var stored = await DB.Default.Find().MatchID(jwt["userId"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); - Assert.NotEqual(Accounts.Password, stored.HashedPassword); - } - - [Fact] - public async Task A_taken_username_is_refused_whatever_its_case() - { - var root = await _host.SignUp("taken"); - using var client = _host.Client(); - - var again = await client.PostJson("/clientapi/user/signup", new { userName = root.UserName, password = Accounts.Password }); - var shouted = await client.PostJson("/clientapi/user/signup", new { userName = root.UserName.ToUpperInvariant(), password = Accounts.Password }); - - Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode); - Assert.Contains("already taken", await Message(again)); - Assert.Equal(HttpStatusCode.BadRequest, shouted.StatusCode); - Assert.Equal(1, await DB.Default.CountAsync(u => u.UserName == root.UserName, TestContext.Current.CancellationToken)); - } - - public static TheoryData InvalidSignUps() => new() - { - "{}", - "{\"userName\":\"ab\",\"password\":\"Test-Pass-1!\"}", - "{\"userName\":\"has space\",\"password\":\"Test-Pass-1!\"}", - "{\"userName\":\"validname\",\"password\":\"short\"}", - "{\"userName\":\"validname\",\"password\":\"nouppercase1\"}", - "{\"userName\":\"validname\",\"password\":\"Has Space1\"}", - "{\"userName\":\"validname\",\"password\":\"Test-Pass-1!\",\"lightThemeIndexColour\":400}", - "{\"userName\":\"" + new string('a', 40) + "\",\"password\":\"Test-Pass-1!\"}", - "not json" - }; - - [Theory] - [MemberData(nameof(InvalidSignUps))] - public async Task An_invalid_sign_up_answers_400_with_a_message(string body) - { - using var client = _host.Client(); - - var response = await client.PostAsync("/clientapi/user/signup", new StringContent(body, Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.False(string.IsNullOrEmpty(await Message(response))); - } - - [Fact] - public async Task Logging_in_and_out() - { - var root = await _host.SignUp("login"); - - var ok = await LogIn(root.UserName, root.Password); - var wrong = await LogIn(root.UserName, "Wrong-Pass-1!"); - var unknown = await LogIn(Name("nobody"), root.Password); - using var signedIn = _host.As((await ok.JsonBody())["token"]!.GetValue()); - var logout = await signedIn.GetAsync("/clientapi/user/logout", TestContext.Current.CancellationToken); - using var anonymous = _host.Client(); - var anonymousLogout = await anonymous.GetAsync("/clientapi/user/logout", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, ok.StatusCode); - Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue()); - // a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist - Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode); - Assert.Equal(RootUsersService.NoMatch, await Message(wrong)); - Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); - Assert.Equal(await Message(wrong), await Message(unknown)); - Assert.Equal(HttpStatusCode.OK, logout.StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode); - } - - [Fact] - public async Task Update_sets_the_recovery_email_and_refuses_one_in_use() - { - var root = await _host.SignUp("email"); - var other = await _host.SignUp("email"); - var email = $"{Guid.NewGuid():N}@example.test"; - using var client = _host.As(root.Jwt); - using var otherClient = _host.As(other.Jwt); - - var set = await client.PostJson("/clientapi/user/update", new { email }); - var taken = await otherClient.PostJson("/clientapi/user/update", new { email }); - var invalid = await otherClient.PostJson("/clientapi/user/update", new { email = "not an email" }); - - Assert.Equal(HttpStatusCode.OK, set.StatusCode); - Assert.Equal(email, (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Email); - Assert.Equal(HttpStatusCode.BadRequest, taken.StatusCode); - Assert.Contains("already taken", await Message(taken)); - Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); - Assert.Null((await DB.Default.Find().MatchID(other.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Email); - } - - [Fact] - public async Task Settings_round_trip() - { - var root = await _host.SignUp("settings"); - using var client = _host.As(root.Jwt); - - var updated = await client.PostJson("/clientapi/user/update/settings", new - { - languageCode = "it", - lightThemeIndexColour = 100, - darkThemeIndexColour = 200, - iconsThemeIndexColour = 50, - themeIsDarkMode = true, - themeIsDarkGray = true - }); - var settings = await (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).JsonBody(); - var unsupported = await client.PostJson("/clientapi/user/update/settings", new { languageCode = "xx" }); - var outOfRange = await client.PostJson("/clientapi/user/update/settings", new { languageCode = "en", lightThemeIndexColour = 360 }); - - Assert.Equal(HttpStatusCode.OK, updated.StatusCode); - Assert.Equal("it", settings["languageCode"]!.GetValue()); - Assert.Equal(100, settings["lightThemeIndexColour"]!.GetValue()); - Assert.Equal(200, settings["darkThemeIndexColour"]!.GetValue()); - Assert.Equal(50, settings["iconsThemeIndexColour"]!.GetValue()); - Assert.True(settings["themeIsDarkMode"]!.GetValue()); - Assert.True(settings["themeIsDarkGray"]!.GetValue()); - Assert.Equal(HttpStatusCode.BadRequest, unsupported.StatusCode); - Assert.Contains("unsupported", await Message(unsupported)); - Assert.Equal(HttpStatusCode.BadRequest, outOfRange.StatusCode); - Assert.Equal("it", (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Settings.LanguageCode); - } - - [Fact] - public async Task Changing_the_password_needs_the_old_one() - { - var root = await _host.SignUp("password"); - using var client = _host.As(root.Jwt); - - var wrongOld = await client.PostJson("/clientapi/user/update/password", new { oldPassword = "Wrong-Pass-1!", newPassword = NewPassword, repeatedPassword = NewPassword }); - var mismatch = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = NewPassword, repeatedPassword = "Something-3!" }); - var weak = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = "weak", repeatedPassword = "weak" }); - var changed = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = NewPassword, repeatedPassword = NewPassword }); - - Assert.Equal(HttpStatusCode.BadRequest, wrongOld.StatusCode); - Assert.Equal("Wrong password.", await Message(wrongOld)); - Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); - Assert.Equal(HttpStatusCode.OK, changed.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode); - } - - [Fact] - public async Task An_invitation_signs_up_a_root_with_a_persona_in_the_group() - { - var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); - var group = await _host.Group(owner, community: false, password: "door-Pass-1"); - var invitationCode = group["invitationCode"]!.GetValue(); - var userName = Name("invited"); - var avatarUserName = Name("guest"); - using var client = _host.Client(); - - var wrongPassword = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, invitationPassword = "nope", avatarUserName }); - var wrongCode = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}", invitationPassword = "door-Pass-1", avatarUserName }); - var response = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, invitationPassword = "door-Pass-1", avatarUserName, avatarName = "Guest" }); - - Assert.Equal(HttpStatusCode.NotAcceptable, wrongPassword.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, wrongCode.StatusCode); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jwt = await response.JsonBody(); - using var invited = _host.As(jwt["token"]!.GetValue()); - var avatars = await (await invited.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems(); - var avatar = Assert.Single(avatars)!; - Assert.Equal(avatarUserName, avatar["userName"]!.GetValue()); - Assert.Equal("Guest", avatar["name"]!.GetValue()); - var stored = await DB.Default.Find().MatchID(group["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); - Assert.Contains(stored.Members, m => !m.IsForeign && m.AvatarId == avatar["id"]!.GetValue() && m.Role == GroupRole.Member); - } - - [Fact] - public async Task An_invitation_never_names_the_persona_after_the_login() - { - var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); - var invitationCode = (await _host.Group(owner, community: true))["invitationCode"]!.GetValue(); - var userName = Name("same"); - using var client = _host.Client(); - - var same = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, avatarUserName = userName }); - var shouted = await client.PostJson("/clientapi/user/invitation/signup", new { userName = userName.ToUpperInvariant(), password = Accounts.Password, invitationCode, avatarUserName = userName }); - var missing = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode }); - - Assert.Equal(HttpStatusCode.BadRequest, same.StatusCode); - Assert.Equal("Your persona's username must differ from your login.", await Message(same)); - Assert.Equal(HttpStatusCode.BadRequest, shouted.StatusCode); - Assert.Equal("Your persona's username must differ from your login.", await Message(shouted)); - Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); - Assert.False(await DB.Default.Find().Match(u => u.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken)); - Assert.False(await DB.Default.Find().Match(a => a.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task An_existing_root_joins_through_an_invitation_with_a_new_persona() - { - var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); - var group = await _host.Group(owner, community: true); - var root = await _host.SignUp("joiner"); - var avatarUserName = Name("joined"); - using var client = _host.Client(); - - var response = await client.PostJson("/clientapi/user/invitation/login", new - { - userName = root.UserName, - password = root.Password, - invitationCode = group["invitationCode"]!.GetValue(), - avatarUserName - }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var joined = _host.As(root.Jwt); - var avatar = Assert.Single(await (await joined.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())!; - Assert.Equal(avatarUserName, avatar["userName"]!.GetValue()); - var groups = await (await joined.GetAsync("/clientapi/group/list?avatarId=" + avatar["id"]!.GetValue(), TestContext.Current.CancellationToken)).JsonItems(); - Assert.Equal(group["id"]!.GetValue(), Assert.Single(groups)!["id"]!.GetValue()); - } - - [Fact] - public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work() - { - var token = TestContext.Current.CancellationToken; - var since = DateTime.UtcNow.AddSeconds(-1); - var withEmail = await _host.SignUp("withemail"); - var withoutEmail = await _host.SignUp("noemail"); - var email = $"{Guid.NewGuid():N}@example.test"; - using (var signedIn = _host.As(withEmail.Jwt)) - Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode); - using var client = _host.Client(); - - var answers = new[] - { - await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }), - await client.PostJson("/clientapi/user/recover/password", new { email }), - await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }), - await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }), - await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" }) - }; - var bodies = new List(); - foreach (var answer in answers) - { - Assert.Equal(HttpStatusCode.OK, answer.StatusCode); - bodies.Add(await answer.Content.ReadAsStringAsync(token)); - } - - Assert.Single(bodies.Distinct()); - Assert.Contains("recovery link is on its way", bodies[0]); - var queued = await DB.Default.Find().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token); - Assert.True(queued.Count >= answers.Length); - Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id)); - Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode); - } - - [Fact] - public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone() - { - var token = TestContext.Current.CancellationToken; - var root = await _host.SignUp("smtp"); - var email = $"{Guid.NewGuid():N}@example.test"; - using (var signedIn = _host.As(root.Jwt)) - Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode); - using var client = _host.Client(); - - var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName }); - await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token); - - Assert.Equal(HttpStatusCode.OK, answer.StatusCode); - var recovery = await DB.Default.Find().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token); - Assert.NotNull(recovery); - Assert.Null(recovery.RecoveryCode); - Assert.Equal(64, recovery.CodeHash.Length); - Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61)); - } - - [Fact] - public async Task A_wrong_recovery_code_changes_nothing() - { - using var client = _host.Client(); - var code = Guid.NewGuid().ToString("N"); - - var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); - var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code }); - - Assert.Equal(HttpStatusCode.OK, valid.StatusCode); - Assert.Equal("false", await valid.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Equal(HttpStatusCode.NotFound, change.StatusCode); - Assert.Equal("Invalid recovery code.", await Message(change)); - } - - [Fact] - public async Task A_valid_recovery_code_resets_the_password_once() - { - var root = await _host.SignUp("recover"); - var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); - await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) }, - TestContext.Current.CancellationToken); - var persona = await _host.Persona(root, "recovered"); - var apiToken = await _host.MastodonToken(persona); - using var client = _host.Client(); - - var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); - var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code }); - var again = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = "Third-Pass-3!", repeatedPassword = "Third-Pass-3!", recoveryCode = code }); - - Assert.Equal("true", await valid.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.True(change.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.NotFound, again.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode); - Assert.False(await DB.Default.Find().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken)); - // whoever held the old sessions may be why the password was recovered: they end - using (var oldSession = _host.As(root.Jwt)) - Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode); - using (var oldApp = _host.Client()) - { - oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken); - Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); - } - } - - [Fact] - public async Task An_expired_recovery_code_changes_nothing() - { - var root = await _host.SignUp("expired"); - var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); - await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }, - TestContext.Current.CancellationToken); - using var client = _host.Client(); - - var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code }); - - Assert.Equal(HttpStatusCode.NotFound, change.StatusCode); - Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode); - } - - [Fact] - public async Task Sniffing_again_answers_a_fresh_token() - { - var root = await _host.SignUp("sniff"); - using var client = _host.As(root.Jwt); - - var response = await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken); - using var anonymous = _host.Client(); - var refused = await anonymous.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jwt = await response.JsonBody(); - Assert.Equal(root.Id, jwt["userId"]!.GetValue()); - Assert.Equal(root.UserName, jwt["username"]!.GetValue()); - Assert.True(jwt["expiration"]!.GetValue() > DateTime.UtcNow.Ticks); - Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue())); - Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode); - } - - [Fact] - public async Task The_eleventh_sign_up_from_one_address_in_a_minute_is_refused() - { - using var client = _host.ClientAt("198.51.100.21"); - for (var attempt = 1; attempt <= 10; attempt++) - Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/signup", new { })).StatusCode); - - var eleventh = await client.PostJson("/clientapi/user/signup", new { userName = Name("limited"), password = Accounts.Password }); - - Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode); - } - - [Fact] - public async Task A_bad_token_answers_401_with_a_json_body() - { - var root = await _host.SignUp("badjwt"); - var tokens = new[] - { - "not-a-jwt", - Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(-1)), - Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(1), key: "another-key-entirely-0123456789abcdef0123456789abcdef0123456789abcdef") - }; - - foreach (var token in tokens) - { - using var client = _host.As(token); - var response = await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); - var body = await response.JsonBody(); - Assert.Equal(401, body["statusCode"]!.GetValue()); - Assert.False(body["isValid"]!.GetValue()); - Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString()); - } - Assert.Equal(HttpStatusCode.OK, await Settings(Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(1)))); - } - - [Fact] - public async Task A_banned_or_removed_root_loses_the_client_api_at_once() - { - var banned = await _host.SignUp("banned"); - var removed = await _host.SignUp("removed"); - Assert.Equal(HttpStatusCode.OK, await Settings(banned.Jwt)); - Assert.Equal(HttpStatusCode.OK, await Settings(removed.Jwt)); - - await ClientApi.Ban(banned.Id); - await DB.Default.Update().MatchID(removed.Id).Modify(u => u.DeletedAt, DateTime.UtcNow).ExecuteAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Unauthorized, await Settings(banned.Jwt)); - Assert.Equal(HttpStatusCode.Unauthorized, await Settings(removed.Jwt)); - using (var client = _host.As(banned.Jwt)) - { - Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostJson("/clientapi/avatar/private/insert", new { userName = Name("late"), name = "late", biography = "testing" })).StatusCode); - } - await ClientApi.Ban(banned.Id, banned: false); - Assert.Equal(HttpStatusCode.OK, await Settings(banned.Jwt)); - } - } -} diff --git a/PrivaPub.Tests/Http/ClientApiGroupsTests.cs b/PrivaPub.Tests/Http/ClientApiGroupsTests.cs deleted file mode 100644 index 4f65da7..0000000 --- a/PrivaPub.Tests/Http/ClientApiGroupsTests.cs +++ /dev/null @@ -1,302 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class ClientApiGroupsTests : IAsyncLifetime - { - const string DoorPassword = "door-Pass-1"; - - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - async Task NewPersona(string name) => await _host.Persona(await _host.SignUp(name), name); - - static Task Stored(JsonObject group) => - DB.Default.Find().MatchID(group["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); - - async Task> Groups(Persona persona) - { - using var client = _host.As(persona.Root.Jwt); - var response = await client.GetAsync("/clientapi/group/list?avatarId=" + persona.Id, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return (await response.JsonItems()).Select(g => g!["id"]!.GetValue()).ToList(); - } - - async Task Approve(Persona manager, JsonObject group, string actorUri) - { - using var client = _host.As(manager.Root.Jwt); - return await client.PostJson("/clientapi/group/approve", new { avatarId = manager.Id, groupId = group["id"]!.GetValue(), memberActorURI = actorUri }); - } - - async Task<(Persona Owner, JsonObject Circle, RemoteActor Member)> CircleWithRemoteMember() - { - var owner = await NewPersona("circleowner"); - var circle = await _host.Group(owner, community: false, name: "circle"); - var member = new RemoteActor(_peer, "member"); - await _host.Follow(member, _peer.A, circle["userName"]!.GetValue()); - Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode); - return (owner, circle, member); - } - - async Task Get(string path, RemoteActor signer = default) - { - using var client = _host.Client(); - using var request = signer == default ? new HttpRequestMessage(HttpMethod.Get, path) : signer.SignedGet(path); - request.Headers.Accept.ParseAdd("application/activity+json"); - return (await client.SendAsync(request, TestContext.Current.CancellationToken)).StatusCode; - } - - [Fact] - public async Task A_community_and_a_circle_are_created() - { - var owner = await NewPersona("founder"); - - var community = await _host.Group(owner, community: true, name: "community"); - var circle = await _host.Group(owner, community: false, password: DoorPassword, name: "circle"); - - Assert.True(community["isCommunity"]!.GetValue()); - Assert.False(circle["isCommunity"]!.GetValue()); - foreach (var group in new[] { community, circle }) - { - Assert.True(group["isOwner"]!.GetValue()); - Assert.Equal(64, group["invitationCode"]!.GetValue().Length); - Assert.Equal($"{PrivaPubHost.Base}/peasants/{group["userName"]!.GetValue()}", group["url"]!.GetValue()); - Assert.Equal(1, group["membersCount"]!.GetValue()); - } - Assert.True(circle["isPasswordRequired"]!.GetValue()); - Assert.False(community["isPasswordRequired"]!.GetValue()); - Assert.Equal(GroupKind.Community, (await Stored(community)).Kind); - var storedCircle = await Stored(circle); - Assert.Equal(GroupKind.Circle, storedCircle.Kind); - Assert.NotEqual(DoorPassword, storedCircle.HashedInvitationPassword); - Assert.Contains(storedCircle.Members, m => m.AvatarId == owner.Id && m.Role == GroupRole.Owner); - Assert.Equal(new[] { community["id"]!.GetValue(), circle["id"]!.GetValue() }.Order(), (await Groups(owner)).Order()); - } - - [Fact] - public async Task A_group_needs_a_persona_of_ones_own() - { - var owner = await NewPersona("founder"); - var stranger = await _host.SignUp("stranger"); - using var client = _host.As(stranger.Jwt); - - var insert = await client.PostJson("/clientapi/group/insert", new { avatarId = owner.Id, userName = $"stolen{Guid.NewGuid():N}"[..20], name = "stolen", isCommunity = true }); - var list = await client.GetAsync("/clientapi/group/list?avatarId=" + owner.Id, TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.NotFound, insert.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, list.StatusCode); - } - - [Fact] - public async Task Joining_takes_the_code_and_the_password() - { - var owner = await NewPersona("host"); - var circle = await _host.Group(owner, community: false, password: DoorPassword); - var joiner = await NewPersona("guest"); - var invitationCode = circle["invitationCode"]!.GetValue(); - using var client = _host.As(joiner.Root.Jwt); - - var noPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode }); - var wrongPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = "wrong" }); - var wrongCode = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}", invitationPassword = DoorPassword }); - var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword }); - var twice = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword }); - - Assert.Equal(HttpStatusCode.NotAcceptable, noPassword.StatusCode); - Assert.Equal(HttpStatusCode.NotAcceptable, wrongPassword.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, wrongCode.StatusCode); - Assert.Equal(HttpStatusCode.OK, joined.StatusCode); - var view = await joined.JsonBody(); - Assert.Equal(2, view["membersCount"]!.GetValue()); - Assert.False(view["isOwner"]!.GetValue()); - Assert.Null(view["invitationCode"]); - Assert.Equal(HttpStatusCode.OK, twice.StatusCode); - Assert.Equal(2, (await Stored(circle)).Members.Count); - Assert.Equal(new[] { circle["id"]!.GetValue() }, await Groups(joiner)); - } - - [Fact] - public async Task Joining_needs_a_persona_of_ones_own() - { - var owner = await NewPersona("host"); - var community = await _host.Group(owner, community: true); - var victim = await NewPersona("victim"); - var stranger = await _host.SignUp("stranger"); - using var client = _host.As(stranger.Jwt); - - var response = await client.PostJson("/clientapi/group/join", new { avatarId = victim.Id, invitationCode = community["invitationCode"]!.GetValue() }); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.DoesNotContain((await Stored(community)).Members, m => m.AvatarId == victim.Id); - } - - [Fact] - public async Task Members_leave_and_owners_cannot() - { - var owner = await NewPersona("host"); - var community = await _host.Group(owner, community: true); - var member = await NewPersona("leaver"); - var groupId = community["id"]!.GetValue(); - using (var joining = _host.As(member.Root.Jwt)) - Assert.Equal(HttpStatusCode.OK, (await joining.PostJson("/clientapi/group/join", new { avatarId = member.Id, invitationCode = community["invitationCode"]!.GetValue() })).StatusCode); - using var memberClient = _host.As(member.Root.Jwt); - using var ownerClient = _host.As(owner.Root.Jwt); - - var left = await memberClient.PostJson("/clientapi/group/leave", new { avatarId = member.Id, groupId }); - var ownerLeft = await ownerClient.PostJson("/clientapi/group/leave", new { avatarId = owner.Id, groupId }); - - Assert.True(left.IsSuccessStatusCode); - Assert.Empty(await Groups(member)); - Assert.Equal(HttpStatusCode.BadRequest, ownerLeft.StatusCode); - Assert.Equal(new[] { owner.Id }, (await Stored(community)).Members.Select(m => m.AvatarId)); - } - - [Fact] - public async Task A_circle_takes_a_remote_member_only_once_the_owner_approves() - { - var owner = await NewPersona("approver"); - var circle = await _host.Group(owner, community: false); - var member = new RemoteActor(_peer, "asker"); - var since = DateTime.UtcNow.AddSeconds(-1); - - await _host.Follow(member, _peer.A, circle["userName"]!.GetValue()); - - var request = await DB.Default.Find().Match(f => f.LocalActorId == circle["id"]!.GetValue() && f.ActorURI == member.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken); - Assert.False(request.IsAccepted); - Assert.DoesNotContain((await Stored(circle)).Members, m => m.AvatarId == member.Id); - Assert.Empty(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken)); - - var outsider = await NewPersona("outsider"); - Assert.Equal(HttpStatusCode.NotFound, (await Approve(outsider, circle, member.Id)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await Approve(owner, circle, $"{_peer.A}/users/nobody")).StatusCode); - Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode); - - Assert.Contains((await Stored(circle)).Members, m => m.IsForeign && m.AvatarId == member.Id); - Assert.True((await DB.Default.Find().MatchID(request.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken)).IsAccepted); - var accept = Assert.Single(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken)); - Assert.Equal("Accept", accept["type"]!.GetValue()); - Assert.Equal(circle["url"]!.GetValue(), accept["actor"]!.GetValue()); - } - - // its owner sees who is in the circle and who asks to join, declines one request and takes members out; nobody else - // sees them - [Fact] - public async Task An_owner_sees_the_members_and_requests_declines_one_and_removes_members() - { - var token = TestContext.Current.CancellationToken; - var (owner, circle, member) = await CircleWithRemoteMember(); - var groupId = circle["id"]!.GetValue(); - var joiner = await NewPersona("joiner"); - using (var joining = _host.As(joiner.Root.Jwt)) - Assert.True((await joining.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = circle["invitationCode"]!.GetValue() })).IsSuccessStatusCode); - var asker = new RemoteActor(_peer, "asker"); - await _host.Follow(asker, _peer.A, circle["userName"]!.GetValue()); - var since = DateTime.UtcNow.AddSeconds(-1); - using var client = _host.As(owner.Root.Jwt); - async Task> Members(HttpClient as_, string avatarId) - { - var response = await as_.GetAsync($"/clientapi/group/members?avatarId={avatarId}&groupId={groupId}", token); - return response.IsSuccessStatusCode ? (await response.JsonItems()).ToList() : null; - } - - var listed = await Members(client, owner.Id); - using var joinerClient = _host.As(joiner.Root.Jwt); - var forJoiner = await Members(joinerClient, joiner.Id); - var declined = await client.PostJson("/clientapi/group/reject", new { avatarId = owner.Id, groupId, memberActorURI = asker.Id }); - var removedRemote = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = member.Id }); - var removedLocal = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = joiner.ActorUri() }); - var removedOwner = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = owner.ActorUri() }); - - Assert.Null(forJoiner); - Assert.Equal(4, listed.Count); - Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == owner.ActorUri() && m["role"]!.GetValue() == "owner" && m["isLocal"]!.GetValue()); - Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == joiner.ActorUri() && !m["isPending"]!.GetValue()); - Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == member.Id && !m["isPending"]!.GetValue() && !m["isLocal"]!.GetValue()); - Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == asker.Id && m["isPending"]!.GetValue()); - Assert.True(declined.IsSuccessStatusCode); - Assert.True(removedRemote.IsSuccessStatusCode); - Assert.True(removedLocal.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.BadRequest, removedOwner.StatusCode); - Assert.Equal(new[] { owner.Id }, (await Stored(circle)).Members.Select(m => m.AvatarId)); - Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == groupId && f.ActorURI != owner.ActorUri()).ExecuteAnyAsync(token)); - Assert.Contains(await Jobs.Deliveries(asker.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Reject"); - Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Reject"); - } - - [Fact] - public async Task A_circle_is_never_found() - { - var owner = await NewPersona("hidden"); - var circle = await _host.Group(owner, community: false, name: "circle"); - var community = await _host.Group(owner, community: true, name: "community"); - var circleName = circle["userName"]!.GetValue(); - var communityName = community["userName"]!.GetValue(); - using var anonymous = _host.Client(); - using var searcher = _host.As(await _host.MastodonToken(await NewPersona("searcher"))); - - foreach (var acct in new[] { circleName, $"{circleName}@{PrivaPubHost.Host}", $"@{circleName}@{PrivaPubHost.Host}" }) - { - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode); - } - Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + communityName, TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/" + circle["id"]!.GetValue(), TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await searcher.GetAsync("/api/v1/accounts/" + community["id"]!.GetValue(), TestContext.Current.CancellationToken)).StatusCode); - - foreach (var q in new[] { circleName, $"@{circleName}@{PrivaPubHost.Host}", circle["url"]!.GetValue() }) - { - var found = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(q)}", TestContext.Current.CancellationToken); - Assert.Empty(JsonNode.Parse(found)!["accounts"]!.AsArray()); - Assert.Empty(JsonNode.Parse(found)!["statuses"]!.AsArray()); - } - var communityFound = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(community["url"]!.GetValue())}", TestContext.Current.CancellationToken); - Assert.Single(JsonNode.Parse(communityFound)!["accounts"]!.AsArray()); - - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"/@{circleName}", TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync($"/@{communityName}", TestContext.Current.CancellationToken)).StatusCode); - } - - [Fact] - public async Task A_circles_members_and_wardens_are_shown_only_to_members() - { - var (_, circle, member) = await CircleWithRemoteMember(); - var outsider = new RemoteActor(_peer, "outsider"); - var community = await _host.Group(await NewPersona("open"), community: true); - var circleName = circle["userName"]!.GetValue(); - var communityName = community["userName"]!.GetValue(); - - foreach (var collection in new[] { "flock", "wardens" }) - { - var path = $"/peasants/{circleName}/{collection}"; - Assert.Equal(HttpStatusCode.NotFound, await Get(path)); - Assert.Equal(HttpStatusCode.NotFound, await Get(path, outsider)); - Assert.Equal(HttpStatusCode.OK, await Get(path, member)); - Assert.Equal(HttpStatusCode.OK, await Get($"/peasants/{communityName}/{collection}")); - } - } - } -} diff --git a/PrivaPub.Tests/Http/ClientApiModerationTests.cs b/PrivaPub.Tests/Http/ClientApiModerationTests.cs deleted file mode 100644 index cf0acd4..0000000 --- a/PrivaPub.Tests/Http/ClientApiModerationTests.cs +++ /dev/null @@ -1,308 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Moderation; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Net.Http.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class ClientApiModerationTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task Settings(string jwt) - { - using var client = _host.As(jwt); - return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode; - } - - async Task Credentials(string token) - { - using var client = _host.As(token); - return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode; - } - - async Task LogIn(Root root) - { - using var client = _host.Client(); - return await client.PostJson("/clientapi/user/login", new { userName = root.UserName, password = root.Password }); - } - - static Task Remove(HttpClient client, params string[] ids) => - client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = ids }) }); - - static Task Stored(string id) => DB.Default.Find().MatchID(id).ExecuteFirstAsync(TestContext.Current.CancellationToken); - - static JsonObject DirectNote(RemoteActor sender, string origin, Persona persona) - { - var noteId = $"{origin}/notes/{Guid.NewGuid():N}"; - var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"); - return new JsonObject - { - ["id"] = noteId + "/activity", - ["type"] = "Create", - ["actor"] = sender.Id, - ["to"] = to.DeepClone(), - ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = "

hello

" } - }; - } - - [Fact] - public async Task An_admin_bans_and_unbans_a_root() - { - var admin = await _host.Admin(); - var victim = await _host.Persona(await _host.SignUp("victim"), "victim"); - var token = await _host.MastodonToken(victim); - using var client = _host.As(admin.Jwt); - - var banned = await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { victim.Root.Id } }); - - Assert.Equal(HttpStatusCode.OK, banned.StatusCode); - Assert.True((await Stored(victim.Root.Id)).IsBanned); - Assert.Equal(HttpStatusCode.Unauthorized, await Settings(victim.Root.Jwt)); - Assert.Equal(HttpStatusCode.Unauthorized, await Credentials(token)); - var login = await LogIn(victim.Root); - Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode); - Assert.Contains("banned", (await login.JsonBody())["errorMessage"]!.GetValue()); - - var unbanned = await client.PostJson("/clientapi/admin/unban/users", new { userIdList = new[] { victim.Root.Id } }); - - Assert.Equal(HttpStatusCode.OK, unbanned.StatusCode); - Assert.Equal(HttpStatusCode.OK, await Settings(victim.Root.Jwt)); - Assert.Equal(HttpStatusCode.OK, await Credentials(token)); - Assert.Equal(HttpStatusCode.OK, (await LogIn(victim.Root)).StatusCode); - } - - [Fact] - public async Task An_admin_cannot_ban_or_remove_itself() - { - var admin = await _host.Admin(); - var victim = await _host.SignUp("victim"); - using var client = _host.As(admin.Jwt); - - Assert.Equal(HttpStatusCode.OK, (await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { admin.Id, victim.Id } })).StatusCode); - var removed = await Remove(client, admin.Id); - - Assert.False((await Stored(admin.Id)).IsBanned); - Assert.True((await Stored(victim.Id)).IsBanned); - Assert.Equal(HttpStatusCode.BadRequest, removed.StatusCode); - Assert.Null((await Stored(admin.Id)).DeletedAt); - Assert.Equal(HttpStatusCode.OK, await Settings(admin.Jwt)); - } - - [Fact] - public async Task An_admin_removes_a_root() - { - var admin = await _host.Admin(); - var victim = await _host.Persona(await _host.SignUp("removed"), "removed"); - var token = await _host.MastodonToken(victim); - using var client = _host.As(admin.Jwt); - - var removed = await Remove(client, victim.Root.Id); - var again = await Remove(client, victim.Root.Id); - - Assert.Equal(HttpStatusCode.OK, removed.StatusCode); - var stored = await Stored(victim.Root.Id); - Assert.NotNull(stored.DeletedAt); - Assert.Null(stored.HashedPassword); - Assert.NotEqual(victim.Root.UserName, stored.UserName); - Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, await Settings(victim.Root.Jwt)); - Assert.Equal(HttpStatusCode.Unauthorized, await Credentials(token)); - Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(victim.Root)).StatusCode); - } - - [Fact] - public async Task Only_admins_moderate() - { - var root = await _host.SignUp("plain"); - var target = await _host.SignUp("target"); - using var client = _host.As(root.Jwt); - using var anonymous = _host.Client(); - var forbidden = new List - { - await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { target.Id } }), - await client.PostJson("/clientapi/admin/unban/users", new { userIdList = new[] { target.Id } }), - await Remove(client, target.Id), - await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken), - await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain = "forbidden.example" }), - await client.PostAsync("/clientapi/admin/domainblocks/delete?domain=forbidden.example", default, TestContext.Current.CancellationToken), - await client.GetAsync("/clientapi/moderator/reports", TestContext.Current.CancellationToken), - await client.PostAsync($"/clientapi/moderator/reports/{target.Id}/resolve", default, TestContext.Current.CancellationToken) - }; - var unauthorized = await anonymous.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { target.Id } }); - - foreach (var response in forbidden) - { - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var body = await response.JsonBody(); - Assert.Equal(403, body["statusCode"]!.GetValue()); - Assert.Equal("Forbidden.", body["errorMessage"]!.GetValue()); - } - Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode); - var stored = await Stored(target.Id); - Assert.False(stored.IsBanned); - Assert.Null(stored.DeletedAt); - Assert.False(await DB.Default.Find().Match(b => b.Domain == "forbidden.example").ExecuteAnyAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task A_moderator_lists_and_resolves_reports() - { - var admin = await _host.Admin(); - var reporter = await _host.Persona(await _host.SignUp("reporter"), "reporter"); - var target = await _host.Persona(await _host.SignUp("reported"), "reported"); - var comment = $"spam {Guid.NewGuid():N}"; - using (var api = _host.As(await _host.MastodonToken(reporter))) - Assert.Equal(HttpStatusCode.OK, (await api.Form("/api/v1/reports", ("account_id", target.Id), ("comment", comment), ("category", "spam"))).StatusCode); - var reportId = (await DB.Default.Find().Match(r => r.Comment == comment).ExecuteSingleAsync(TestContext.Current.CancellationToken)).ID; - using var client = _host.As(admin.Jwt); - - var open = await client.GetStringAsync("/clientapi/moderator/reports?resolved=false", TestContext.Current.CancellationToken); - var resolved = await client.PostAsync($"/clientapi/moderator/reports/{reportId}/resolve", default, TestContext.Current.CancellationToken); - var stillOpen = await client.GetStringAsync("/clientapi/moderator/reports?resolved=false", TestContext.Current.CancellationToken); - var closed = await client.GetStringAsync("/clientapi/moderator/reports?resolved=true", TestContext.Current.CancellationToken); - var unknown = await client.PostAsync($"/clientapi/moderator/reports/{MongoDB.Bson.ObjectId.GenerateNewId()}/resolve", default, TestContext.Current.CancellationToken); - var junk = await client.PostAsync("/clientapi/moderator/reports/x/resolve", default, TestContext.Current.CancellationToken); - - var listed = JsonNode.Parse(open)!.AsArray().Single(r => r!["id"]!.GetValue() == reportId)!; - Assert.Equal("local", listed["reporter"]!.GetValue()); - Assert.Equal(target.Id, listed["targetAccountId"]!.GetValue()); - Assert.Equal("spam", listed["category"]!.GetValue()); - Assert.DoesNotContain(reporter.Id, open); - Assert.DoesNotContain(reporter.UserName, open); - Assert.DoesNotContain(reporter.Root.Id, open); - Assert.Equal(HttpStatusCode.OK, resolved.StatusCode); - Assert.DoesNotContain(reportId, stillOpen); - Assert.Contains(reportId, closed); - var stored = await DB.Default.Find().MatchID(reportId).ExecuteFirstAsync(TestContext.Current.CancellationToken); - Assert.Equal(admin.Id, stored.ResolvedBy); - Assert.NotNull(stored.ResolvedAt); - Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, junk.StatusCode); - } - - [Fact] - public async Task Domain_blocks_are_inserted_listed_and_deleted() - { - var admin = await _host.Admin(); - var domain = $"blocked-{Guid.NewGuid():N}.example"; - using var client = _host.As(admin.Jwt); - try - { - var inserted = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain = $" {domain.ToUpperInvariant()}. ", suspend = false, rejectMedia = true, publicComment = "noisy" }); - var listed = await (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).JsonItems(); - var suspended = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain, suspend = true }); - var deleted = await client.PostAsync($"/clientapi/admin/domainblocks/delete?domain={domain}", default, TestContext.Current.CancellationToken); - var after = await (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).JsonItems(); - - Assert.Equal(HttpStatusCode.OK, inserted.StatusCode); - var view = await inserted.JsonBody(); - Assert.Equal(domain, view["domain"]!.GetValue()); - Assert.Equal("Silence", view["severity"]!.GetValue()); - Assert.True(view["rejectMedia"]!.GetValue()); - Assert.Contains(listed, b => b!["domain"]!.GetValue() == domain); - Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); - Assert.Equal("Suspend", (await suspended.JsonBody())["severity"]!.GetValue()); - Assert.Equal(view["id"]!.GetValue(), (await suspended.JsonBody())["id"]!.GetValue()); - Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); - Assert.DoesNotContain(after, b => b!["domain"]!.GetValue() == domain); - Assert.Null(_host.Get().Find(domain)); - } - finally - { - await DB.Default.DeleteAsync(b => b.Domain == domain); - await _host.Get().Reload(CancellationToken.None); - } - } - - [Theory] - [InlineData("")] - [InlineData("ab")] - [InlineData("not a domain")] - [InlineData("127.0.0.1")] - [InlineData("https://bad.example/")] - [InlineData("bad..example")] - public async Task A_bad_domain_is_refused(string domain) - { - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - - var response = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var normalised = DomainBlocks.Normalise(domain); - Assert.False(await DB.Default.Find().Match(b => b.Domain == normalised).ExecuteAnyAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task A_suspended_server_is_answered_202_and_nothing_is_kept() - { - var token = TestContext.Current.CancellationToken; - var admin = await _host.Admin(); - var persona = await _host.Persona(await _host.SignUp("suspend"), "suspend"); - await using var peer = await Peer.Start(); - var sender = new RemoteActor(peer, "suspended", origin: peer.B); - var blocked = DirectNote(sender, peer.B, persona); - var welcome = DirectNote(sender, peer.B, persona); - using var adminClient = _host.As(admin.Jwt); - using var client = _host.Client(); - try - { - Assert.Equal(HttpStatusCode.OK, (await adminClient.PostJson("/clientapi/admin/domainblocks/insert", new { domain = "localhost", suspend = true })).StatusCode); - var answer = await client.SendAsync(sender.SignedPost($"/peasants/{persona.UserName}/mouth", blocked), token); - - Assert.Equal(HttpStatusCode.Accepted, answer.StatusCode); - Assert.False(await DB.Default.Find().Match(j => j.DedupeKey == "inbox|" + blocked["id"]!.GetValue()).ExecuteAnyAsync(token)); - Assert.Equal(0, await _host.RunInbox(blocked["id"]!.GetValue(), token)); - Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == blocked["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token)); - Assert.Empty(peer.Requests); - - Assert.Equal(HttpStatusCode.OK, (await adminClient.PostAsync("/clientapi/admin/domainblocks/delete?domain=localhost", default, token)).StatusCode); - Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(sender.SignedPost($"/peasants/{persona.UserName}/mouth", welcome), token)).StatusCode); - Assert.Equal(1, await _host.RunInbox(welcome["id"]!.GetValue(), token)); - Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == welcome["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token)); - } - finally - { - await DB.Default.DeleteAsync(b => b.Domain == "localhost"); - await _host.Get().Reload(CancellationToken.None); - } - } - - [Fact] - public async Task The_data_endpoints_answer_anyone() - { - using var client = _host.Client(); - - var ping = await client.GetAsync("/clientapi/data/ping", TestContext.Current.CancellationToken); - var version = await client.GetAsync("/clientapi/data/current-version", TestContext.Current.CancellationToken); - var languages = await client.GetAsync("/clientapi/data/languages", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.NoContent, ping.StatusCode); - Assert.Equal(HttpStatusCode.OK, version.StatusCode); - Assert.False(string.IsNullOrWhiteSpace(await version.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))); - Assert.Equal(HttpStatusCode.OK, languages.StatusCode); - var codes = (await languages.JsonItems()).Select(l => l!["international2Code"]!.GetValue()).Order().ToList(); - Assert.Equal(new[] { "en", "it" }, codes); - } - } -} diff --git a/PrivaPub.Tests/Http/ClientApiPersonasTests.cs b/PrivaPub.Tests/Http/ClientApiPersonasTests.cs deleted file mode 100644 index af9b9a1..0000000 --- a/PrivaPub.Tests/Http/ClientApiPersonasTests.cs +++ /dev/null @@ -1,232 +0,0 @@ -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 Insert(Root root, object body) - { - using var client = _host.As(root.Jwt); - return await client.PostJson("/clientapi/avatar/private/insert", body); - } - - async Task> 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()).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(); - var link = await DB.Default.Find().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().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 taken", (await response.JsonBody())["errorMessage"]!.GetValue()); - 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(); - 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().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()); - Assert.Equal(1, await DB.Default.CountAsync(a => a.UserName == persona.UserName, TestContext.Current.CancellationToken)); - Assert.Equal(1, await DB.Default.CountAsync(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() == "Update"); - Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", update["actor"]!.GetValue()); - var person = update["object"]!.AsObject(); - Assert.Equal("Person", person["type"]!.GetValue()); - Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", person["id"]!.GetValue()); - Assert.Equal("Renamed", person["name"]!.GetValue()); - 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().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().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().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().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); - var stored = await DB.Default.Find().MatchID(group["id"]!.GetValue()).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(), 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)); - } - } -} diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs deleted file mode 100644 index 1b373d5..0000000 --- a/PrivaPub.Tests/Http/FederationGetTests.cs +++ /dev/null @@ -1,579 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Rendering; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Security.Cryptography; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.Host.FederationHelpers; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class FederationGetTests : IAsyncLifetime - { - const string Base = PrivaPubHost.Base; - - PrivaPubHost _host; - HttpClient _client; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - _client?.Dispose(); - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task The_actor_document_is_activity_json_with_an_spki_key_on_the_actor() - { - var persona = await _host.Persona(await _host.SignUp(), "actor"); - var avatar = await DB.Default.Find().MatchID(persona.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken); - - var fetched = await _client.Fetch($"/peasants/{persona.UserName}"); - - Assert.Equal(HttpStatusCode.OK, fetched.Status); - Assert.Equal(ActivityJson, fetched.MediaType); - Assert.Contains("Accept", fetched.Response.Headers.Vary); - var actor = fetched.Json; - var id = persona.ActorUri(); - Assert.Equal(id, actor["id"]!.GetValue()); - Assert.Equal("Person", actor["type"]!.GetValue()); - Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue()); - Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue()); - Assert.Equal(id + "/anus", actor["outbox"]!.GetValue()); - Assert.Equal(id + "/groupies", actor["followers"]!.GetValue()); - Assert.Equal(id + "/stalking", actor["following"]!.GetValue()); - Assert.Equal(id + "/trophies", actor["featured"]!.GetValue()); - Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue()); - Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue()); - Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue()); - - var key = actor["publicKey"]!; - Assert.Equal(id + "#main-key", key["id"]!.GetValue()); - Assert.Equal(id, key["owner"]!.GetValue()); - var pem = key["publicKeyPem"]!.GetValue(); - Assert.StartsWith("-----BEGIN PUBLIC KEY-----", pem); - using var rsa = RSA.Create(); - rsa.ImportFromPem(pem); - Assert.True(rsa.KeySize >= 2048); - - Assert.Equal(ActivityPubRenderer.Day(avatar.PublishedOn), actor["published"]!.GetValue()); - Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue()); - Assert.DoesNotContain(Keys(actor), k => k.Contains("created", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(ActivityPubRenderer.Timestamp(avatar.CreatedAt)[..16], fetched.Text); - Assert.DoesNotContain(persona.Root.Id, fetched.Text); - Assert.DoesNotContain(persona.Root.UserName, fetched.Text); - } - - [Fact] - public async Task The_actor_answers_ld_json_and_sends_browsers_to_the_profile_page() - { - var persona = await _host.Persona(await _host.SignUp(), "browser"); - - var ld = await _client.Fetch($"/peasants/{persona.UserName}", LdJson); - var browser = await _client.Fetch($"/peasants/{persona.UserName}", Browser); - var instance = await _client.Fetch("/peasants/privapub", Browser); - - Assert.Equal(HttpStatusCode.OK, ld.Status); - Assert.Equal(persona.ActorUri(), ld.Json["id"]!.GetValue()); - Assert.Equal(HttpStatusCode.Redirect, browser.Status); - Assert.Equal($"{Base}/@{persona.UserName}", browser.Location); - Assert.Contains("Accept", browser.Response.Headers.Vary); - Assert.Equal(HttpStatusCode.OK, instance.Status); - Assert.Equal("Application", instance.Json["type"]!.GetValue()); - } - - // PieFed reads the inbox it sends a community's announces to from the Application at a peer's root - [Fact] - public async Task The_instance_actor_answers_at_the_root_for_activitypub_only() - { - var root = await _client.Fetch("/"); - var browser = await _client.Fetch("/", Browser); - - Assert.Equal(HttpStatusCode.OK, root.Status); - Assert.Equal("Application", root.Json["type"]!.GetValue()); - Assert.Equal($"{Base}/peasants/privapub", root.Json["id"]!.GetValue()); - Assert.Equal($"{Base}/peasants/privapub/mouth", root.Json["inbox"]!.GetValue()); - Assert.Equal(HttpStatusCode.NotFound, browser.Status); - } - - // Lemmy takes a report only from a Person, a Service or an Organization, whose document names its preferredUsername, - // inbox, outbox and key; a browser gets the document too, as from the instance actor: the reporter has no page - [Fact] - public async Task The_reporter_is_a_service_with_an_inbox_an_outbox_and_its_own_key() - { - var fetched = await _client.Fetch("/peasants/privapub_reports"); - var browser = await _client.Fetch("/peasants/privapub_reports", Browser); - var outbox = await _client.Fetch("/peasants/privapub_reports/anus"); - var instance = await _client.Fetch("/peasants/privapub"); - - Assert.Equal(HttpStatusCode.OK, fetched.Status); - var actor = fetched.Json; - var id = $"{Base}/peasants/privapub_reports"; - Assert.Equal(id, actor["id"]!.GetValue()); - Assert.Equal("Service", actor["type"]!.GetValue()); - Assert.Equal("privapub_reports", actor["preferredUsername"]!.GetValue()); - Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue()); - Assert.Equal(id + "/anus", actor["outbox"]!.GetValue()); - Assert.Equal(id, actor["url"]!.GetValue()); - Assert.Equal(id + "#main-key", actor["publicKey"]!["id"]!.GetValue()); - Assert.Equal(id, actor["publicKey"]!["owner"]!.GetValue()); - Assert.NotEqual(instance.Json["publicKey"]!["publicKeyPem"]!.GetValue(), actor["publicKey"]!["publicKeyPem"]!.GetValue()); - Assert.False(actor["discoverable"]!.GetValue()); - Assert.Null(actor["wall"]); - Assert.Null(actor["implements"]); - Assert.Equal(HttpStatusCode.OK, browser.Status); - Assert.Equal("Service", browser.Json["type"]!.GetValue()); - Assert.Equal(HttpStatusCode.OK, outbox.Status); - Assert.Equal(0, outbox.Json["totalItems"]!.GetValue()); - } - - [Fact] - public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() - { - var persona = await _host.Persona(await _host.SignUp(), "users"); - - var moved = await _client.Fetch($"/users/{persona.UserName}"); - var unknown = await _client.Fetch($"/peasants/nobody{Guid.NewGuid():N}"[..28]); - - Assert.Equal(HttpStatusCode.MovedPermanently, moved.Status); - Assert.Equal($"/peasants/{persona.UserName}", moved.Location); - Assert.Equal(HttpStatusCode.NotFound, unknown.Status); - } - - [Fact] - public async Task The_outbox_pages_public_posts_and_boosts_and_nothing_else() - { - var persona = await _host.Persona(await _host.SignUp(), "outbox"); - var friend = await _host.Persona(await _host.SignUp(), "friend"); - var boosted = await _host.Publish(friend, "worth a boost"); - var shown = new List(); - for (var i = 0; i < 20; i++) - shown.Add(await _host.Publish(persona, $"public number {i}")); - shown.Add(await _host.Publish(persona, "quietly public", PostVisibility.Unlisted)); - var hidden = new List - { - await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly), - await _host.Publish(persona, $"@{friend.UserName} just you", PostVisibility.Direct), - await _host.PublishLocated(persona, "only around here"), - await _host.FederatedCopy(persona) - }; - var deleted = await _host.Publish(persona, "regretted"); - await _host.Remove(persona, deleted); - hidden.Add(deleted); - var reblog = await _host.Reblog(persona, boosted); - Assert.Equal(PostVisibility.LocalGeo, hidden[2].Visibility); - - var outbox = await _client.Fetch($"/peasants/{persona.UserName}/anus"); - Assert.Equal(HttpStatusCode.OK, outbox.Status); - Assert.Equal(ActivityJson, outbox.MediaType); - Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue()); - Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue()); - // statuses_count, as Mastodon counts it: everything but the DM, the deleted post and the copy of a remote post, - // the followers-only and located posts and the boost included; only the public posts are listed - Assert.Equal(24, outbox.Json["totalItems"]!.GetValue()); - Assert.Null(outbox.Json["orderedItems"]); - var first = outbox.Json["first"]!.GetValue(); - Assert.Equal(persona.ActorUri() + "/anus?page=true", first); - - var page1 = await _client.Fetch(PathOf(first)); - Assert.Equal(HttpStatusCode.OK, page1.Status); - Assert.Equal("OrderedCollectionPage", page1.Json["type"]!.GetValue()); - Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue()); - var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList(); - Assert.Equal(20, items1.Count); - var next = page1.Json["next"]!.GetValue(); - Assert.StartsWith(persona.ActorUri() + "/anus?page=true&max_id=", next); - - var page2 = await _client.Fetch(PathOf(next)); - Assert.Equal(HttpStatusCode.OK, page2.Status); - Assert.Equal(next, page2.Json["id"]!.GetValue()); - var items2 = page2.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList(); - Assert.Equal(2, items2.Count); - Assert.Null(page2.Json["next"]); - - var items = items1.Concat(items2).ToList(); - Assert.Equal(22, items.Select(i => i["id"]!.GetValue()).Distinct().Count()); - var announce = items[0]; - Assert.Equal("Announce", announce["type"]!.GetValue()); - Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue()); - Assert.Equal($"{persona.ActorUri()}/grunts/announce-{reblog.ID}", announce["id"]!.GetValue()); - Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue()); - Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue()); - - var creates = items.Skip(1).ToList(); - Assert.All(creates, c => - { - Assert.Equal("Create", c["type"]!.GetValue()); - Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue()); - Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue()); - }); - Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue())); - var everything = page1.Text + page2.Text; - foreach (var post in hidden) - Assert.DoesNotContain(post.ID, everything); - - var junk = await _client.Fetch($"/peasants/{persona.UserName}/anus?page=true&max_id=not-an-id"); - Assert.True((int)junk.Status < 500, $"a junk max_id answered {(int)junk.Status}"); - } - - [Fact] - public async Task Followers_and_following_give_no_names_away() - { - var token = TestContext.Current.CancellationToken; - var persona = await _host.Persona(await _host.SignUp(), "groupies"); - var friend = await _host.Persona(await _host.SignUp(), "stalked"); - var accepted = $"https://remote.example/users/fan{Guid.NewGuid():N}"; - var pending = $"https://remote.example/users/hopeful{Guid.NewGuid():N}"; - await DB.Default.SaveAsync(new[] - { - new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = accepted, InboxURL = accepted + "/inbox" }, - new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = pending, InboxURL = pending + "/inbox", IsAccepted = false } - }, token); - await DB.Default.SaveAsync(new Following - { - AvatarId = persona.Id, - TargetAccountId = friend.Id, - TargetActorURI = friend.ActorUri(), - TargetIsLocal = true, - State = FollowState.Accepted - }, token); - - var followers = await _client.Fetch($"/peasants/{persona.UserName}/groupies"); - var following = await _client.Fetch($"/peasants/{persona.UserName}/stalking"); - - Assert.Equal(HttpStatusCode.OK, followers.Status); - Assert.Equal("OrderedCollection", followers.Json["type"]!.GetValue()); - Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue()); - Assert.Equal(1, followers.Json["totalItems"]!.GetValue()); - Assert.DoesNotContain(accepted, followers.Text); - Assert.DoesNotContain(pending, followers.Text); - Assert.Equal(HttpStatusCode.OK, following.Status); - Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue()); - Assert.Equal(1, following.Json["totalItems"]!.GetValue());//counted, as following_count says, never listed - Assert.DoesNotContain(friend.UserName, following.Text); - } - - [Fact] - public async Task Trophies_are_the_public_pins_newest_first_and_tattoos_is_empty() - { - var token = TestContext.Current.CancellationToken; - var persona = await _host.Persona(await _host.SignUp(), "trophies"); - var older = await _host.Publish(persona, "pinned first"); - var newer = await _host.Publish(persona, "pinned second"); - var followersOnly = await _host.Publish(persona, "pinned but private", PostVisibility.FollowersOnly); - var removed = await _host.Publish(persona, "pinned then deleted"); - foreach (var post in new[] { older, followersOnly, removed, newer }) - await DB.Default.SaveAsync(new Pin { AvatarId = persona.Id, PostId = post.ID }, token); - await _host.Remove(persona, removed); - - var featured = await _client.Fetch($"/peasants/{persona.UserName}/trophies"); - var tags = await _client.Fetch($"/peasants/{persona.UserName}/tattoos"); - - Assert.Equal(HttpStatusCode.OK, featured.Status); - Assert.Equal(persona.ActorUri() + "/trophies", featured.Json["id"]!.GetValue()); - Assert.Equal(2, featured.Json["totalItems"]!.GetValue()); - var notes = featured.Json["orderedItems"]!.AsArray(); - Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue())); - Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue())); - Assert.DoesNotContain(followersOnly.ID, featured.Text); - Assert.DoesNotContain(removed.ID, featured.Text); - - Assert.Equal(HttpStatusCode.OK, tags.Status); - Assert.Equal(persona.ActorUri() + "/tattoos", tags.Json["id"]!.GetValue()); - Assert.Equal(0, tags.Json["totalItems"]!.GetValue()); - Assert.Empty(tags.Json["orderedItems"]!.AsArray()); - } - - [Fact] - public async Task A_scribble_is_served_while_public_and_a_tombstone_once_deleted() - { - var persona = await _host.Persona(await _host.SignUp(), "scribble"); - var other = await _host.Persona(await _host.SignUp(), "other"); - var shown = await _host.Publish(persona, "hello fediverse"); - var unlisted = await _host.Publish(persona, "hello quietly", PostVisibility.Unlisted); - var followersOnly = await _host.Publish(persona, "hello followers", PostVisibility.FollowersOnly); - var direct = await _host.Publish(persona, $"@{other.UserName} hello you", PostVisibility.Direct); - var located = await _host.PublishLocated(persona, "hello neighbours"); - var deleted = await _host.Publish(persona, "goodbye"); - await _host.Remove(persona, deleted); - string Path(Post post) => $"/peasants/{persona.UserName}/scribbles/{post.ID}"; - - var note = await _client.Fetch(Path(shown)); - Assert.Equal(HttpStatusCode.OK, note.Status); - Assert.Equal(ActivityJson, note.MediaType); - Assert.Equal(shown.ObjectURI, note.Json["id"]!.GetValue()); - Assert.Equal("Note", note.Json["type"]!.GetValue()); - Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue()); - Assert.NotNull(note.Json["@context"]); - Assert.Contains(ActivityPubRenderer.Public, Strings(note.Json["to"])); - Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(Path(unlisted))).Status); - - var browser = await _client.Fetch(Path(shown), Browser); - Assert.Equal(HttpStatusCode.Redirect, browser.Status); - Assert.Equal($"{Base}/@{persona.UserName}/{shown.ID}", browser.Location); - - foreach (var post in new[] { followersOnly, direct, located }) - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(post))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{other.UserName}/scribbles/{shown.ID}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{persona.UserName}/scribbles/{Guid.NewGuid():N}"[..48])).Status); - - var tombstone = await _client.Fetch(Path(deleted)); - Assert.Equal(HttpStatusCode.Gone, tombstone.Status); - Assert.Equal(ActivityJson, tombstone.MediaType); - Assert.Equal("Tombstone", tombstone.Json["type"]!.GetValue()); - Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue()); - Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue()); - Assert.NotNull(tombstone.Json["deleted"]); - Assert.DoesNotContain("goodbye", tombstone.Text); - } - - [Fact] - public async Task A_circle_post_is_served_only_to_a_member_or_its_instance_actor() - { - var owner = await _host.Persona(await _host.SignUp(), "circleowner"); - var circle = await _host.FederatedGroup(owner, community: false); - var member = new RemoteActor(_peer, "member"); - var memberServer = new RemoteActor(_peer, "instance", type: "Application"); - var neighbour = new RemoteActor(_peer, "neighbour"); - var stranger = new RemoteActor(_peer, "stranger", _peer.B); - var strangerServer = new RemoteActor(_peer, "instance", _peer.B, "Application"); - await circle.AddForeignMember(member.Id); - var post = await _host.Publish(owner, new StatusDraft { Text = "just us", PlainText = true, GroupId = circle.Id }); - Assert.Equal(PostVisibility.Circle, post.Visibility); - var path = $"/peasants/{owner.UserName}/scribbles/{post.ID}"; - - var asMember = await _client.Fetch(member.SignedGet(path)); - var asMemberServer = await _client.Fetch(memberServer.SignedGet(path)); - - Assert.Equal(HttpStatusCode.OK, asMember.Status); - Assert.Equal(post.ObjectURI, asMember.Json["id"]!.GetValue()); - Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"])); - Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text); - Assert.Equal(HttpStatusCode.OK, asMemberServer.Status); - // a refetch names the member, as the member's copy did; the instance actor sees the members on its server only - Assert.Equal(new[] { member.Id }, Strings(asMember.Json["cc"])); - Assert.Equal(new[] { member.Id }, Strings(asMemberServer.Json["cc"])); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status); - foreach (var outsider in new[] { neighbour, stranger, strangerServer }) - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status); - var create = $"/peasants/{owner.UserName}/grunts/create-{post.ID}"; - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(create)).Status); - var createAsMember = await _client.Fetch(member.SignedGet(create)); - Assert.Equal(HttpStatusCode.OK, createAsMember.Status); - Assert.Equal(post.ObjectURI, createAsMember.Json["object"]!["id"]!.GetValue()); - Assert.Equal(new[] { member.Id }, Strings(createAsMember.Json["object"]!["cc"])); - } - - [Fact] - public async Task Followers_only_and_direct_posts_are_served_to_signed_fetches_from_those_they_were_for() - { - var token = TestContext.Current.CancellationToken; - var owner = await _host.Persona(await _host.SignUp(), "private"); - var follower = new RemoteActor(_peer, "follower"); - var followerServer = new RemoteActor(_peer, "instance", type: "Application"); - var recipient = new RemoteActor(_peer, "recipient"); - var stranger = new RemoteActor(_peer, "stranger", _peer.B); - _peer.WebFinger(recipient); - await DB.Default.SaveAsync(new Follower - { - LocalActorId = owner.Id, - LocalActorKind = LocalActorKind.Person, - ActorURI = follower.Id, - InboxURL = follower.Id + "/inbox" - }, token); - var quiet = await _host.Publish(owner, "for followers", PostVisibility.FollowersOnly); - var direct = await _host.Publish(owner, new StatusDraft { Text = $"@{recipient.Handle()} just you", PlainText = true, Visibility = PostVisibility.Direct }); - var quietPath = $"/peasants/{owner.UserName}/scribbles/{quiet.ID}"; - var directPath = $"/peasants/{owner.UserName}/scribbles/{direct.ID}"; - - Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(follower.SignedGet(quietPath))).Status); - Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(followerServer.SignedGet(quietPath))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status); - var asRecipient = await _client.Fetch(recipient.SignedGet(directPath)); - Assert.Equal(HttpStatusCode.OK, asRecipient.Status); - Assert.Contains(recipient.Id, Strings(asRecipient.Json["to"])); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(follower.SignedGet(directPath))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(directPath)).Status); - - // the DM's context is the conversation, for its participants only - var context = PathOf(asRecipient.Json["context"]!.GetValue()); - var conversation = await _client.Fetch(recipient.SignedGet(context)); - Assert.Equal(HttpStatusCode.OK, conversation.Status); - Assert.Equal(new[] { direct.ObjectURI }, Strings(conversation.Json["orderedItems"])); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(context))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(context)).Status); - - // once deleted, those it was for learn it is gone; everyone else still learns nothing - await _host.Remove(owner, quiet); - Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(follower.SignedGet(quietPath))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status); - } - - [Fact] - public async Task A_circles_followers_members_and_wardens_are_shown_only_to_its_members() - { - var token = TestContext.Current.CancellationToken; - var owner = await _host.Persona(await _host.SignUp(), "circlelists"); - var circle = await _host.FederatedGroup(owner, community: false); - var community = await _host.FederatedGroup(owner, community: true); - var member = new RemoteActor(_peer, "member"); - var stranger = new RemoteActor(_peer, "stranger", _peer.B); - await circle.AddForeignMember(member.Id); - foreach (var group in new[] { circle, community }) - await DB.Default.SaveAsync(new Follower - { - LocalActorId = group.Id, - LocalActorKind = LocalActorKind.Group, - ActorURI = member.Id, - InboxURL = member.Id + "/inbox" - }, token); - - foreach (var collection in new[] { "groupies", "flock", "wardens" }) - { - var path = $"/peasants/{circle.UserName}/{collection}"; - Assert.True((await _client.Fetch(path)).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered an unsigned GET"); - Assert.True((await _client.Fetch(stranger.SignedGet(path))).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered a stranger"); - var asMember = await _client.Fetch(member.SignedGet(path)); - Assert.True(asMember.Status == HttpStatusCode.OK, $"the circle's {collection} refused a member"); - Assert.Equal(circle.Uri + "/" + collection, asMember.Json["id"]!.GetValue()); - var open = await _client.Fetch($"/peasants/{community.UserName}/{collection}"); - Assert.True(open.Status == HttpStatusCode.OK, $"the community's {collection} answered {(int)open.Status}"); - } - Assert.Equal(1, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/groupies"))).Json["totalItems"]!.GetValue()); - Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue()); - var wardens = await _client.Fetch($"/peasants/{community.UserName}/wardens"); - Assert.Equal(new[] { owner.ActorUri() }, Strings(wardens.Json["orderedItems"])); - Assert.Equal(community.Uri + "/wardens", (await _client.Fetch($"/peasants/{community.UserName}")).Json["attributedTo"]!.GetValue()); - Assert.Null((await _client.Fetch($"/peasants/{circle.UserName}")).Json["attributedTo"]); - } - - [Fact] - public async Task Grunts_resolve_creates_and_boosts() - { - var persona = await _host.Persona(await _host.SignUp(), "grunts"); - var friend = await _host.Persona(await _host.SignUp(), "boosted"); - var post = await _host.Publish(persona, "a grunt"); - var followersOnly = await _host.Publish(persona, "a private grunt", PostVisibility.FollowersOnly); - var original = await _host.Publish(friend, "boost me"); - var reblog = await _host.Reblog(persona, original); - string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}"; - - var create = await _client.Fetch(Path($"create-{post.ID}")); - var announce = await _client.Fetch(Path($"announce-{reblog.ID}")); - - Assert.Equal(HttpStatusCode.OK, create.Status); - Assert.Equal(ActivityJson, create.MediaType); - Assert.Equal("Create", create.Json["type"]!.GetValue()); - Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue()); - Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue()); - Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue()); - Assert.Equal(HttpStatusCode.OK, announce.Status); - Assert.Equal("Announce", announce.Json["type"]!.GetValue()); - Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue()); - Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue()); - - foreach (var unknown in new[] { $"create-{followersOnly.ID}", $"create-{Guid.NewGuid():N}"[..31], $"announce-{post.ID}", $"like-{post.ID}", "x" }) - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(unknown))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path($"announce-{original.ID}"))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{reblog.ID}")).Status); - } - - [Fact] - public async Task A_parrot_licence_is_served_by_its_author_until_revoked() - { - var token = TestContext.Current.CancellationToken; - var quoted = await _host.Persona(await _host.SignUp(), "quoted"); - var quoter = await _host.Persona(await _host.SignUp(), "quoter"); - var original = await _host.Publish(quoted, "quote me"); - var quoting = await _host.Publish(quoter, new StatusDraft { Text = "well said", PlainText = true, QuotedStatusId = original.ID }); - Assert.Equal(QuoteState.Accepted, quoting.QuoteState); - var licenceUri = quoting.QuoteAuthorizationURI; - Assert.StartsWith(quoted.ActorUri() + "/parrot-licences/", licenceUri); - var licenceId = licenceUri.Split('/')[^1]; - - var licence = await _client.Fetch(PathOf(licenceUri)); - var wrongAuthor = await _client.Fetch($"/peasants/{quoter.UserName}/parrot-licences/{licenceId}"); - var unknown = await _client.Fetch($"/peasants/{quoted.UserName}/parrot-licences/{Guid.NewGuid():N}"[..48]); - - Assert.Equal(HttpStatusCode.OK, licence.Status); - Assert.Equal(ActivityJson, licence.MediaType); - Assert.Equal("QuoteAuthorization", licence.Json["type"]!.GetValue()); - Assert.Equal(licenceUri, licence.Json["id"]!.GetValue()); - Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue()); - Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue()); - Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue()); - Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status); - Assert.Equal(HttpStatusCode.NotFound, unknown.Status); - - Assert.True(await _host.Get().RevokeLicence(original, quoting, token)); - var revoked = await _client.Fetch(PathOf(licenceUri)); - - Assert.Equal(HttpStatusCode.Gone, revoked.Status); - Assert.Equal(ActivityJson, revoked.MediaType); - Assert.Equal("Tombstone", revoked.Json["type"]!.GetValue()); - Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue()); - Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue()); - } - - [Fact] - public async Task Secure_mode_refuses_unsigned_gets_except_for_the_servers_own_actors() - { - var secure = await SecureModeHost.Shared(); - using var client = secure.Client(); - var persona = await _host.Persona(await _host.SignUp(), "secure"); - var post = await _host.Publish(persona, "only for the signed"); - var reader = new RemoteActor(_peer, "reader"); - var paths = new[] - { - $"/peasants/{persona.UserName}", - $"/peasants/{persona.UserName}/anus", - $"/peasants/{persona.UserName}/anus?page=true", - $"/peasants/{persona.UserName}/groupies", - $"/peasants/{persona.UserName}/stalking", - $"/peasants/{persona.UserName}/trophies", - $"/peasants/{persona.UserName}/tattoos", - $"/peasants/{persona.UserName}/scribbles/{post.ID}", - $"/peasants/{persona.UserName}/grunts/create-{post.ID}", - $"/peasants/nobody{Guid.NewGuid():N}"[..28] - }; - - foreach (var path in paths) - Assert.True((await client.Fetch(path)).Status == HttpStatusCode.Unauthorized, $"{path} answered an unsigned GET"); - var instance = await client.Fetch("/peasants/privapub"); - Assert.Equal(HttpStatusCode.OK, instance.Status); - Assert.Equal("Application", instance.Json["type"]!.GetValue()); - var reporter = await client.Fetch("/peasants/privapub_reports"); - Assert.Equal(HttpStatusCode.OK, reporter.Status); - Assert.Equal("Service", reporter.Json["type"]!.GetValue()); - Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status); - foreach (var path in paths[..^1]) - Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); - // a browser is not asked for a signature: it is only sent to the public pages - var browser = await client.Fetch($"/peasants/{persona.UserName}", Browser); - Assert.Equal(HttpStatusCode.Redirect, browser.Status); - Assert.Equal(HttpStatusCode.Redirect, (await client.Fetch($"/peasants/{persona.UserName}/scribbles/{post.ID}", Browser)).Status); - } - } -} diff --git a/PrivaPub.Tests/Http/HostBootTests.cs b/PrivaPub.Tests/Http/HostBootTests.cs deleted file mode 100644 index a4b3d12..0000000 --- a/PrivaPub.Tests/Http/HostBootTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.DependencyInjection; - -using MongoDB.Entities; - -using PrivaPub.Federation.Inbox; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class HostBootTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task The_host_shares_the_fixture_database() - { - Assert.True(await DB.Default.Find().ExecuteAnyAsync(TestContext.Current.CancellationToken)); - using var client = _host.Client(); - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/build.json", TestContext.Current.CancellationToken)).StatusCode); - } - - [Fact] - public async Task The_harness_handles_the_same_activities_as_the_server() - { - await using var harness = await Harness.Start(); - var registered = _host.Services.GetServices().Select(h => h.GetType()).OrderBy(t => t.Name); - var harnessed = harness.Handlers.Select(h => h.GetType()).OrderBy(t => t.Name); - - Assert.Equal(registered, harnessed); - } - - [Fact] - public void Every_job_kind_has_exactly_one_handler() - { - var handlers = _host.Services.GetServices().GroupBy(h => h.Kind).ToDictionary(g => g.Key, g => g.Count()); - - foreach (var kind in Enum.GetValues()) - Assert.True(handlers.GetValueOrDefault(kind) == 1, $"{kind} has {handlers.GetValueOrDefault(kind)} handlers"); - } - - [Fact] - public void Every_controller_and_page_model_can_be_made() - { - using var scope = _host.Services.CreateScope(); - var controllers = _host.Get().ActionDescriptors.Items - .OfType() - .Select(d => d.ControllerTypeInfo.AsType()) - .Distinct() - .ToList(); - var pages = typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(PageModel)) && !t.IsAbstract).ToList(); - - Assert.NotEmpty(controllers); - Assert.NotEmpty(pages); - foreach (var type in controllers.Concat(pages)) - Assert.NotNull(ActivatorUtilities.CreateInstance(scope.ServiceProvider, type)); - } - - [Fact] - public void The_service_graph_is_valid() - { - var services = new ServiceCollection(); - foreach (var descriptor in _host.Registered) - ((ICollection)services).Add(descriptor); - - using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); - } - - [Fact] - public async Task Swagger_is_not_served_outside_development() - { - using var client = _host.Client(); - - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/swagger/index.html", TestContext.Current.CancellationToken)).StatusCode); - } - - [Fact] - public async Task A_persona_gets_a_mastodon_token_through_the_real_code_flow() - { - var root = await _host.SignUp(); - var persona = await _host.Persona(root, "boot"); - - var token = await _host.MastodonToken(persona); - - using var client = _host.As(token); - var account = await client.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); - Assert.Contains($"\"id\":\"{persona.Id}\"", account); - Assert.DoesNotContain(root.Id, account); - Assert.DoesNotContain(root.UserName, account); - } - - [Fact] - public async Task A_signed_delivery_through_the_real_route_is_queued_and_stored() - { - var token = TestContext.Current.CancellationToken; - await using var peer = await Peer.Start(); - var persona = await _host.Persona(await _host.SignUp(), "mouth"); - var bob = new RemoteActor(peer, "bob"); - var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}"; - var activity = new JsonObject - { - ["id"] = noteId + "/activity", - ["type"] = "Create", - ["actor"] = bob.Id, - ["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"), - ["object"] = new JsonObject - { - ["id"] = noteId, - ["type"] = "Note", - ["attributedTo"] = bob.Id, - ["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"), - ["content"] = "

hello through the front door

", - ["published"] = DateTime.UtcNow.ToString("O") - } - }; - using var client = _host.Client(); - - var response = await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", activity), token); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token)); - var stored = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token); - Assert.Equal(PostVisibility.Direct, stored.Visibility); - Assert.Contains("front door", stored.ContentHtml); - } - } -} diff --git a/PrivaPub.Tests/Http/InboxRouteTests.cs b/PrivaPub.Tests/Http/InboxRouteTests.cs deleted file mode 100644 index d2dce28..0000000 --- a/PrivaPub.Tests/Http/InboxRouteTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Globalization; -using System.Net; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class InboxRouteTests : IAsyncLifetime - { - PrivaPubHost _host; - HttpClient _client; - Peer _peer; - Persona _persona; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - _peer = await Peer.Start(); - _persona = await _host.Persona(await _host.SignUp(), "inbox"); - } - - public async ValueTask DisposeAsync() - { - _client?.Dispose(); - if (_peer != default) - await _peer.DisposeAsync(); - } - - public static TheoryData Inboxes() => new() { "personal", "personal-shared", "shared" }; - - string Route(string inbox) => inbox switch - { - "personal" => $"/peasants/{_persona.UserName}/mouth", - "personal-shared" => $"/peasants/{_persona.UserName}/human-centipede", - _ => "/human-centipede" - }; - - JsonObject Direct(RemoteActor sender) - { - var noteId = $"{new Uri(sender.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}"; - var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{_persona.UserName}"); - return new JsonObject - { - ["id"] = noteId + "/activity", - ["type"] = "Create", - ["actor"] = sender.Id, - ["to"] = to.DeepClone(), - ["object"] = new JsonObject - { - ["id"] = noteId, - ["type"] = "Note", - ["attributedTo"] = sender.Id, - ["to"] = to.DeepClone(), - ["content"] = "

knock knock

", - ["published"] = DateTime.UtcNow.ToString("O") - } - }; - } - - static string Id(JsonObject activity) => activity["id"]!.GetValue(); - - async Task Queued(JsonObject activity) => - await DB.Default.Find().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken); - - async Task Send(HttpRequestMessage request) => await _client.SendAsync(request, TestContext.Current.CancellationToken); - - [Theory] - [MemberData(nameof(Inboxes))] - public async Task A_signed_delivery_is_accepted_and_queued(string inbox) - { - var sender = new RemoteActor(_peer, "sender"); - var activity = Direct(sender); - - var response = await Send(sender.SignedPost(Route(inbox), activity)); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - Assert.Equal(1, await _host.RunInbox(Id(activity), TestContext.Current.CancellationToken)); - } - - [Theory] - [MemberData(nameof(Inboxes))] - public async Task Junk_is_400_and_an_unsigned_activity_401(string inbox) - { - var sender = new RemoteActor(_peer, "unsigned"); - foreach (var junk in new[] { "not json", "[1,2,3]", "\"Create\"", "{}", "{\"type\":\"Create\"}" }) - { - using var request = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) { Content = new StringContent(junk, Encoding.UTF8, "application/activity+json") }; - Assert.Equal(HttpStatusCode.BadRequest, (await Send(request)).StatusCode); - } - var activity = Direct(sender); - using var unsigned = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) - { - Content = new StringContent(activity.ToJsonString(), Encoding.UTF8, "application/activity+json") - }; - - Assert.Equal(HttpStatusCode.Unauthorized, (await Send(unsigned)).StatusCode); - Assert.False(await Queued(activity)); - } - - [Fact] - public async Task An_unknown_persona_has_no_mouth() - { - var sender = new RemoteActor(_peer, "lost"); - var activity = Direct(sender); - - var response = await Send(sender.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..28] + "/mouth", activity)); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.False(await Queued(activity)); - } - - [Theory] - [MemberData(nameof(Inboxes))] - public async Task A_tampered_stale_or_misdirected_signature_is_401(string inbox) - { - var sender = new RemoteActor(_peer, "forger"); - var path = Route(inbox); - - var tampered = Direct(sender); - var badDigest = sender.SignedPost(path, tampered); - badDigest.Headers.Remove("Digest"); - badDigest.Headers.TryAddWithoutValidation("Digest", "SHA-256=" + Convert.ToBase64String(new byte[32])); - var stale = Direct(sender); - var twoHoursAgo = DateTimeOffset.UtcNow.AddHours(-2).ToString("r", CultureInfo.InvariantCulture); - var elsewhere = Direct(sender); - var otherBody = Direct(sender); - var swapped = sender.SignedPost(path, Direct(sender)); - swapped.Content = new StringContent(otherBody.ToJsonString(), Encoding.UTF8, "application/activity+json"); - - Assert.Equal(HttpStatusCode.Unauthorized, (await Send(badDigest)).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, stale, date: twoHoursAgo))).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, elsewhere, host: "elsewhere.example"))).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await Send(swapped)).StatusCode); - foreach (var activity in new[] { tampered, stale, elsewhere, otherBody }) - Assert.False(await Queued(activity)); - } - - [Fact] - public async Task Ld_json_with_the_activitystreams_profile_is_accepted() - { - var sender = new RemoteActor(_peer, "ldjson"); - var activity = Direct(sender); - var request = sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity); - request.Content!.Headers.Remove("Content-Type"); - request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); - - var response = await Send(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - Assert.True(await Queued(activity)); - } - - [Fact] - public async Task An_unreachable_signing_key_asks_the_sender_to_retry() - { - var sender = new RemoteActor(_peer, "flaky"); - _peer.Answer(new Uri(sender.Id).AbsolutePath, 503); - var activity = Direct(sender); - - var response = await Send(sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity)); - - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.True(response.Headers.RetryAfter?.Delta is { TotalSeconds: > 0 }, "no Retry-After"); - Assert.False(await Queued(activity)); - } - - [Fact] - public async Task Unsigned_posts_from_one_address_are_limited() - { - var token = TestContext.Current.CancellationToken; - using var client = _host.Client(); - client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader); - client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"fd7e:{Random.Shared.Next(0x10000):x}:{Random.Shared.Next(0x10000):x}::1"); - var body = Direct(new RemoteActor(_peer, "flood")).ToJsonString(); - async Task Post() - { - using var content = new StringContent(body, Encoding.UTF8, "application/activity+json"); - using var response = await client.PostAsync("/human-centipede", content, token); - return response.StatusCode; - } - - // The bucket holds 300 and refills 50 every ten seconds on its own timer, so a refill can land at any moment: - // the first refusal comes after at least 300 posts and within a few refills. - var answers = new List(); - while (answers.Count < 600 && (answers.Count == 0 || answers[^1] != HttpStatusCode.TooManyRequests)) - answers.Add(await Post()); - - Assert.Equal(HttpStatusCode.TooManyRequests, answers[^1]); - Assert.True(answers.Count > 300, $"limited after only {answers.Count - 1} posts"); - Assert.All(answers.SkipLast(1), status => Assert.Equal(HttpStatusCode.Unauthorized, status)); - var polite = new RemoteActor(_peer, "polite"); - using var signed = await client.SendAsync(polite.SignedPost("/human-centipede", Direct(polite)), token); - Assert.Equal(HttpStatusCode.Accepted, signed.StatusCode); - } - } -} diff --git a/PrivaPub.Tests/Http/InstanceLocationTests.cs b/PrivaPub.Tests/Http/InstanceLocationTests.cs deleted file mode 100644 index 4d99b61..0000000 --- a/PrivaPub.Tests/Http/InstanceLocationTests.cs +++ /dev/null @@ -1,291 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Where a server is, on the public instance API: where it was located (city, coordinates and network, whatever its size), - // only the CDN's name for a CDN-fronted one, the batch form, and this server itself. - [Trait("Category", "Integration")] - public sealed class InstanceLocationTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static async Task Instance(int users, InstanceGeo geo, string name = "located") - { - var host = $"{name}{Guid.NewGuid():N}.example"; - await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = "mastodon", UsersTotal = users, Geo = geo }, Token); - return host; - } - - static InstanceGeo Milan(string cdn = default) => new() - { - Country = "IT", - City = "Milan", - Latitude = 45.5, - Longitude = 9.2, - Asn = 12345, - AsnOrg = "Example Hosting", - Cdn = cdn, - Ipv6 = true, - Source = "DB-IP Lite 2026-10", - LocatedAt = DateTime.UtcNow.AddDays(-2) - }; - - async Task Geo(string host) => (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body["geo"]; - - [Fact] - public async Task A_located_server_shows_its_city_coordinates_and_network() - { - var geo = await Geo(await Instance(50, Milan())); - - Assert.Equal("city", geo.Text("precision")); - Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network"))); - Assert.Equal(45.5, geo["latitude"]!.GetValue()); - Assert.Equal(9.2, geo["longitude"]!.GetValue()); - Assert.Equal("DB-IP Lite 2026-10", geo.Text("source")); - Assert.NotNull(geo.Text("located_at")); - Assert.Null(geo["asn"]); - Assert.Null(geo["ipv6"]); - } - - [Fact] - public async Task A_one_user_server_shows_its_city_too() - { - var geo = await Geo(await Instance(1, Milan())); - - Assert.Equal("city", geo.Text("precision")); - Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network"))); - Assert.Equal(45.5, geo["latitude"]!.GetValue()); - } - - [Fact] - public async Task A_server_located_only_to_its_country_shows_the_country() - { - var geo = await Geo(await Instance(50, new InstanceGeo { Country = "IT", Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow })); - - Assert.Equal("country", geo.Text("precision")); - Assert.Equal("IT", geo.Text("country")); - Assert.Null(geo["city"]); - Assert.Null(geo["latitude"]); - } - - [Fact] - public async Task A_cdn_fronted_server_shows_only_its_cdn() - { - var geo = await Geo(await Instance(5000, Milan(cdn: "Cloudflare"))); - - Assert.Equal("cdn", geo.Text("precision")); - Assert.Equal("Cloudflare", geo.Text("cdn")); - Assert.Null(geo["country"]); - Assert.Null(geo["latitude"]); - Assert.Null(geo["network"]); - } - - [Fact] - public async Task An_unlocated_server_has_no_place() - { - Assert.Null(await Geo(await Instance(50, default))); - } - - [Fact] - public async Task The_provenance_of_a_post_carries_the_same_projection() - { - var host = await Instance(3, Milan()); - var described = (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body; - - Assert.Equal("city", described["geo"].Text("precision")); - Assert.Equal(described["geo"]!.ToJsonString(), (await Geo(host))!.ToJsonString()); - } - - [Fact] - public async Task Several_servers_answer_at_once_in_the_order_asked_without_the_unknown_ones() - { - var big = await Instance(50, Milan(), "big"); - var small = await Instance(3, Milan(), "small"); - var unknown = $"unknown{Guid.NewGuid():N}.example"; - - var answer = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={small}&host[]={unknown}&host[]={big.ToUpperInvariant()}&host[]={small}")).Ok(); - var hosts = answer.Body!.AsArray().Select(i => i.Text("host")).ToList(); - - Assert.Equal(new[] { small, big }, hosts); - Assert.Equal("city", answer.Body![0]["geo"].Text("precision")); - Assert.Equal("city", answer.Body![1]["geo"].Text("precision")); - Assert.Empty((await _host.Client().Get("/api/privapub/v1/instances")).Ok().Body!.AsArray()); - } - - [Fact] - public async Task At_most_forty_servers_answer_at_once() - { - var hosts = new List(); - for (var i = 0; i < 42; i++) - hosts.Add(await Instance(1, default, "many")); - - var answer = (await _host.Client().Get("/api/privapub/v1/instances?" + string.Join("&", hosts.Select(h => "host[]=" + h)))).Ok(); - - Assert.Equal(hosts.Take(40), answer.Body!.AsArray().Select(i => i.Text("host"))); - } - - [Fact] - public async Task This_server_describes_itself() - { - var self = (await _host.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body; - - Assert.Equal(PrivaPubHost.Host, self.Text("host")); - Assert.Equal("privapub", self.Text("software")); - Assert.Equal("activitypub", Assert.Single(self["protocols"]!.AsArray())!.GetValue()); - Assert.Null(self["geo"]); - var batch = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={PrivaPubHost.Host}")).Ok().Body!.AsArray(); - Assert.Equal("privapub", Assert.Single(batch).Text("software")); - } - - [Fact] - public async Task The_owner_may_place_this_server() - { - var placed = await SelfPlacedHost.Shared(); - - var geo = (await placed.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body["geo"]; - - Assert.Equal("city", geo.Text("precision")); - Assert.Equal(("IT", "Milan", "configured"), (geo.Text("country"), geo.Text("city"), geo.Text("source"))); - Assert.Equal(45.5, geo["latitude"]!.GetValue()); - Assert.Equal(9.2, geo["longitude"]!.GetValue()); - } - - static string WeeksAgo(int weeks) => PrivaPub.Federation.Objects.InstanceDescriber.Week(DateTime.UtcNow.AddDays(-7 * weeks)); - - static Task Snapshot(string host, int weeksAgo, string cdn = default, string cdnDomain = default, string city = default, double? latitude = default, - double? longitude = default, string country = default) => - DB.Default.SaveAsync(new RemoteInstanceSnapshot - { - Host = host, Week = WeeksAgo(weeksAgo), TakenAt = DateTime.UtcNow.AddDays(-7 * weeksAgo), Reachable = true, Software = "mastodon", UsersTotal = 3, - Cdn = cdn, CdnDomain = cdnDomain, CdnSource = cdn == default ? default : "ranges", Country = country, City = city, Latitude = latitude, Longitude = longitude - }, Token); - - [Fact] - public async Task A_cdn_fronted_server_names_its_cdn_and_where_it_was_before_it() - { - var host = await Instance(3, new InstanceGeo - { - Country = "US", City = "San Francisco", Latitude = 37.8, Longitude = -122.4, Cdn = "Cloudflare", CdnDomain = "cloudflare.com", CdnSource = "ranges", - Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow - }, "fronted"); - await Snapshot(host, 3, city: "Berlin", latitude: 52.5, longitude: 13.4, country: "DE"); - await Snapshot(host, 2, city: "Hamburg", latitude: 53.6, longitude: 10.0, country: "DE"); - await Snapshot(host, 1, cdn: "Cloudflare", cdnDomain: "cloudflare.com", city: "San Francisco", latitude: 37.8, longitude: -122.4, country: "US"); - - var geo = await Geo(host); - - Assert.Equal(("cdn", "Cloudflare", "cloudflare.com", "ranges"), (geo.Text("precision"), geo.Text("cdn"), geo.Text("cdn_domain"), geo.Text("cdn_source"))); - Assert.Null(geo["latitude"]); - Assert.Equal((WeeksAgo(2), "Hamburg", "DE"), (geo["before_cdn"].Text("week"), geo["before_cdn"].Text("city"), geo["before_cdn"].Text("country"))); - Assert.Equal(53.6, geo["before_cdn"]!["latitude"]!.GetValue()); - } - - [Fact] - public async Task A_server_history_is_weekly_newest_first_and_a_cdn_hides_its_place() - { - var host = await Instance(3, Milan(), "historic"); - await Snapshot(host, 2, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT"); - await Snapshot(host, 1, cdn: "Fastly", cdnDomain: "fastly.com", city: "Frankfurt", latitude: 50.1, longitude: 8.7, country: "DE"); - - var weeks = (await _host.Client().Get($"/api/privapub/v1/instances/{host}/history")).Ok().Body!.AsArray(); - - Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2) }, weeks.Select(w => w.Text("week"))); - Assert.Equal(("cdn", "fastly.com"), (weeks[0]!["geo"].Text("precision"), weeks[0]!["geo"].Text("cdn_domain"))); - Assert.Null(weeks[0]!["geo"]!["city"]); - Assert.Equal(("city", "Milan"), (weeks[1]!["geo"].Text("precision"), weeks[1]!["geo"].Text("city"))); - Assert.Equal(3, weeks[1]!["users_total"]!.GetValue()); - Assert.Single((await _host.Client().Get($"/api/privapub/v1/instances/{host}/history?weeks=1")).Ok().Body!.AsArray()); - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example/history")).Status); - } - - [Fact] - public async Task Servers_are_grouped_by_their_cdn_week_by_week() - { - var cdn = $"Edge{Guid.NewGuid():N}"[..20]; - var key = cdn.ToLowerInvariant(); - var stayed = await Instance(3, new InstanceGeo { Country = "FR", Cdn = cdn, CdnSource = "headers" }, "stayed"); - var moved = await Instance(3, Milan(), "moved"); - await Snapshot(moved, 3, cdn: cdn); - await Snapshot(moved, 2, cdn: cdn); - await Snapshot(stayed, 2, cdn: cdn); - await Snapshot(moved, 1, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT"); - await Snapshot(stayed, 1, cdn: cdn); - - var list = (await _host.Client().Get("/api/privapub/v1/cdns")).Ok().Body!.AsArray(); - var group = list.Single(g => g.Text("key") == key); - Assert.Equal((cdn, 1), (group.Text("name"), group["servers"]!.GetValue())); - var cloudflare = list.Single(g => g.Text("key") == "cloudflare.com"); - Assert.Equal(new[] { "ranges", "headers", "asn" }, cloudflare["detected_by"]!.AsArray().Select(v => v!.GetValue())); - - var detail = (await _host.Client().Get($"/api/privapub/v1/cdns/{key}")).Ok().Body; - Assert.Equal(new[] { stayed }, detail["hosts"]!.AsArray().Select(h => h!.GetValue())); - var weeks = detail["weeks"]!.AsArray(); - Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2), WeeksAgo(3) }, weeks.Select(w => w.Text("week"))); - Assert.Equal(new[] { 1, 2, 1 }, weeks.Select(w => w!["servers"]!.GetValue())); - Assert.Equal(new[] { moved }, weeks[0]!["left"]!.AsArray().Select(h => h!.GetValue())); - Assert.Equal(new[] { stayed }, weeks[1]!["joined"]!.AsArray().Select(h => h!.GetValue())); - Assert.Equal(new[] { moved }, weeks[2]!["joined"]!.AsArray().Select(h => h!.GetValue())); - - Assert.Equal("Cloudflare", (await _host.Client().Get("/api/privapub/v1/cdns/cloudflare.com")).Ok().Body.Text("name")); - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/cdns/none{Guid.NewGuid():N}")).Status); - } - - [Fact] - public async Task Junk_hosts_are_not_an_error() - { - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/{new string('a', 300)}")).Status); - Assert.Empty((await _host.Client().Get($"/api/privapub/v1/instances?host[]=&host[]={new string('a', 300)}")).Ok().Body!.AsArray()); - } - } - - public sealed class SelfPlacedHost : PrivaPubHost - { - static readonly SemaphoreSlim Boot = new(1, 1); - static SelfPlacedHost _shared; - - public static new async Task Shared() - { - await PrivaPubHost.Shared(); - await Boot.WaitAsync(); - try - { - if (_shared == default) - { - var host = new SelfPlacedHost(); - _ = host.Services; - _shared = host; - } - return _shared; - } - finally - { - Boot.Release(); - } - } - - protected override IEnumerable> Settings() => base.Settings().Concat(new Dictionary - { - ["Statistics:Geo:Self:Latitude"] = "45.4642", - ["Statistics:Geo:Self:Longitude"] = "9.19", - ["Statistics:Geo:Self:City"] = "Milan", - ["Statistics:Geo:Self:Country"] = "IT" - }); - } -} diff --git a/PrivaPub.Tests/Http/MastodonAccountsTests.cs b/PrivaPub.Tests/Http/MastodonAccountsTests.cs deleted file mode 100644 index f13b447..0000000 --- a/PrivaPub.Tests/Http/MastodonAccountsTests.cs +++ /dev/null @@ -1,672 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Net.Http.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class MastodonAccountsTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - async Task<(RemoteActor Actor, string Id)> Remote(string name = "bob") - { - var actor = new RemoteActor(_peer, name); - var known = await _host.Known(actor); - return (actor, known.ID); - } - - [Fact] - public async Task Verify_credentials_shows_the_persona_and_its_source_and_nothing_of_the_login() - { - var alice = await _host.Mastodon("alice"); - - var me = (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok(); - - Assert.Equal(alice.Id, me.Body.Text("id")); - Assert.Equal(alice.UserName, me.Body.Text("username")); - Assert.Equal(alice.UserName, me.Body.Text("acct")); - Assert.Equal(alice.Uri, me.Body.Text("uri")); - Assert.Equal("public", me.Body["source"].Text("privacy")); - Assert.Equal("public", me.Body["source"].Text("quote_policy")); - Assert.Equal(0, me.Body["source"].Number("follow_requests_count")); - Assert.DoesNotContain(alice.Persona.Root.Id, me.Text); - Assert.DoesNotContain(alice.Persona.Root.UserName, me.Text); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/accounts/verify_credentials")).Status); - } - - [Fact] - public async Task Update_credentials_takes_fields_in_both_shapes_the_source_and_the_quote_policy() - { - var alice = await _host.Mastodon("alice"); - - var updated = (await alice.Client.Patch("/api/v1/accounts/update_credentials", - ("display_name", " Alice Liddell "), ("note", "down the hole"), ("locked", "true"), ("bot", "true"), - ("fields_attributes[0][name]", "Site"), ("fields_attributes[0][value]", "https://example.com"), - ("fields_attributes[1][name]", "Pronouns"), ("fields_attributes[1][value]", "she/her"), - ("source[privacy]", "private"), ("source[sensitive]", "true"), ("source[language]", "it"), ("source[quote_policy]", "followers"))).Ok(); - - Assert.Equal("Alice Liddell", updated.Body.Text("display_name")); - Assert.True(updated.Body.Flag("locked")); - Assert.True(updated.Body.Flag("bot")); - Assert.Equal(new[] { "Site", "Pronouns" }, updated.Body["fields"]!.AsArray().Select(f => f.Text("name"))); - Assert.Equal("down the hole", updated.Body["source"].Text("note")); - Assert.Equal("private", updated.Body["source"].Text("privacy")); - Assert.True(updated.Body["source"].Flag("sensitive")); - Assert.Equal("it", updated.Body["source"].Text("language")); - Assert.Equal("followers", updated.Body["source"].Text("quote_policy")); - - var arrayShape = (await alice.Client.Json(HttpMethod.Patch, "/api/v1/accounts/update_credentials", new JsonObject - { - ["fields_attributes"] = new JsonArray(new JsonObject { ["name"] = "Garden", ["value"] = "roses" }, new JsonObject { ["name"] = "Cat", ["value"] = "Dinah" }), - ["source"] = new JsonObject { ["quote_policy"] = "everyone" } - })).Ok(); - Assert.Equal(new[] { ("Garden", "roses"), ("Cat", "Dinah") }, - arrayShape.Body["source"]!["fields"]!.AsArray().Select(f => (f.Text("name"), f.Text("value")))); - Assert.Equal("followers", arrayShape.Body["source"].Text("quote_policy")); - - var formArray = (await alice.Client.Patch("/api/v1/accounts/update_credentials", - ("fields_attributes[][name]", "One"), ("fields_attributes[][value]", "1"), ("fields_attributes[][name]", "Two"), ("fields_attributes[][value]", "2"))).Ok(); - Assert.Equal(new[] { ("One", "1"), ("Two", "2") }, formArray.Body["source"]!["fields"]!.AsArray().Select(f => (f.Text("name"), f.Text("value")))); - - var again = (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok(); - Assert.Equal(2, again.Body["fields"]!.AsArray().Count); - Assert.Equal("private", again.Body["source"].Text("privacy")); - var status = await alice.Status("with the default visibility"); - Assert.Equal("private", status.Text("visibility")); - Assert.True(status.Flag("sensitive")); - Assert.Equal("it", status.Text("language")); - } - - [Fact] - public async Task Avatar_and_header_uploads_are_resized_and_lose_their_metadata() - { - var alice = await _host.Mastodon("alice"); - var picture = MastodonHelpers.JpegWithMetadata(900, 700); - using (var original = NetVips.Image.NewFromBuffer(picture)) - Assert.Contains("exif-data", original.GetFields()); - var form = MastodonHelpers.Multipart(("avatar", picture, "image/jpeg", "me.jpg"), ("header", MastodonHelpers.JpegWithMetadata(2000, 900), "image/jpeg", "header.jpg")); - form.Add(new StringContent("Alice"), "display_name"); - - var updated = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") { Content = form })).Ok(); - - Assert.Equal("Alice", updated.Body.Text("display_name")); - foreach (var (field, maxWidth, maxHeight) in new[] { ("avatar", 400, 400), ("header", 1500, 500) }) - { - var url = updated.Body.Text(field); - Assert.StartsWith($"{PrivaPubHost.Base}/media/files/", url); - var bytes = await _host.Client().GetByteArrayAsync(url, Token); - MastodonHelpers.AssertNoMetadata(bytes); - using var stored = NetVips.Image.NewFromBuffer(bytes); - Assert.True(stored.Width <= maxWidth && stored.Height <= maxHeight, $"{field} is {stored.Width}x{stored.Height}"); - } - } - - [Fact] - public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_servers_own_actors() - { - var alice = await _host.Mastodon("alice"); - var (bob, bobId) = await Remote(); - var circleName = $"circle{Guid.NewGuid():N}"[..20]; - string circleId; - using (var clientApi = _host.As(alice.Persona.Root.Jwt)) - { - var circle = await clientApi.PostAsJsonAsync("/clientapi/group/insert", new { avatarId = alice.Id, userName = circleName, name = "inner", isCommunity = false }, Token); - Assert.Equal(HttpStatusCode.OK, circle.StatusCode); - circleId = (await circle.Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - } - var anonymous = _host.Client(); - - Assert.Equal(alice.Id, (await anonymous.Get($"/api/v1/accounts/lookup?acct={alice.UserName}")).Ok().Body.Text("id")); - Assert.Equal(alice.Id, (await anonymous.Get($"/api/v1/accounts/lookup?acct=@{alice.UserName}@{PrivaPubHost.Host}")).Ok().Body.Text("id")); - var remote = (await anonymous.Get($"/api/v1/accounts/lookup?acct={Uri.EscapeDataString(bob.Handle())}")).Ok(); - Assert.Equal(bobId, remote.Body.Text("id")); - Assert.Equal(bob.Handle(), remote.Body.Text("acct")); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub_reports")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct=nobody{Guid.NewGuid():N}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=a@b@c")).Status); - - Assert.Equal(alice.UserName, (await anonymous.Get($"/api/v1/accounts/{alice.Id}")).Ok().Body.Text("username")); - Assert.Equal(bob.Handle(), (await anonymous.Get($"/api/v1/accounts/{bobId}")).Ok().Body.Text("acct")); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/000000000000000000000000")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/{circleId}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/{circleId}/statuses")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{circleId}/follow")).Status); - } - - [Fact] - public async Task Search_resolves_a_remote_handle_only_when_asked_by_a_signed_in_persona() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - _peer.WebFinger(bob); - var handle = Uri.EscapeDataString(bob.Handle()); - - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/search?q={handle}")).Ok().Array); - Assert.Empty((await alice.Client.Get($"/api/v2/search?q={handle}")).Ok().Body["accounts"]!.AsArray()); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v1/accounts/search?q={handle}&resolve=true")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v2/search?q={handle}&resolve=true")).Status); - Assert.Empty(_peer.Requests); - - var resolved = (await alice.Client.Get($"/api/v1/accounts/search?q={handle}&resolve=true")).Ok(); - - var found = Assert.Single(resolved.Array); - Assert.Equal(bob.Handle(), found.Text("acct")); - Assert.Contains(_peer.Requests, r => r.Path == "/.well-known/webfinger"); - Assert.Equal(found.Text("id"), Assert.Single((await alice.Client.Get($"/api/v2/search?q={handle}&type=accounts")).Ok().Body["accounts"]!.AsArray()).Text("id")); - Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids); - } - - // an account elsewhere: what it features, in its order, its public posts only - [Fact] - public async Task A_remote_accounts_pinned_statuses_are_what_it_features() - { - var alice = await _host.Mastodon("alice"); - var (bob, bobId) = await Remote(); - Post Held(string text, PostVisibility visibility) => new() - { - ObjectURI = $"{bob.Id}/statuses/{Guid.NewGuid():N}", ActorURI = bob.Id, AuthorAccountId = bobId, IsFederatedCopy = true, - Visibility = visibility, ContentHtml = $"

{text}

", CreationDate = DateTime.UtcNow, UpdateDate = DateTime.UtcNow - }; - var first = Held("first pin", PostVisibility.Public); - var second = Held("second pin", PostVisibility.Unlisted); - var hidden = Held("for followers", PostVisibility.FollowersOnly); - await DB.Default.SaveAsync(new[] { first, second, hidden }, Token); - await DB.Default.SaveAsync(new[] - { - new RemoteFeatured { ActorURI = bob.Id, ObjectURI = second.ObjectURI, PostId = second.ID, Position = 1 }, - new RemoteFeatured { ActorURI = bob.Id, ObjectURI = hidden.ObjectURI, PostId = hidden.ID, Position = 2 }, - new RemoteFeatured { ActorURI = bob.Id, ObjectURI = first.ObjectURI, PostId = first.ID, Position = 0 } - }, Token); - - var pinned = (await alice.Client.Get($"/api/v1/accounts/{bobId}/statuses?pinned=true")).Ok(); - - Assert.Equal(new[] { first.ID, second.ID }, pinned.Ids); - } - - [Fact] - public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var tag = $"tag{Guid.NewGuid():N}"[..16]; - var tagged = await alice.Status($"first #{tag}"); - var thread = await alice.Status("and a thread", ("in_reply_to_id", tagged.Text("id"))); - var upload = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") - { - Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(64, 48), "image/jpeg", "a.jpg")) - })).Ok(); - var withMedia = await alice.Status("a picture", ("media_ids[]", upload.Body.Text("id"))); - var carolPost = await carol.Status("boost me"); - var answer = await alice.Status("an answer", ("in_reply_to_id", carolPost.Text("id"))); - var reblog = (await alice.Client.Post($"/api/v1/statuses/{carolPost.Text("id")}/reblog")).Ok().Body; - var pinned = await alice.Status("pinned"); - (await alice.Client.Post($"/api/v1/statuses/{pinned.Text("id")}/pin")).Ok(); - var path = $"/api/v1/accounts/{alice.Id}/statuses"; - string[] Ids(params JsonObject[] statuses) => statuses.Select(s => s.Text("id")).ToArray(); - - Assert.Equal(Ids(pinned), (await carol.Client.Get(path + "?pinned=true")).Ok().Ids); - Assert.True((await carol.Client.Get(path + "?pinned=true")).Ok().Array[0].Flag("pinned")); - Assert.Equal(Ids(pinned, reblog.AsObject(), answer, withMedia, thread, tagged), (await carol.Client.Get(path)).Ok().Ids); - Assert.Equal(Ids(pinned, reblog.AsObject(), withMedia, thread, tagged), (await carol.Client.Get(path + "?exclude_replies=true")).Ok().Ids); - Assert.DoesNotContain(reblog.Text("id"), (await carol.Client.Get(path + "?exclude_reblogs=true")).Ok().Ids); - Assert.Equal(Ids(withMedia), (await carol.Client.Get(path + "?only_media=true")).Ok().Ids); - Assert.Equal(Ids(tagged), (await _host.Client().Get(path + $"?tagged={tag.ToUpperInvariant()}")).Ok().Ids); - - var first = (await carol.Client.Get(path + "?limit=2")).Ok(); - Assert.Equal(Ids(pinned, reblog.AsObject()), first.Ids); - var next = (await carol.Client.Get(first.Link("next"))).Ok(); - Assert.Equal(Ids(answer, withMedia), next.Ids); - Assert.Contains("limit=2", next.Link("prev")); - Assert.Equal(first.Ids, (await carol.Client.Get(next.Link("prev"))).Ok().Ids); - var last = (await carol.Client.Get(next.Link("next"))).Ok(); - Assert.Equal(Ids(thread, tagged), last.Ids); - Assert.Empty((await carol.Client.Get(last.Link("next"))).Ok().Array); - } - - [Fact] - public async Task A_community_account_lists_the_posts_addressed_to_it() - { - var alice = await _host.Mastodon("alice"); - var reader = await _host.Mastodon("reader"); - var name = $"community{Guid.NewGuid():N}"[..20]; - string communityId; - using (var clientApi = _host.As(alice.Persona.Root.Jwt)) - { - var created = await clientApi.PostAsJsonAsync("/clientapi/group/insert", new { avatarId = alice.Id, userName = name, name = "Gardeners", isCommunity = true }, Token); - Assert.Equal(HttpStatusCode.OK, created.StatusCode); - communityId = (await created.Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - } - var addressed = await alice.Status($"@{name} first meeting on Sunday"); - await alice.Status("not for the garden"); - - var community = (await reader.Client.Get($"/api/v1/accounts/lookup?acct={name}")).Ok(); - Assert.Equal(communityId, community.Body.Text("id")); - Assert.True(community.Body.Flag("group")); - Assert.Equal(new[] { addressed.Text("id") }, (await reader.Client.Get($"/api/v1/accounts/{communityId}/statuses")).Ok().Ids); - Assert.Equal(1, (await _host.Client().Get($"/api/v1/accounts/{communityId}")).Ok().Body.Number("statuses_count")); - } - - [Fact] - public async Task A_follower_sees_followers_only_posts_and_a_stranger_does_not() - { - var alice = await _host.Mastodon("alice"); - var follower = await _host.Mastodon("follower"); - var stranger = await _host.Mastodon("stranger"); - (await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - var open = await alice.Status("for everyone"); - var quiet = await alice.Status("for followers", ("visibility", "private")); - var direct = await alice.Status($"@{follower.UserName} for you", ("visibility", "direct")); - var path = $"/api/v1/accounts/{alice.Id}/statuses"; - - Assert.Equal(new[] { quiet.Text("id"), open.Text("id") }, (await follower.Client.Get(path)).Ok().Ids); - Assert.Equal(new[] { open.Text("id") }, (await stranger.Client.Get(path)).Ok().Ids); - Assert.Equal(new[] { open.Text("id") }, (await _host.Client().Get(path)).Ok().Ids); - Assert.Equal(new[] { direct.Text("id"), quiet.Text("id"), open.Text("id") }, (await alice.Client.Get(path)).Ok().Ids); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{quiet.Text("id")}")).Status); - Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}")).Status); - } - - [Fact] - public async Task Followers_and_following_are_listed_only_to_their_owner() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - - Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Ids); - Assert.Empty((await bob.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array); - Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array); - Assert.Equal(new[] { alice.Id }, (await bob.Client.Get($"/api/v1/accounts/{bob.Id}/following")).Ok().Ids); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/following")).Ok().Array); - var account = (await bob.Client.Get($"/api/v1/accounts/{alice.Id}")).Ok(); - Assert.Equal(1, account.Body.Number("followers_count")); - } - - [Fact] - public async Task Following_is_immediate_for_an_open_persona_requested_for_a_locked_one_and_queued_for_a_remote_one() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var locked = await _host.Mastodon("locked"); - var follower = await _host.Mastodon("follower"); - (await locked.Client.Patch("/api/v1/accounts/update_credentials", ("locked", "true"))).Ok(); - var (bob, bobId) = await Remote(); - - var open = (await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - Assert.True(open.Body.Flag("following")); - Assert.False(open.Body.Flag("requested")); - Assert.True(open.Body.Flag("showing_reblogs")); - var notified = (await alice.Client.Get("/api/v1/notifications?types[]=follow")).Ok(); - Assert.Equal(follower.Id, Assert.Single(notified.Array)!["account"].Text("id")); - - var asked = (await follower.Client.Post($"/api/v1/accounts/{locked.Id}/follow", ("reblogs", "false"))).Ok(); - Assert.False(asked.Body.Flag("following")); - Assert.True(asked.Body.Flag("requested")); - Assert.Equal(new[] { follower.Id }, (await locked.Client.Get("/api/v1/follow_requests")).Ok().Ids); - Assert.Equal(1, (await locked.Client.Get("/api/v1/accounts/verify_credentials")).Ok().Body["source"].Number("follow_requests_count")); - Assert.True((await locked.Client.Post($"/api/v1/follow_requests/{follower.Id}/authorize")).Ok().Body.Flag("followed_by")); - var accepted = Assert.Single((await follower.Client.Get($"/api/v1/accounts/relationships?id[]={locked.Id}")).Ok().Array); - Assert.True(accepted.Flag("following")); - Assert.False(accepted.Flag("showing_reblogs")); - Assert.Empty((await locked.Client.Get("/api/v1/follow_requests")).Ok().Array); - - var remote = (await follower.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok(); - Assert.True(remote.Body.Flag("requested")); - var follow = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow"); - Assert.Equal(follower.Uri, follow.Text("actor")); - Assert.Equal(bob.Id, follow.Text("object")); - - Assert.False((await follower.Client.Post($"/api/v1/accounts/{alice.Id}/unfollow")).Ok().Body.Flag("following")); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array); - Assert.False((await follower.Client.Post($"/api/v1/accounts/{bobId}/unfollow")).Ok().Body.Flag("requested")); - var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo"); - Assert.Equal(follow.Text("id"), undo["object"].Text("id")); - Assert.Equal(HttpStatusCode.NotFound, (await follower.Client.Post("/api/v1/accounts/000000000000000000000000/follow")).Status); - Assert.Equal(HttpStatusCode.Forbidden, (await follower.Client.Post($"/api/v1/accounts/{follower.Id}/follow")).Status); - } - - [Fact] - public async Task Remote_follow_requests_are_answered_with_the_original_follow() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var locked = await _host.Mastodon("locked"); - (await locked.Client.Patch("/api/v1/accounts/update_credentials", ("locked", "true"))).Ok(); - var bob = new RemoteActor(_peer, "bob"); - var carol = new RemoteActor(_peer, "carol"); - var bobFollow = await _host.FollowedBy(locked, bob); - var carolFollow = await _host.FollowedBy(locked, carol); - var bobId = (await _host.Known(bob)).ID; - var carolId = (await _host.Known(carol)).ID; - - var requests = (await locked.Client.Get("/api/v1/follow_requests")).Ok(); - Assert.Equal(new[] { carolId, bobId }, requests.Ids); - Assert.True(Assert.Single((await locked.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("requested_by")); - Assert.Empty(await bob.Delivered(since)); - - Assert.True((await locked.Client.Post($"/api/v1/follow_requests/{bobId}/authorize")).Ok().Body.Flag("followed_by")); - Assert.False((await locked.Client.Post($"/api/v1/follow_requests/{carolId}/reject")).Ok().Body.Flag("requested_by")); - - var accept = Assert.Single(await bob.Delivered(since)); - Assert.Equal("Accept", accept.Type()); - Assert.Equal(locked.Uri, accept.Text("actor")); - Assert.Equal(bobFollow, accept["object"].Text("id")); - var reject = Assert.Single(await carol.Delivered(since)); - Assert.Equal("Reject", reject.Type()); - Assert.Equal(carolFollow, reject["object"].Text("id")); - Assert.Equal(carol.Id, reject["object"].Text("actor")); - Assert.Empty((await locked.Client.Get("/api/v1/follow_requests")).Ok().Array); - Assert.Equal(new[] { bobId }, (await locked.Client.Get($"/api/v1/accounts/{locked.Id}/followers")).Ok().Ids); - Assert.Equal(HttpStatusCode.NotFound, (await locked.Client.Post($"/api/v1/follow_requests/{carolId}/authorize")).Status); - - var local = await _host.Mastodon("local"); - (await local.Client.Post($"/api/v1/accounts/{locked.Id}/follow")).Ok(); - (await locked.Client.Post($"/api/v1/follow_requests/{local.Id}/reject")).Ok(); - var refused = Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={locked.Id}")).Ok().Array); - Assert.False(refused.Flag("requested")); - Assert.False(refused.Flag("following")); - } - - [Fact] - public async Task Removing_a_follower_ends_the_follow_on_both_sides_and_tells_a_remote_follower() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var local = await _host.Mastodon("local"); - var bob = new RemoteActor(_peer, "bob"); - var follow = await _host.FollowedBy(alice, bob); - var bobId = (await _host.Known(bob)).ID; - (await local.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - Assert.True(Assert.Single((await alice.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("followed_by")); - - var removed = (await alice.Client.Post($"/api/v1/accounts/{bobId}/remove_from_followers")).Ok(); - - Assert.False(removed.Body.Flag("followed_by")); - var reject = Assert.Single(await bob.Delivered(since), d => d.Type() == "Reject"); - Assert.Equal(alice.Uri, reject.Text("actor")); - Assert.Equal(follow, reject["object"].Text("id")); - Assert.Equal("Follow", reject["object"].Text("type")); - Assert.Equal(bob.Id, reject["object"].Text("actor")); - Assert.Equal(alice.Uri, reject["object"].Text("object")); - - Assert.False((await alice.Client.Post($"/api/v1/accounts/{local.Id}/remove_from_followers")).Ok().Body.Flag("followed_by")); - Assert.False(Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={alice.Id}")).Ok().Array).Flag("following")); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array); - Assert.Empty((await local.Client.Get($"/api/v1/accounts/{local.Id}/following")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/accounts/000000000000000000000000/remove_from_followers")).Status); - } - - [Fact] - public async Task Blocks_federate_and_reject_a_remote_follower() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var local = await _host.Mastodon("local"); - var bob = new RemoteActor(_peer, "bob"); - var follow = await _host.FollowedBy(alice, bob); - var bobId = (await _host.Known(bob)).ID; - (await local.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - - var blocked = (await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok(); - - Assert.True(blocked.Body.Flag("blocking")); - Assert.False(blocked.Body.Flag("followed_by")); - var sent = await bob.Delivered(since); - var reject = Assert.Single(sent, d => d.Type() == "Reject"); - Assert.Equal((follow, "Follow", bob.Id), (reject["object"].Text("id"), reject["object"].Text("type"), reject["object"].Text("actor"))); - var block = Assert.Single(sent, d => d.Type() == "Block"); - Assert.Equal((alice.Uri, bob.Id), (block.Text("actor"), block.Text("object"))); - Assert.True((await alice.Client.Post($"/api/v1/accounts/{local.Id}/block")).Ok().Body.Flag("blocking")); - Assert.Equal(new[] { local.Id, bobId }, (await alice.Client.Get("/api/v1/blocks")).Ok().Ids); - var blockedBy = Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={alice.Id}")).Ok().Array); - Assert.True(blockedBy.Flag("blocked_by")); - Assert.False(blockedBy.Flag("following")); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{alice.Id}/block")).Status); - - Assert.False((await alice.Client.Post($"/api/v1/accounts/{bobId}/unblock")).Ok().Body.Flag("blocking")); - var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo"); - Assert.Equal(block.Text("id"), undo["object"].Text("id")); - (await alice.Client.Post($"/api/v1/accounts/{local.Id}/unblock")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v1/blocks")).Ok().Array); - Assert.Equal(new[] { "Accept", "Block", "Reject", "Undo" }, (await bob.Delivered(since)).Select(d => d.Type()).Order()); - } - - [Fact] - public async Task Mutes_keep_their_duration_and_notification_choice_and_never_federate() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var chatty = await _host.Mastodon("chatty"); - var muffled = await _host.Mastodon("muffled"); - var (bob, bobId) = await Remote(); - (await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/follow")).Ok(); - var post = (await alice.Status("like me")).Text("id"); - - var muted = (await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/mute", ("duration", "3600"), ("notifications", "false"))).Ok(); - Assert.True(muted.Body.Flag("muting")); - Assert.False(muted.Body.Flag("muting_notifications")); - var mute = await DB.Default.Find().Match(m => m.AvatarId == alice.Id && m.TargetAccountId == chatty.Id).ExecuteFirstAsync(Token); - Assert.InRange(mute.ExpiresAt!.Value, DateTime.UtcNow.AddMinutes(59), DateTime.UtcNow.AddMinutes(61)); - var forever = (await alice.Client.Post($"/api/v1/accounts/{muffled.Id}/mute")).Ok(); - Assert.True(forever.Body.Flag("muting_notifications")); - Assert.Null((await DB.Default.Find().Match(m => m.AvatarId == alice.Id && m.TargetAccountId == muffled.Id).ExecuteFirstAsync(Token)).ExpiresAt); - (await alice.Client.Post($"/api/v1/accounts/{bobId}/mute")).Ok(); - Assert.Equal(new[] { bobId, muffled.Id, chatty.Id }, (await alice.Client.Get("/api/v1/mutes")).Ok().Ids); - - var hidden = (await chatty.Status("you will not see this")).Text("id"); - (await chatty.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - (await muffled.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - Assert.DoesNotContain(hidden, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); - Assert.Equal(new[] { chatty.Id }, (await alice.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array.Select(n => n!["account"].Text("id"))); - Assert.Empty(await bob.Delivered(since)); - - Assert.False((await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/unmute")).Ok().Body.Flag("muting")); - (await alice.Client.Post($"/api/v1/accounts/{muffled.Id}/unmute")).Ok(); - (await alice.Client.Post($"/api/v1/accounts/{bobId}/unmute")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v1/mutes")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{alice.Id}/mute")).Status); - } - - [Fact] - public async Task Domain_blocks_are_listed_added_and_removed() - { - var alice = await _host.Mastodon("alice"); - var domain = $"blocked{Guid.NewGuid():N}.example"; - - Assert.Equal("{}", (await alice.Client.Post("/api/v1/domain_blocks", ("domain", domain.ToUpperInvariant()))).Ok().Text); - (await alice.Client.Post("/api/v1/domain_blocks", ("domain", "not a domain"))).Ok(); - Assert.Equal(new[] { domain }, (await alice.Client.Get("/api/v1/domain_blocks")).Ok().Array.Select(d => d!.GetValue())); - - (await alice.Client.Delete("/api/v1/domain_blocks", ("domain", domain))).Ok(); - Assert.Empty((await alice.Client.Get("/api/v1/domain_blocks")).Ok().Array); - } - - [Fact] - public async Task Relationships_answer_every_asked_id_even_a_junk_one() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - - var answer = (await alice.Client.Get($"/api/v1/accounts/relationships?id[]=junk&id[]={bob.Id}&id[]=junk")).Ok(); - - Assert.Equal(new[] { "junk", bob.Id }, answer.Ids); - Assert.All(answer.Array, r => Assert.False(r.Flag("following") || r.Flag("blocking") || r.Flag("muting") || r.Flag("followed_by"))); - } - - [Fact] - public async Task Following_again_sends_an_unanswered_request_once_more_and_no_more_than_hourly() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var (bob, bobId) = await Remote(); - Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested")); - var first = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow"); - - Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested")); - (await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok(); - - var follows = (await bob.Delivered(since)).Where(d => d.Type() == "Follow").ToList(); - Assert.Equal(2, follows.Count); - Assert.All(follows, f => Assert.Equal(first.Text("id"), f.Text("id"))); - } - - [Fact] - public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var (bob, bobId) = await Remote(); - (await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok(); - var follow = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow"); - await _host.Deliver(bob, alice.Mouth, new JsonObject - { - ["id"] = $"{bob.Origin()}/accepts/{Guid.NewGuid():N}", - ["type"] = "Accept", - ["actor"] = bob.Id, - ["object"] = follow.DeepClone() - }); - Assert.True(Assert.Single((await alice.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("following")); - var create = bob.Create("

for my followers

", new[] { bob.Id + "/followers" }); - await _host.Deliver(bob, alice.Mouth, create); - var noteId = create["object"].Text("id"); - var stored = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token); - Assert.Equal(PostVisibility.FollowersOnly, stored.Visibility); - var path = $"/api/v1/accounts/{bobId}/statuses"; - - Assert.Equal(new[] { stored.ID }, (await alice.Client.Get(path)).Ok().Ids); - Assert.Empty((await carol.Client.Get(path)).Ok().Array); - Assert.Empty((await _host.Client().Get(path)).Ok().Array); - Assert.Equal("private", (await alice.Client.Get($"/api/v1/statuses/{stored.ID}")).Ok().Body.Text("visibility")); - Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Get($"/api/v1/statuses/{stored.ID}")).Status); - Assert.Contains(stored.ID, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); - Assert.True((await alice.Client.Post($"/api/v1/statuses/{stored.ID}/favourite")).Ok().Body.Flag("favourited")); - } - - [Fact] - public async Task Search_by_address_finds_local_posts_and_personas_but_never_a_circle_or_a_hidden_post() - { - var alice = await _host.Mastodon("alice"); - var stranger = await _host.Mastodon("stranger"); - var open = await alice.Status("findable"); - var quiet = await alice.Status("not for strangers", ("visibility", "private")); - var circleName = $"circle{Guid.NewGuid():N}"[..20]; - using (var clientApi = _host.As(alice.Persona.Root.Jwt)) - Assert.Equal(HttpStatusCode.OK, (await clientApi.PostAsJsonAsync("/clientapi/group/insert", - new { avatarId = alice.Id, userName = circleName, name = "inner", isCommunity = false }, Token)).StatusCode); - - var byPost = (await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(open.Text("uri"))}")).Ok(); - Assert.Equal(open.Text("id"), Assert.Single(byPost.Body["statuses"]!.AsArray()).Text("id")); - Assert.Equal(open.Text("id"), Assert.Single((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(open.Text("url"))}")).Ok().Body["statuses"]!.AsArray()).Text("id")); - var hidden = (await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(quiet.Text("uri"))}&resolve=true")).Ok(); - Assert.Empty(hidden.Body["statuses"]!.AsArray()); - Assert.Equal(alice.Id, Assert.Single((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(alice.Uri)}")).Ok().Body["accounts"]!.AsArray()).Text("id")); - Assert.Empty((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString($"{PrivaPubHost.Base}/peasants/{circleName}")}&resolve=true")).Ok().Body["accounts"]!.AsArray()); - Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={circleName}")).Ok().Array); - var tag = (await stranger.Client.Get("/api/v2/search?q=%23Cats&type=hashtags")).Ok(); - Assert.Equal("cats", Assert.Single(tag.Body["hashtags"]!.AsArray()).Text("name")); - Assert.Empty(tag.Body["accounts"]!.AsArray()); - - (await alice.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); - Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Array); - Assert.Equal(alice.Id, (await stranger.Client.Get($"/api/v1/accounts/lookup?acct={alice.UserName}")).Ok().Body.Text("id")); - } - - [Fact] - public async Task A_banned_login_loses_every_persona_token() - { - var alice = await _host.Mastodon("alice"); - Assert.Equal(HttpStatusCode.OK, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Status); - - await DB.Default.Update().MatchID(alice.Persona.Root.Id).Modify(r => r.IsBanned, true).ExecuteAsync(Token); - - Assert.Equal(HttpStatusCode.Unauthorized, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await alice.Client.Post("/api/v1/statuses", ("status", "still here?"))).Status); - Assert.Equal(alice.Id, (await _host.Client().Get($"/api/v1/accounts/{alice.Id}")).Ok().Body.Text("id")); - } - - [Fact] - public async Task A_forwarded_report_leaves_as_a_flag_from_the_instance_actor_never_from_the_reporter() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var local = await _host.Mastodon("local"); - var bob = new RemoteActor(_peer, "bob"); - var post = await _host.PublicPostFrom(bob, alice, "

something nasty

"); - var bobId = (await _host.Known(bob)).ID; - var unrelated = await alice.Status("mine"); - - var report = (await alice.Client.Post("/api/v1/reports", ("account_id", bobId), ("status_ids[]", post.ID), ("status_ids[]", unrelated.Text("id")), - ("comment", "please look"), ("category", "spam"), ("forward", "true"))).Ok(); - - Assert.Equal(bobId, report.Body["target_account"].Text("id")); - Assert.Equal(new[] { post.ID }, report.Body["status_ids"]!.AsArray().Select(i => i!.GetValue())); - Assert.True(report.Body.Flag("forwarded")); - Assert.Equal("spam", report.Body.Text("category")); - var flag = Assert.Single(await bob.Delivered(since)); - Assert.Equal("Flag", flag.Type()); - Assert.Equal($"{PrivaPubHost.Base}/peasants/privapub", flag.Text("actor")); - Assert.Equal(new[] { bob.Id, post.ObjectURI }, flag["object"]!.AsArray().Select(o => o!.GetValue())); - Assert.DoesNotContain(alice.UserName, flag.ToJsonString()); - Assert.DoesNotContain(alice.Id, flag.ToJsonString()); - - var quiet = (await alice.Client.Post("/api/v1/reports", ("account_id", bobId), ("comment", "just noting"))).Ok(); - Assert.False(quiet.Body.Flag("forwarded")); - Assert.False((await alice.Client.Post("/api/v1/reports", ("account_id", local.Id), ("forward", "true"))).Ok().Body.Flag("forwarded")); - Assert.Single(await bob.Delivered(since)); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/reports", ("account_id", "000000000000000000000000"))).Status); - } - - [Fact] - public async Task Statuses_count_still_counts_located_posts_pending_an_owner_decision() - { - var alice = await _host.Mastodon("alice"); - await alice.Status("visible"); - await _host.Located(alice, "by the river"); - - var account = (await _host.Client().Get($"/api/v1/accounts/{alice.Id}")).Ok(); - - Assert.Equal(2, account.Body.Number("statuses_count")); - Assert.Single((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); - } - - [Fact] - public async Task Account_stub_routes_answer_empty_lists() - { - var alice = await _host.Mastodon("alice"); - - Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array); - var familiar = (await alice.Client.Get($"/api/v1/accounts/familiar_followers?id[]={alice.Id}")).Ok(); - Assert.Equal(alice.Id, Assert.Single(familiar.Array).Text("id")); - Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray()); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonFeedTests.cs b/PrivaPub.Tests/Http/MastodonFeedTests.cs deleted file mode 100644 index f70f1c0..0000000 --- a/PrivaPub.Tests/Http/MastodonFeedTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities - [Trait("Category", "Integration")] - public sealed class MastodonFeedTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task A_feed_shows_as_an_account_whose_following_is_its_communities() - { - var token = TestContext.Current.CancellationToken; - var reader = await _host.Mastodon("feeder"); - var cats = new RemoteActor(_peer, "cats", type: "Group"); - var dogs = new RemoteActor(_peer, "dogs", type: "Group"); - var name = $"feed{Guid.NewGuid():N}"[..12]; - var feed = $"{_peer.A}/m/{name}"; - // as Lemmy serves one: the instance's inbox and key, its text in `description` - _peer.Serve($"/m/{name}", new JsonObject - { - ["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"), - ["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox", - ["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" }, - ["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet", - ["attributedTo"] = $"{_peer.A}/users/owner" - }.ToJsonString()); - _peer.Serve($"/m/{name}/following", new JsonObject - { - ["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id) - }.ToJsonString()); - - var account = await _host.Get().GetActor(feed, refresh: true, token); - Assert.NotNull(account); - await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token); - - var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body; - Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count"))); - Assert.Contains("every pet", shown.Text("note")); - var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array; - Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri"))); - Assert.All(communities, c => Assert.True(c!["group"]!.GetValue())); - } - - // owner decision 2026-10-07: a feed is read through the server, whose reader follows its communities; the persona - // follows nothing, and reads the feed in a timeline of its own - [Fact] - public async Task A_feed_is_followed_by_the_servers_reader_and_read_in_its_own_timeline() - { - var token = TestContext.Current.CancellationToken; - var since = DateTime.UtcNow.AddSeconds(-1); - var persona = await _host.Mastodon("feedreader"); - var cats = new RemoteActor(_peer, "cats", type: "Group"); - var dogs = new RemoteActor(_peer, "dogs", type: "Group"); - var poster = new RemoteActor(_peer, "poster"); - var name = $"feed{Guid.NewGuid():N}"[..12]; - var feed = $"{_peer.A}/m/{name}"; - _peer.Serve($"/m/{name}", new JsonObject - { - ["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox", ["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets" - }.ToJsonString()); - _peer.Serve($"/m/{name}/following", new JsonObject - { - ["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id) - }.ToJsonString()); - var account = await _host.Get().GetActor(feed, refresh: true, token); - await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token); - - var followed = (await persona.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Ok().Body; - Assert.True(followed.Flag("following")); - Assert.Equal(new[] { account.ID }, (await persona.Client.Get("/api/v1/feeds")).Ok().Ids); - await _host.Run(j => j.Kind == JobKind.SyncFeeds, token); - - var readerActor = await _host.Get().GetReaderActor(token); - var follows = await DB.Default.Find().Match(f => f.AvatarId == readerActor.Id).ExecuteAsync(token); - Assert.Equal(new[] { cats.Id, dogs.Id }.Order(), follows.Select(f => f.TargetActorURI).Order()); - Assert.All(follows, f => Assert.Equal(LocalActorKind.Reader, f.FollowerKind)); - Assert.False(await DB.Default.Find().Match(f => f.AvatarId == persona.Id).ExecuteAnyAsync(token)); - var asked = Assert.Single(await cats.Delivered(since), d => d.Type() == "Follow"); - Assert.Equal(readerActor.Uri, asked.Text("actor")); - var document = (await _host.Client().Fetch(new Uri(readerActor.Uri).AbsolutePath)).Json; - Assert.Equal(("Service", LocalActorService.ReaderUserName), (document.Text("type"), document.Text("preferredUsername"))); - - await _host.Deliver(cats, "/human-centipede", new JsonObject - { - ["id"] = $"{cats.Id}/accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = cats.Id, ["object"] = asked.Text("id") - }); - var create = poster.Create("

a thread among cats

", new[] { "https://www.w3.org/ns/activitystreams#Public", cats.Id }, shape: note => - { - note["type"] = "Page"; - note["name"] = "Cats"; - note["audience"] = cats.Id; - }); - _peer.Serve(new Uri(create["object"]!.Text("id")).AbsolutePath, create["object"]!.ToJsonString()); - await _host.Deliver(cats, "/human-centipede", new JsonObject - { - ["id"] = $"{cats.Id}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = cats.Id, ["object"] = create, - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public") - }); - var thread = await DB.Default.Find().Match(p => p.ObjectURI == create["object"]!.Text("id")).ExecuteSingleAsync(token); - - Assert.Equal(new[] { thread.ID }, (await persona.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Ok().Ids); - Assert.DoesNotContain(thread.ID, (await persona.Client.Get("/api/v1/timelines/home")).Ok().Ids); - Assert.False(await DB.Default.Find().Match(e => e.PostId == thread.ID).ExecuteAnyAsync(token)); - var stranger = await _host.Mastodon("notreading"); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Status); - - Assert.False((await persona.Client.Post($"/api/v1/accounts/{account.ID}/unfollow")).Ok().Body.Flag("following")); - await _host.Run(j => j.Kind == JobKind.SyncFeeds, token); - Assert.False(await DB.Default.Find().Match(f => f.AvatarId == readerActor.Id).ExecuteAnyAsync(token)); - Assert.Contains(await cats.Delivered(since), d => d.Type() == "Undo" && d["object"].Text("type") == "Follow"); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonFiltersTests.cs b/PrivaPub.Tests/Http/MastodonFiltersTests.cs deleted file mode 100644 index 127df7a..0000000 --- a/PrivaPub.Tests/Http/MastodonFiltersTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Mastodon's filters: a persona's v2 filters with keywords and statuses, the v1 API over their keywords, and the - // `filtered` each status carries for the filters it matches - [Trait("Category", "Integration")] - public sealed class MastodonFiltersTests : IAsyncLifetime - { - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static Task Create(Mastodon owner, string title, string action, params (string Keyword, bool WholeWord)[] keywords) => - owner.Client.Json(HttpMethod.Post, "/api/v2/filters", new JsonObject - { - ["title"] = title, - ["context"] = new JsonArray("home", "public", "thread"), - ["filter_action"] = action, - ["keywords_attributes"] = new JsonArray(keywords.Select(k => (JsonNode)new JsonObject { ["keyword"] = k.Keyword, ["whole_word"] = k.WholeWord }).ToArray()) - }); - - [Fact] - public async Task A_filter_is_made_changed_and_deleted_by_its_owner_only() - { - var alice = await _host.Mastodon("alice"); - var mallory = await _host.Mastodon("mallory"); - - var made = (await Create(alice, "Spoilers", "hide", ("spoiler", true), ("leak", false))).Ok().Object; - var id = made.Text("id"); - Assert.Equal("hide", made.Text("filter_action")); - Assert.Equal(new[] { "home", "public", "thread" }, made["context"]!.AsArray().Select(c => c!.GetValue())); - var keywords = made["keywords"]!.AsArray(); - Assert.Equal(new[] { "spoiler", "leak" }, keywords.Select(k => k.Text("keyword"))); - Assert.Equal(new[] { true, false }, keywords.Select(k => k!.Flag("whole_word"))); - Assert.Null(made["expires_at"]); - - var changed = (await alice.Client.Json(HttpMethod.Put, $"/api/v2/filters/{id}", new JsonObject - { - ["title"] = "Leaks", - ["filter_action"] = "warn", - ["expires_in"] = 3600, - ["keywords_attributes"] = new JsonArray( - new JsonObject { ["id"] = keywords[0].Text("id"), ["_destroy"] = true }, - new JsonObject { ["keyword"] = "ending" }) - })).Ok().Object; - Assert.Equal("Leaks", changed.Text("title")); - Assert.Equal("warn", changed.Text("filter_action")); - Assert.NotNull(changed.Text("expires_at")); - Assert.Equal(new[] { "leak", "ending" }, changed["keywords"]!.AsArray().Select(k => k.Text("keyword"))); - - var added = (await alice.Client.Post($"/api/v2/filters/{id}/keywords", ("keyword", "finale"), ("whole_word", "false"))).Ok().Object; - Assert.False(added.Flag("whole_word")); - var keywordId = added.Text("id"); - Assert.Equal("finale", (await alice.Client.Get($"/api/v2/filters/keywords/{keywordId}")).Ok().Object.Text("keyword")); - Assert.Equal("last scene", (await alice.Client.Put($"/api/v2/filters/keywords/{keywordId}", ("keyword", "last scene"))).Ok().Object.Text("keyword")); - (await alice.Client.Delete($"/api/v2/filters/keywords/{keywordId}")).Ok(); - Assert.Equal(2, (await alice.Client.Get($"/api/v2/filters/{id}/keywords")).Ok().Array.Count); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, " ", "warn", ("x", true))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, "x", "explode", ("x", true))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v2/filters", ("title", "x"), ("context[]", "everywhere"))).Status); - Assert.Empty((await mallory.Client.Get("/api/v2/filters")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v2/filters/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v2/filters/keywords/{changed["keywords"]![0].Text("id")}")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v2/filters")).Status); - - (await alice.Client.Delete($"/api/v2/filters/{id}")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array); - } - - [Fact] - public async Task Statuses_carry_the_filters_they_match_by_word_and_by_id() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - var filterId = (await Create(alice, "Spoilers", "warn", ("spoiler", true), ("#tv", true))).Ok().Object.Text("id"); - var other = (await Create(bob, "Not mine", "hide", ("spoiler", false))).Ok().Object.Text("id"); - var whole = (await alice.Status("a big SPOILER ahead")).Text("id"); - var partial = (await alice.Status("spoilers galore")).Text("id"); - var tagged = (await alice.Status("watching #TV tonight")).Text("id"); - var plain = (await alice.Status("nothing to see")).Text("id"); - var warned = (await alice.Status("calm text", ("spoiler_text", "spoiler inside"))).Text("id"); - var byId = (await alice.Client.Post($"/api/v2/filters/{filterId}/statuses", ("status_id", plain))).Ok().Object; - Assert.Equal(plain, byId.Text("status_id")); - var boost = (await bob.Client.Post($"/api/v1/statuses/{whole}/reblog")).Ok().Object.Text("id"); - - var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Array.ToDictionary(s => s.Text("id"), s => s!["filtered"]!.AsArray()); - var match = Assert.Single(home[whole]); - Assert.Equal(filterId, match!["filter"].Text("id")); - Assert.Equal("warn", match["filter"].Text("filter_action")); - Assert.Equal(new[] { "spoiler" }, match["keyword_matches"]!.AsArray().Select(k => k!.GetValue())); - Assert.Empty(home[partial]); - Assert.Equal(new[] { "#tv" }, Assert.Single(home[tagged])!["keyword_matches"]!.AsArray().Select(k => k!.GetValue())); - Assert.Equal(new[] { byId.Text("id") }, Assert.Single(home[plain])!["status_matches"]!.AsArray().Select(k => k!.GetValue())); - Assert.Single(home[warned]); - Assert.Single((await alice.Client.Get($"/api/v1/statuses/{boost}")).Ok().Body["filtered"]!.AsArray()); - - var asBob = (await bob.Client.Get($"/api/v1/statuses/{partial}")).Ok().Body["filtered"]!.AsArray(); - Assert.Equal(other, Assert.Single(asBob)!["filter"].Text("id")); - Assert.Empty((await _host.Client().Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray()); - - // an expired filter matches nothing - await DB.Default.Update().MatchID(filterId).Modify(f => f.ExpiresAt, DateTime.UtcNow.AddMinutes(-1)).ExecuteAsync(Token); - Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray()); - } - - [Fact] - public async Task Keywords_come_as_form_fields_listed_or_numbered() - { - var alice = await _host.Mastodon("alice"); - - var listed = (await alice.Client.Post("/api/v2/filters", ("title", "Listed"), ("context[]", "home"), - ("keywords_attributes[][keyword]", "one"), ("keywords_attributes[][whole_word]", "false"), - ("keywords_attributes[][keyword]", "two"), ("keywords_attributes[][whole_word]", "true"))).Ok().Object; - Assert.Equal(new[] { ("one", false), ("two", true) }, - listed["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word")))); - - var numbered = (await alice.Client.Post("/api/v2/filters", ("title", "Numbered"), ("context[]", "public"), - ("keywords_attributes[1][keyword]", "second"), ("keywords_attributes[0][keyword]", "first"), - ("keywords_attributes[0][whole_word]", "false"))).Ok().Object; - Assert.Equal(new[] { ("first", false), ("second", true) }, - numbered["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word")))); - } - - [Fact] - public async Task A_v1_filter_is_one_keyword_of_a_v2_filter() - { - var alice = await _host.Mastodon("alice"); - - var made = (await alice.Client.Post("/api/v1/filters", ("phrase", "election"), ("context[]", "home"), ("context[]", "notifications"), - ("irreversible", "true"), ("whole_word", "false"))).Ok().Object; - var id = made.Text("id"); - Assert.Equal("election", made.Text("phrase")); - Assert.True(made.Flag("irreversible")); - Assert.False(made.Flag("whole_word")); - var v2 = Assert.Single((await alice.Client.Get("/api/v2/filters")).Ok().Array)!; - Assert.Equal("election", v2.Text("title")); - Assert.Equal("hide", v2.Text("filter_action")); - Assert.Equal(id, Assert.Single(v2["keywords"]!.AsArray()).Text("id")); - - var changed = (await alice.Client.Put($"/api/v1/filters/{id}", ("phrase", "elections"), ("irreversible", "false"))).Ok().Object; - Assert.Equal("elections", changed.Text("phrase")); - Assert.False(changed.Flag("irreversible")); - Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/filters")).Ok().Ids); - Assert.Equal("elections", (await alice.Client.Get($"/api/v1/filters/{id}")).Ok().Object.Text("phrase")); - - (await alice.Client.Delete($"/api/v1/filters/{id}")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/filters/{id}")).Status); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs deleted file mode 100644 index 23b354a..0000000 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using PrivaPub.Api.Mastodon.Controllers; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class MastodonInstanceTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task Both_instance_documents_advertise_mastodon_4_2_and_the_domain() - { - var anonymous = _host.Client(); - - var v1 = (await anonymous.Get("/api/v1/instance")).Ok(); - Assert.Equal("4.2.0 (compatible; PrivaPub)", v1.Body.Text("version")); - Assert.Equal(InstanceController.Version, v1.Body.Text("version")); - Assert.Equal(PrivaPubHost.Host, v1.Body.Text("uri")); - Assert.True(v1.Body.Flag("registrations")); - Assert.True(v1.Body.Flag("invites_enabled")); - Assert.True(v1.Body["stats"].Number("user_count") >= 0); - Assert.Equal(5000, v1.Body["configuration"]!["statuses"].Number("max_characters")); - Assert.Equal(4, v1.Body["configuration"]!["polls"].Number("max_options")); - Assert.Contains("image/jpeg", v1.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue())); - Assert.Empty(v1.Body["rules"]!.AsArray()); - Assert.Null(v1.Body["contact_account"]); - Assert.True(v1.Body.AsObject().ContainsKey("contact_account")); - - var v2 = (await anonymous.Get("/api/v2/instance")).Ok(); - Assert.Equal(InstanceController.Version, v2.Body.Text("version")); - Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain")); - Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"]!.Text("streaming")); - Assert.Equal($"wss://{PrivaPubHost.Host}", v1.Body["urls"]!.Text("streaming_api")); - Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses")); - Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue())); - // what the bundled libvips can't decode is not offered - Assert.DoesNotContain("image/heic", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue())); - Assert.True(v2.Body["registrations"].Flag("enabled")); - Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations")); - Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled")); - Assert.Equal(7, v2.Body["api_versions"].Number("mastodon")); - - var signedIn = await _host.Mastodon("reader"); - Assert.Equal(InstanceController.Version, (await signedIn.Client.Get("/api/v1/instance")).Ok().Body.Text("version")); - } - - [Fact] - public async Task The_instance_side_routes_answer_without_a_login() - { - var anonymous = _host.Client(); - - Assert.Empty((await anonymous.Get("/api/v1/instance/peers")).Ok().Array); - Assert.Empty((await anonymous.Get("/api/v1/instance/activity")).Ok().Array); - Assert.Empty((await anonymous.Get("/api/v1/instance/rules")).Ok().Array); - var extended = (await anonymous.Get("/api/v1/instance/extended_description")).Ok(); - Assert.StartsWith("

", extended.Body.Text("content")); - Assert.EndsWith("T00:00:00.000Z", extended.Body.Text("updated_at")); - } - - [Theory] - [InlineData("/api/v1/custom_emojis")] - [InlineData("/api/v1/announcements")] - public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path) - { - Assert.Empty((await _host.Client().Get(path)).Ok().Array); - } - - [Theory] - [InlineData("/api/v1/suggestions")] - [InlineData("/api/v2/suggestions")] - [InlineData("/api/v1/endorsements")] - [InlineData("/api/v1/featured_tags")] - public async Task Signed_in_stub_routes_answer_empty_lists(string path) - { - var reader = await _host.Mastodon("reader"); - - Assert.Empty((await reader.Client.Get(path)).Ok().Array); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get(path)).Status); - } - - [Fact] - public async Task Apps_are_registered_validated_and_verified() - { - var anonymous = _host.Client(); - - var app = (await anonymous.Post("/api/v1/apps", ("client_name", "tusky-ish"), ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"), ("scopes", "read write"), - ("website", "https://example.com"))).Ok(); - - Assert.Equal("tusky-ish", app.Body.Text("name")); - Assert.Equal(new[] { "read", "write" }, app.Body["scopes"]!.AsArray().Select(s => s!.GetValue())); - Assert.False(string.IsNullOrEmpty(app.Body.Text("client_id"))); - Assert.False(string.IsNullOrEmpty(app.Body.Text("client_secret"))); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"), ("redirect_uris", "javascript:alert(1)"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"))).Status); - - var reader = await _host.Mastodon("reader"); - var verified = (await reader.Client.Get("/api/v1/apps/verify_credentials")).Ok(); - Assert.Equal("privapub-tests", verified.Body.Text("name")); - Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.Get("/api/v1/apps/verify_credentials")).Status); - } - - [Fact] - public async Task Preferences_follow_the_persona_and_push_has_no_subscription() - { - var reader = await _host.Mastodon("reader", scopes: "read write follow push"); - (await reader.Client.Patch("/api/v1/accounts/update_credentials", ("source[privacy]", "unlisted"), ("source[sensitive]", "true"))).Ok(); - - var preferences = (await reader.Client.Get("/api/v1/preferences")).Ok(); - - Assert.Equal("unlisted", preferences.Body.Text("posting:default:visibility")); - Assert.True(preferences.Body.Flag("posting:default:sensitive")); - Assert.False(preferences.Body.Flag("reading:expand:spoilers")); - var push = await reader.Client.Get("/api/v1/push/subscription"); - Assert.Equal(HttpStatusCode.NotFound, push.Status); - Assert.Equal("Record not found", push.Body.Text("error")); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonListsTests.cs b/PrivaPub.Tests/Http/MastodonListsTests.cs deleted file mode 100644 index 2d5557d..0000000 --- a/PrivaPub.Tests/Http/MastodonListsTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - // Mastodon's lists: a persona's own groups of accounts it follows, read as timelines, never federated - [Trait("Category", "Integration")] - public sealed class MastodonListsTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static async Task Follow(Mastodon follower, Mastodon target) => - (await follower.Client.Post($"/api/v1/accounts/{target.Id}/follow")).Ok(); - - static async Task List(Mastodon owner, string title, params (string Key, string Value)[] more) => - (await owner.Client.Post("/api/v1/lists", more.Prepend(("title", title)).ToArray())).Ok().Object.Text("id"); - - [Fact] - public async Task A_list_is_created_renamed_and_deleted_by_its_owner_only() - { - var alice = await _host.Mastodon("alice"); - var mallory = await _host.Mastodon("mallory"); - - var created = (await alice.Client.Post("/api/v1/lists", ("title", "Friends"), ("replies_policy", "followed"), ("exclusive", "true"))).Ok().Object; - var id = created.Text("id"); - Assert.Equal("Friends", created.Text("title")); - Assert.Equal("followed", created.Text("replies_policy")); - Assert.True(created.Flag("exclusive")); - var other = await List(alice, "Art"); - - Assert.Equal(new[] { other, id }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); - var renamed = (await alice.Client.Put($"/api/v1/lists/{id}", ("title", "Close friends"), ("exclusive", "false"))).Ok().Object; - Assert.Equal("Close friends", renamed.Text("title")); - Assert.Equal("followed", renamed.Text("replies_policy")); - Assert.False(renamed.Flag("exclusive")); - Assert.Equal("Close friends", (await alice.Client.Get($"/api/v1/lists/{id}")).Ok().Object.Text("title")); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", " "))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", "x"), ("replies_policy", "everyone"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/lists/{id}", ("title", ""))).Status); - Assert.Empty((await mallory.Client.Get("/api/v1/lists")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/lists/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/lists/{id}", ("title", "mine"))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/lists/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/timelines/list/{id}")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/lists")).Status); - - (await alice.Client.Delete($"/api/v1/lists/{id}")).Ok(); - Assert.Equal(new[] { other }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/lists/{id}")).Status); - } - - [Fact] - public async Task Only_followed_accounts_join_a_list_and_its_timeline_shows_their_posts() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var carol = await _host.Mastodon("carol"); - var dave = await _host.Mastodon("dave"); - await Follow(alice, bob); - await Follow(alice, dave); - var list = await List(alice, "Bob"); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", carol.Id))).Status); - (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Ids); - Assert.Equal(new[] { list }, (await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Ids); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{dave.Id}/lists")).Ok().Array); - - var fromBob = (await bob.Status("from bob")).Text("id"); - await dave.Status("from dave"); - await carol.Status("from carol"); - Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); - - (await alice.Client.Delete($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); - Assert.Empty((await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Array); - } - - [Theory] - [InlineData("list", true, false, false)] - [InlineData("followed", true, true, false)] - [InlineData("none", true, false, false)] - public async Task A_lists_replies_follow_its_policy(string policy, bool toSelf, bool toFollowed, bool toStranger) - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var dave = await _host.Mastodon("dave"); - var carol = await _host.Mastodon("carol"); - await Follow(alice, bob); - await Follow(alice, dave); - var list = await List(alice, "Bob", ("replies_policy", policy)); - (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - var root = (await bob.Status("a thread")).Text("id"); - var mine = (await alice.Status("asking bob")).Text("id"); - var ofDave = (await dave.Status("dave's")).Text("id"); - var ofCarol = (await carol.Status("carol's")).Text("id"); - - var self = (await bob.Status("and more", ("in_reply_to_id", root))).Text("id"); - var toAlice = (await bob.Status($"@{alice.UserName} answered", ("in_reply_to_id", mine))).Text("id"); - var toDave = (await bob.Status($"@{dave.UserName} agreed", ("in_reply_to_id", ofDave))).Text("id"); - var toCarol = (await bob.Status($"@{carol.UserName} hm", ("in_reply_to_id", ofCarol))).Text("id"); - - var shown = (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids.ToList(); - Assert.Contains(root, shown); - Assert.Equal(toSelf, shown.Contains(self)); - Assert.Contains(toAlice, shown); - Assert.Equal(toFollowed, shown.Contains(toDave)); - Assert.Equal(toStranger, shown.Contains(toCarol)); - } - - [Fact] - public async Task An_exclusive_lists_members_are_read_there_and_not_at_home() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var dave = await _host.Mastodon("dave"); - await Follow(alice, bob); - await Follow(alice, dave); - var list = await List(alice, "Apart", ("exclusive", "true")); - (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - var fromBob = (await bob.Status("from bob")).Text("id"); - var fromDave = (await dave.Status("from dave")).Text("id"); - - var home = (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids.ToList(); - Assert.Contains(fromDave, home); - Assert.DoesNotContain(fromBob, home); - Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); - - (await alice.Client.Put($"/api/v1/lists/{list}", ("exclusive", "false"))).Ok(); - Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); - } - - [Fact] - public async Task Account_search_with_following_finds_only_followed_accounts() - { - var alice = await _host.Mastodon("alice"); - var prefix = $"ls{Guid.NewGuid():N}"[..10]; - var followed = await _host.Mastodon(prefix + "a"); - var stranger = await _host.Mastodon(prefix + "b"); - await Follow(alice, followed); - - var found = (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Ids.ToList(); - Assert.Equal(new[] { followed.Id }, found); - Assert.Contains(stranger.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}")).Ok().Ids); - Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Array); - } - - [Fact] - public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - await Follow(alice, bob); - var list = await List(alice, "Bob", ("exclusive", "true")); - (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); - - (await alice.Client.Post($"/api/v1/accounts/{bob.Id}/unfollow")).Ok(); - Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Array); - - await Follow(alice, bob); - var fromBob = (await bob.Status("back again")).Text("id"); - Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); - Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonMediaTests.cs b/PrivaPub.Tests/Http/MastodonMediaTests.cs deleted file mode 100644 index a6a45f1..0000000 --- a/PrivaPub.Tests/Http/MastodonMediaTests.cs +++ /dev/null @@ -1,504 +0,0 @@ -using PrivaPub.Models.Jobs; -using PrivaPub.Domain.Media; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Diagnostics; -using System.Net; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class MastodonMediaTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - // what GET /api/v1/media/:id answers once the job processing an upload sent to v2 has run - async Task Processed(Mastodon account, ApiAnswer accepted) - { - var id = accepted.Body.Text("id"); - await _host.Run(j => j.Kind == JobKind.ProcessMedia && j.Payload == id, Token); - return await account.Client.Get($"/api/v1/media/{id}"); - } - - static Task Upload(Mastodon account, string path, byte[] bytes, string contentType, string fileName, params (string Key, string Value)[] fields) - { - var form = MastodonHelpers.Multipart(("file", bytes, contentType, fileName)); - foreach (var (key, value) in fields) - form.Add(new StringContent(value), key); - return account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, path) { Content = form }); - } - - static async Task<(int ExitCode, string Output)> Run(string program, params string[] arguments) - { - var start = new ProcessStartInfo(program) { RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (var argument in arguments) - start.ArgumentList.Add(argument); - try - { - using var process = Process.Start(start)!; - var output = process.StandardOutput.ReadToEndAsync(Token); - var errors = process.StandardError.ReadToEndAsync(Token); - await process.WaitForExitAsync(Token); - return (process.ExitCode, await output + await errors); - } - catch (System.ComponentModel.Win32Exception) - { - return (-1, $"{program} is not installed"); - } - } - - static async Task Made(string extension, params string[] arguments) - { - if ((await Run("ffmpeg", "-version")).ExitCode != 0 || (await Run("ffprobe", "-version")).ExitCode != 0) - Assert.Skip("ffmpeg and ffprobe are needed to make and inspect audio and video"); - var path = Path.Combine(Path.GetTempPath(), $"privapub-av-{Guid.NewGuid():N}.{extension}"); - try - { - var (exitCode, output) = await Run("ffmpeg", arguments.Append(path).Prepend("-nostdin").Prepend("-y").ToArray()); - Assert.True(exitCode == 0, output); - return await File.ReadAllBytesAsync(path, Token); - } - finally - { - File.Delete(path); - } - } - - async Task Probe(string url) - { - var path = Path.Combine(Path.GetTempPath(), $"privapub-probe-{Guid.NewGuid():N}"); - try - { - await File.WriteAllBytesAsync(path, await _host.Client().GetByteArrayAsync(url, Token), Token); - var (exitCode, output) = await Run("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path); - Assert.Equal(0, exitCode); - return output; - } - finally - { - File.Delete(path); - } - } - - [Theory] - [InlineData("/api/v1/media")] - [InlineData("/api/v2/media")] - public async Task Images_upload_with_their_description_and_focus_and_without_their_metadata(string route) - { - var alice = await _host.Mastodon("alice"); - - var uploaded = (await Upload(alice, route, MastodonHelpers.JpegWithMetadata(640, 480), "image/jpeg", "holiday.jpg", - ("description", "a blue square"), ("focus", "0.5,-0.25"))).Ok(); - - Assert.Equal("image", uploaded.Body.Text("type")); - Assert.Equal("a blue square", uploaded.Body.Text("description")); - Assert.Equal(640, uploaded.Body["meta"]!["original"].Number("width")); - Assert.Equal(480, uploaded.Body["meta"]!["original"].Number("height")); - Assert.Equal(0.5, uploaded.Body["meta"]!["focus"]!["x"]!.GetValue()); - Assert.Equal(-0.25, uploaded.Body["meta"]!["focus"]!["y"]!.GetValue()); - Assert.False(string.IsNullOrEmpty(uploaded.Body.Text("blurhash"))); - Assert.Null(uploaded.Body.Text("remote_url")); - foreach (var field in new[] { "url", "preview_url" }) - { - Assert.StartsWith($"{PrivaPubHost.Base}/media/files/", uploaded.Body.Text(field)); - MastodonHelpers.AssertNoMetadata(await _host.Client().GetByteArrayAsync(uploaded.Body.Text(field), Token)); - } - } - - [Fact] - public async Task Media_is_read_and_described_only_by_its_owner() - { - var alice = await _host.Mastodon("alice"); - var mallory = await _host.Mastodon("mallory"); - var id = (await Upload(alice, "/api/v2/media", MastodonHelpers.JpegWithMetadata(32, 32), "image/jpeg", "a.jpg")).Ok().Body.Text("id"); - - Assert.Equal(id, (await alice.Client.Get($"/api/v1/media/{id}")).Ok().Body.Text("id")); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/media/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/media/{id}", ("description", "mine now"))).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v1/media/{id}")).Status); - - var described = (await alice.Client.Put($"/api/v1/media/{id}", ("description", " a tiny square "), ("focus", "2,-3"))).Ok(); - - Assert.Equal("a tiny square", described.Body.Text("description")); - Assert.Equal((1.0, -1.0), (described.Body["meta"]!["focus"]!["x"]!.GetValue(), described.Body["meta"]!["focus"]!["y"]!.GetValue())); - Assert.Equal("a tiny square", (await alice.Client.Get($"/api/v1/media/{id}")).Ok().Body.Text("description")); - Assert.Null((await alice.Client.Put($"/api/v1/media/{id}", ("description", ""))).Ok().Body.Text("description")); - } - - // a focal point is two finite numbers: NaN or Infinity is ignored, never stored (it broke every timeline holding the post) - [Fact] - public async Task A_focal_point_that_is_not_a_number_is_ignored() - { - var alice = await _host.Mastodon("alice"); - var uploaded = (await Upload(alice, "/api/v2/media", MastodonHelpers.JpegWithMetadata(32, 32), "image/jpeg", "a.jpg", ("focus", "NaN,NaN"))).Ok(); - Assert.Null(uploaded.Body["meta"]?["focus"]); - var id = uploaded.Body.Text("id"); - - (await alice.Client.Put($"/api/v1/media/{id}", ("focus", "0.5,0.25"))).Ok(); - foreach (var unsound in new[] { "NaN,0", "Infinity,1", "0,-Infinity" }) - { - var focus = (await alice.Client.Put($"/api/v1/media/{id}", ("focus", unsound))).Ok().Body["meta"]!["focus"]!; - Assert.Equal((0.5, 0.25), (focus["x"]!.GetValue(), focus["y"]!.GetValue())); - } - var status = (await alice.Client.Post("/api/v1/statuses", ("status", "focused"), ("media_ids[]", id))).Ok(); - Assert.Equal(0.5, status.Body["media_attachments"]![0]!["meta"]!["focus"]!["x"]!.GetValue()); - } - - [Fact] - public async Task Unsupported_unreadable_and_missing_files_are_refused_with_422() - { - var alice = await _host.Mastodon("alice"); - - var text = await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("hello"), "text/plain", "a.txt"); - Assert.Equal(HttpStatusCode.UnprocessableEntity, text.Status); - Assert.Contains("not supported", text.Body.Text("error")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a jpeg"), "image/jpeg", "a.jpg")).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v1/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4")).Status); - // sent to v2, it is taken, then refused once processed - var later = await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4"); - Assert.Equal(HttpStatusCode.Accepted, later.Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Processed(alice, later)).Status); - var empty = new MultipartFormDataContent { { new StringContent("no file"), "description" } }; - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = empty })).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/media")).Status); - } - - // refused before anything is decoded: an SVG claiming to be a PNG, and an image that would decode to too many pixels - [Fact] - public async Task An_image_that_is_not_one_or_too_large_is_refused_before_decoding() - { - var alice = await _host.Mastodon("alice"); - var svg = ""u8.ToArray(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", svg, "image/png", "a.png")).Status); - - using var huge = NetVips.Image.Black(8000, 6000); - var tooLarge = await Upload(alice, "/api/v2/media", huge.WriteToBuffer(".png"), "image/png", "huge.png"); - Assert.Equal(HttpStatusCode.UnprocessableEntity, tooLarge.Status); - Assert.Contains("too large", tooLarge.Body.Text("error")); - } - - // an animated GIF becomes what Mastodon makes of one, a looping mp4 typed gifv; a still GIF stays an image - [Fact] - public async Task An_animated_gif_becomes_a_gifv_and_a_still_one_an_image() - { - var alice = await _host.Mastodon("alice"); - using var frame = (NetVips.Image.Black(64, 48, bands: 3) + 60).Cast(NetVips.Enums.BandFormat.Uchar); - using var frames = NetVips.Image.Arrayjoin(new[] { frame, frame + 80, frame + 160 }, across: 1).Cast(NetVips.Enums.BandFormat.Uchar); - using var animated = frames.Mutate(m => m.Set(NetVips.GValue.GIntType, "page-height", 48)); - - var gifv = (await Upload(alice, "/api/v2/media", animated.WriteToBuffer(".gif"), "image/gif", "dance.gif")).Ok(); - Assert.Equal("gifv", gifv.Body.Text("type")); - Assert.EndsWith(".mp4", gifv.Body.Text("url")); - Assert.Equal(64, gifv.Body["meta"]!["original"]!["width"]!.GetValue()); - var status = (await alice.Client.Post("/api/v1/statuses", ("status", "dancing"), ("media_ids[]", gifv.Body.Text("id")))).Ok(); - Assert.Equal("gifv", status.Body["media_attachments"]![0]!.Text("type")); - - var still = (await Upload(alice, "/api/v2/media", frame.WriteToBuffer(".gif"), "image/gif", "still.gif")).Ok(); - Assert.Equal("image", still.Body.Text("type")); - } - - [Fact] - public async Task Video_is_remuxed_without_its_metadata() - { - var alice = await _host.Mastodon("alice"); - var video = await Made("mp4", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=10", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", - "-metadata", "title=secret title", "-metadata", "comment=filmed at home", "-metadata:s:v:0", "handler_name=hidden handler", - "-c:v", "mpeg4", "-c:a", "aac", "-shortest"); - Assert.Contains("secret title", Encoding.Latin1.GetString(video)); - - // v2 answers before it is processed: 202, no url yet, and it can't be posted until it is - var accepted = await Upload(alice, "/api/v2/media", video, "video/mp4", "clip.mp4"); - Assert.Equal(HttpStatusCode.Accepted, accepted.Status); - Assert.Null(accepted.Body["url"]); - Assert.Equal(HttpStatusCode.PartialContent, (await alice.Client.Get($"/api/v1/media/{accepted.Body.Text("id")}")).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("media_ids[]", accepted.Body.Text("id")))).Status); - var uploaded = (await Processed(alice, accepted)).Ok(); - - Assert.Equal("video", uploaded.Body.Text("type")); - Assert.Equal(320, uploaded.Body["meta"]!["original"].Number("width")); - Assert.EndsWith(".mp4", uploaded.Body.Text("url")); - Assert.EndsWith(".jpg", uploaded.Body.Text("preview_url")); - var probe = await Probe(uploaded.Body.Text("url")); - Assert.Contains("\"codec_type\": \"video\"", probe); - // MPEG-4 Part 2, which browsers don't play, made H.264 - Assert.Contains("\"codec_name\": \"h264\"", probe); - Assert.DoesNotContain("secret title", probe); - Assert.DoesNotContain("filmed at home", probe); - Assert.DoesNotContain("hidden handler", probe); - } - - [Fact] - public async Task Audio_is_remuxed_without_its_metadata() - { - var alice = await _host.Mastodon("alice"); - var audio = await Made("m4a", "-f", "lavfi", "-i", "sine=frequency=330:duration=1", "-metadata", "title=secret song", "-metadata", "artist=Alice Smith", - "-c:a", "aac"); - - // v1 waits for it - var uploaded = (await Upload(alice, "/api/v1/media", audio, "audio/mp4", "song.m4a")).Ok(); - - Assert.Equal("audio", uploaded.Body.Text("type")); - var probe = await Probe(uploaded.Body.Text("url")); - Assert.Contains("\"codec_type\": \"audio\"", probe); - Assert.DoesNotContain("secret song", probe); - Assert.DoesNotContain("Alice Smith", probe); - } - - // FLAC is served as what it is (ASP.NET's map has no entry for it, so it was a 404) - [Fact] - public async Task Flac_is_served() - { - var alice = await _host.Mastodon("alice"); - var flac = await Made("flac", "-f", "lavfi", "-i", "sine=frequency=500:duration=1", "-c:a", "flac"); - - var uploaded = (await Upload(alice, "/api/v1/media", flac, "audio/flac", "song.flac")).Ok(); - - var served = await _host.Client().GetAsync(new Uri(uploaded.Body.Text("url")).PathAndQuery, Token); - Assert.Equal(HttpStatusCode.OK, served.StatusCode); - Assert.Equal("audio/flac", served.Content.Headers.ContentType?.MediaType); - } - - // a video larger than video_matrix_limit is made smaller, its shape kept - [Fact] - public async Task A_video_larger_than_the_limit_is_made_smaller() - { - var alice = await _host.Mastodon("alice"); - var video = await Made("mp4", "-f", "lavfi", "-i", "testsrc=duration=1:size=2000x1500:rate=5", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"); - - var uploaded = (await Upload(alice, "/api/v1/media", video, "video/mp4", "big.mp4")).Ok(); - - var width = uploaded.Body["meta"]!["original"].Number("width"); - var height = uploaded.Body["meta"]!["original"].Number("height"); - Assert.True((long)width * height <= 2_304_000, $"{width}x{height}"); - Assert.InRange((double)width / height, 1.3, 1.37); - } - - static byte[] Bytes(int length) - { - var bytes = new byte[length]; - Random.Shared.NextBytes(bytes); - return bytes; - } - - string Served(byte[] bytes, string contentType = "video/mp4") - { - var path = $"/media/{Guid.NewGuid():N}.bin"; - _peer.ServeFile(path, bytes, contentType); - return _peer.A + path; - } - - static HttpRequestMessage Ranged(string url, long from, long to) - { - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Range = new RangeHeaderValue(from, to); - return request; - } - - [Fact] - public async Task Every_remote_media_address_the_api_returns_goes_through_the_proxy() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var document = bob.Document(); - document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/avatar.png" }; - document["image"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/header.png" }; - _peer.Serve($"/users/{bob.Name}", document.ToJsonString()); - var bobId = (await _host.Known(bob)).ID; - var post = await _host.PublicPostFrom(bob, alice, "

look :blob:

", note => - { - note["attachment"] = new JsonArray(new JsonObject - { - ["type"] = "Document", ["mediaType"] = "image/png", ["url"] = _peer.A + "/picture.png", ["name"] = "a picture" - }); - note["tag"]!.AsArray().Add(new JsonObject - { - ["type"] = "Emoji", ["name"] = ":blob:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/blob.png" } - }); - }); - var proxied = $"{PrivaPubHost.Base}/media/proxy/"; - - var account = (await alice.Client.Get($"/api/v1/accounts/{bobId}")).Ok().Body; - var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body; - - foreach (var field in new[] { "avatar", "avatar_static", "header", "header_static" }) - Assert.StartsWith(proxied, account.Text(field)); - Assert.StartsWith(proxied, status["account"].Text("avatar")); - var attachment = Assert.Single(status["media_attachments"]!.AsArray()); - Assert.StartsWith(proxied, attachment.Text("url")); - Assert.StartsWith(proxied, attachment.Text("preview_url")); - Assert.Equal(_peer.A + "/picture.png", attachment.Text("remote_url")); - Assert.Equal("a picture", attachment.Text("description")); - Assert.StartsWith(proxied, Assert.Single(status["emojis"]!.AsArray()).Text("url")); - var everything = account.ToJsonString() + status.ToJsonString(); - foreach (var file in new[] { "/avatar.png", "/header.png", "/blob.png" }) - Assert.DoesNotContain(_peer.A + file, everything); - } - - [Fact] - public async Task The_proxy_refuses_a_url_it_did_not_sign() - { - var proxy = _host.Get(); - var remote = Served(Bytes(100)); - var wrapped = proxy.Wrap(remote); - var parts = new Uri(wrapped).AbsolutePath.Split('/'); - var other = new Uri(proxy.Wrap(Served(Bytes(100)))).AbsolutePath.Split('/'); - using var client = _host.Client(); - - Assert.StartsWith($"{PrivaPubHost.Base}/media/proxy/", wrapped); - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/AAAAAAAAAAAAAAAAAAAAAA/{parts[^1]}", Token)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/{parts[^2]}/{other[^1]}", Token)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/{parts[^2]}/!!!", Token)).StatusCode); - Assert.Empty(_peer.Requests); - Assert.Equal($"{PrivaPubHost.Base}/media/files/a.jpg", proxy.Wrap($"{PrivaPubHost.Base}/media/files/a.jpg")); - } - - [Fact] - public async Task A_ranged_request_is_streamed_as_206_and_never_cached() - { - var proxy = _host.Get(); - var bytes = Bytes(10_000); - var remote = Served(bytes); - using var client = _host.Client(); - - using var response = await client.SendAsync(Ranged(proxy.Wrap(remote), 100, 199), Token); - - Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); - Assert.Equal("bytes 100-199/10000", response.Content.Headers.ContentRange!.ToString()); - Assert.Equal(bytes[100..200], await response.Content.ReadAsByteArrayAsync(Token)); - Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single()); - Assert.Equal(default, proxy.Cached(remote)); - using var again = await client.SendAsync(Ranged(proxy.Wrap(remote), 0, 9), Token); - Assert.Equal(bytes[..10], await again.Content.ReadAsByteArrayAsync(Token)); - Assert.Equal(2, _peer.Requests.Count); - Assert.All(_peer.Requests, r => Assert.StartsWith("bytes=", r.Headers["Range"])); - } - - [Fact] - public async Task A_whole_download_is_cached_and_then_served_with_ranges() - { - var proxy = _host.Get(); - var bytes = Bytes(5_000); - var remote = Served(bytes, "image/png"); - using var client = _host.Client(); - - using var whole = await client.GetAsync(proxy.Wrap(remote), Token); - - Assert.Equal(HttpStatusCode.OK, whole.StatusCode); - Assert.Equal("image/png", whole.Content.Headers.ContentType!.MediaType); - Assert.Equal(bytes, await whole.Content.ReadAsByteArrayAsync(Token)); - Assert.NotNull(proxy.Cached(remote).Path); - using var part = await client.SendAsync(Ranged(proxy.Wrap(remote), 10, 19), Token); - Assert.Equal(HttpStatusCode.PartialContent, part.StatusCode); - Assert.Equal(bytes[10..20], await part.Content.ReadAsByteArrayAsync(Token)); - using var cached = await client.GetAsync(proxy.Wrap(remote), Token); - Assert.Equal(bytes, await cached.Content.ReadAsByteArrayAsync(Token)); - Assert.Single(_peer.Requests); - } - - // clients asking at once for a file not cached yet share one download - [Fact] - public async Task Clients_asking_at_once_share_one_download() - { - var proxy = _host.Get(); - var bytes = Bytes(200_000); - var remote = Served(bytes, "image/png"); - var path = new Uri(remote).AbsolutePath; - using var client = _host.Client(); - - var answers = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => client.GetAsync(proxy.Wrap(remote), Token))); - - foreach (var answer in answers) - Assert.Equal(bytes, await answer.Content.ReadAsByteArrayAsync(Token)); - Assert.Single(_peer.Requests, r => r.Path == path); - } - - // a remote file that can't be had is a 404 no browser keeps - [Fact] - public async Task A_remote_file_that_fails_is_not_cached_by_browsers() - { - var proxy = _host.Get(); - using var client = _host.Client(); - - using var missing = await client.GetAsync(proxy.Wrap($"{_peer.A}/media/{Guid.NewGuid():N}.png"), Token); - - Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); - Assert.Null(missing.Headers.CacheControl); - } - - [Fact] - public async Task Anything_over_the_proxy_limit_is_streamed_and_never_cached() - { - var host = await SmallProxyHost.Shared(); - var proxy = host.Get(); - var bytes = Bytes(SmallProxyHost.Limit * 4); - var remote = Served(bytes); - using var client = host.Client(); - - using var response = await client.GetAsync(proxy.Wrap(remote), Token); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(bytes.Length, response.Content.Headers.ContentLength); - Assert.Equal(bytes, await response.Content.ReadAsByteArrayAsync(Token)); - Assert.Equal(default, proxy.Cached(remote)); - using var again = await client.GetAsync(proxy.Wrap(remote), Token); - Assert.Equal(bytes, await again.Content.ReadAsByteArrayAsync(Token)); - // the first time, an attempt to cache it and the stream; then it is known to be too big, and only streamed - Assert.Equal(3, _peer.Requests.Count); - var small = Served(Bytes(SmallProxyHost.Limit / 2)); - using (var fits = await client.GetAsync(proxy.Wrap(small), Token)) - Assert.Equal(HttpStatusCode.OK, fits.StatusCode); - Assert.NotNull(proxy.Cached(small).Path); - } - } - - public sealed class SmallProxyHost : PrivaPubHost - { - public const int Limit = 16 * 1024; - - static readonly SemaphoreSlim Boot = new(1, 1); - static SmallProxyHost _shared; - - public static new async Task Shared() - { - await PrivaPubHost.Shared(); - await Boot.WaitAsync(); - try - { - if (_shared == default) - { - var host = new SmallProxyHost(); - _ = host.Services; - _shared = host; - } - return _shared; - } - finally - { - Boot.Release(); - } - } - - protected override IEnumerable> Settings() => - base.Settings().Append(new KeyValuePair("Media:MaxProxiedBytes", Limit.ToString(System.Globalization.CultureInfo.InvariantCulture))); - } -} diff --git a/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs b/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs deleted file mode 100644 index 13d890d..0000000 --- a/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Media; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - // Mastodon's scheduled statuses: a post given a scheduled_at waits as asked, can be moved or dropped, and is - // published by its job when its time comes - [Trait("Category", "Integration")] - public sealed class MastodonScheduledStatusesTests : IAsyncLifetime - { - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static string In(TimeSpan from) => DateTime.UtcNow.Add(from).ToString("o"); - - [Fact] - public async Task A_scheduled_post_waits_is_moved_and_is_dropped_by_its_owner_only() - { - var alice = await _host.Mastodon("alice"); - var mallory = await _host.Mastodon("mallory"); - - var scheduled = (await alice.Client.Post("/api/v1/statuses", ("status", "later, friends"), ("visibility", "unlisted"), - ("spoiler_text", "soon"), ("scheduled_at", In(TimeSpan.FromMinutes(30))))).Ok().Object; - var id = scheduled.Text("id"); - Assert.Null(scheduled["uri"]); - Assert.Equal("later, friends", scheduled["params"].Text("text")); - Assert.Equal("unlisted", scheduled["params"].Text("visibility")); - Assert.Equal("soon", scheduled["params"].Text("spoiler_text")); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); - Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Ids); - - var later = DateTime.UtcNow.AddHours(2); - var moved = (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", later.ToString("o")))).Ok().Object; - Assert.Equal(later.ToString("yyyy-MM-ddTHH:mm"), moved.Text("scheduled_at")[..16]); - Assert.Equal(HttpStatusCode.UnprocessableEntity, - (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", In(TimeSpan.FromMinutes(2))))).Status); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, - (await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("scheduled_at", In(TimeSpan.FromMinutes(1))))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, - (await alice.Client.Post("/api/v1/statuses", ("status", " "), ("scheduled_at", In(TimeSpan.FromHours(1))))).Status); - Assert.Empty((await mallory.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/scheduled_statuses/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/scheduled_statuses")).Status); - - (await alice.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); - await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id) - .Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token); - await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); - } - - [Fact] - public async Task A_scheduled_post_is_published_with_its_media_when_its_time_comes() - { - var alice = await _host.Mastodon("alice"); - var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg")); - var mediaId = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id"); - - var id = (await alice.Client.Post("/api/v1/statuses", ("status", "on time"), ("media_ids[]", mediaId), ("poll[options][]", "yes"), - ("poll[options][]", "no"), ("poll[expires_in]", "3600"), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id"); - Assert.Equal(id, (await DB.Default.Find().MatchID(mediaId).ExecuteFirstAsync(Token)).ScheduledStatusId); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "same media"), ("media_ids[]", mediaId), - ("scheduled_at", In(TimeSpan.FromMinutes(20))))).Status); - - // not due yet: its job leaves it waiting - Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token)); - Assert.Single((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); - - // its time comes (moved into the past, as the clock would have it) - await DB.Default.Update().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token); - await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.State, JobState.Pending).ExecuteAsync(Token); - await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); - - Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); - var published = Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array)!; - Assert.Contains("on time", published.Text("content")); - Assert.Equal(mediaId, Assert.Single(published["media_attachments"]!.AsArray()).Text("id")); - Assert.Equal(2, published["poll"]!["options"]!.AsArray().Count); - var media = await DB.Default.Find().MatchID(mediaId).ExecuteFirstAsync(Token); - Assert.Equal(published.Text("id"), media.PostId); - Assert.Null(media.ScheduledStatusId); - } - - async Task Picture(Mastodon account) - { - var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg")); - return (await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id"); - } - - // a scheduled post's media are its alone: a post now, or an edit, can't take them - [Fact] - public async Task A_scheduled_posts_media_cannot_be_taken_by_a_post_now() - { - var alice = await _host.Mastodon("alice"); - var mediaId = await Picture(alice); - (await alice.Client.Post("/api/v1/statuses", ("status", "later"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok(); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "now"), ("media_ids[]", mediaId))).Status); - var plain = (await alice.Client.Post("/api/v1/statuses", ("status", "plain"))).Ok().Body.Text("id"); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/statuses/{plain}", ("status", "edited"), ("media_ids[]", mediaId))).Status); - var media = await DB.Default.Find().OneAsync(mediaId, Token); - Assert.Null(media.PostId); - Assert.NotNull(media.ScheduledStatusId); - } - - // two posts asking for the same upload at once: exactly one gets it - [Fact] - public async Task Two_posts_racing_for_one_upload_never_both_get_it() - { - var alice = await _host.Mastodon("alice"); - for (var round = 0; round < 5; round++) - { - var mediaId = await Picture(alice); - var answers = await Task.WhenAll(Enumerable.Range(0, 2).Select(i => - alice.Client.Post("/api/v1/statuses", ("status", $"race {round} {i}"), ("media_ids[]", mediaId)))); - Assert.Single(answers, a => a.Status == HttpStatusCode.OK); - Assert.Single(answers, a => a.Status == HttpStatusCode.UnprocessableEntity); - } - } - - // a persona deleted since it scheduled a post publishes nothing, and what the post held is trashed - [Fact] - public async Task A_gone_personas_scheduled_post_never_publishes() - { - var gone = await _host.Mastodon($"gone{Guid.NewGuid():N}"[..12]); - var mediaId = await Picture(gone); - var id = (await gone.Client.Post("/api/v1/statuses", ("status", "from beyond"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id"); - await DB.Default.Update().MatchID(gone.Persona.Id).Modify(a => a.DeletionAt, DateTime.UtcNow).ExecuteAsync(Token); - - await DB.Default.Update().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token); - await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token); - await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); - - Assert.False(await DB.Default.Find().Match(p => p.GroupUserId == gone.Persona.Id && p.Text == "from beyond").ExecuteAnyAsync(Token)); - Assert.False(await DB.Default.Find().Match(s => s.ID == id).ExecuteAnyAsync(Token)); - Assert.NotNull((await DB.Default.Find().OneAsync(mediaId, Token)).TrashedAt); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonStatusesTests.cs b/PrivaPub.Tests/Http/MastodonStatusesTests.cs deleted file mode 100644 index 97a54ee..0000000 --- a/PrivaPub.Tests/Http/MastodonStatusesTests.cs +++ /dev/null @@ -1,728 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class MastodonStatusesTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static IEnumerable Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue()); - - static async Task Image(Mastodon account) => - (await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v1/media") - { - Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(80, 60), "image/jpeg", "a.jpg")) - })).Ok().Object; - - [Fact] - public async Task Each_visibility_is_addressed_the_way_mastodon_addresses_it() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var carol = new RemoteActor(_peer, "carol"); - await _host.FollowedBy(alice, bob); - _peer.WebFinger(carol); - var followers = alice.Uri + "/groupies"; - - var open = await alice.Status("for everyone"); - var unlisted = await alice.Status("quietly public", ("visibility", "unlisted")); - var quiet = await alice.Status("for followers", ("visibility", "private")); - var direct = await alice.Status($"@{carol.Handle()} just you", ("visibility", "direct")); - - Assert.Equal(new[] { "public", "unlisted", "private", "direct" }, new[] { open, unlisted, quiet, direct }.Select(s => s.Text("visibility"))); - var creates = (await bob.Delivered(since)).Where(d => d.Type() == "Create").ToDictionary(d => d["object"].Text("id")); - Assert.Equal(3, creates.Count); - var publicNote = creates[open.Text("uri")]["object"]; - Assert.Equal(new[] { MastodonHelpers.Public }, Strings(publicNote["to"])); - Assert.Equal(new[] { followers }, Strings(publicNote["cc"])); - var unlistedNote = creates[unlisted.Text("uri")]["object"]; - Assert.Equal(new[] { followers }, Strings(unlistedNote["to"])); - Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"])); - var quietNote = creates[quiet.Text("uri")]["object"]; - Assert.Equal(new[] { followers }, Strings(quietNote["to"])); - // named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message - Assert.Equal(new[] { followers }, Strings(quietNote["cc"])); - var whisper = Assert.Single(await carol.Delivered(since)); - Assert.Equal(direct.Text("uri"), whisper["object"].Text("id")); - Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"])); - Assert.Empty(whisper["object"]!["cc"]!.AsArray()); - Assert.Equal(carol.Handle(), Assert.Single(direct["mentions"]!.AsArray()).Text("acct")); - } - - [Fact] - public async Task A_content_warning_is_kept_as_spoiler_text_and_federates_as_a_summary() - { - var alice = await _host.Mastodon("alice"); - - var warned = await alice.Status("the butler did it", ("spoiler_text", "spoilers")); - - Assert.True(warned.Flag("sensitive")); - Assert.Equal("spoilers", warned.Text("spoiler_text")); - var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{warned.Text("id")}")).Ok().Body; - Assert.Equal("spoilers", note.Text("summary")); - Assert.True(note.Flag("sensitive")); - Assert.Contains("the butler did it", note.Text("content")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", " "))).Status); - } - - [Fact] - public async Task A_reply_to_a_local_post_threads_counts_and_shows_in_the_context() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var root = await alice.Status("what do you think?"); - - var reply = await bob.Status("I agree", ("in_reply_to_id", root.Text("id"))); - var nested = await alice.Status("thanks", ("in_reply_to_id", reply.Text("id"))); - - Assert.Equal(root.Text("id"), reply.Text("in_reply_to_id")); - Assert.Equal(alice.Id, reply.Text("in_reply_to_account_id")); - Assert.Equal(1, (await bob.Client.Get($"/api/v1/statuses/{root.Text("id")}")).Ok().Body.Number("replies_count")); - var context = (await _host.Client().Get($"/api/v1/statuses/{reply.Text("id")}/context")).Ok(); - Assert.Equal(new[] { root.Text("id") }, context.Body["ancestors"]!.AsArray().Select(s => s.Text("id"))); - Assert.Equal(new[] { nested.Text("id") }, context.Body["descendants"]!.AsArray().Select(s => s.Text("id"))); - var whole = (await alice.Client.Get($"/api/v1/statuses/{root.Text("id")}/context")).Ok(); - Assert.Equal(new[] { reply.Text("id"), nested.Text("id") }, whole.Body["descendants"]!.AsArray().Select(s => s.Text("id"))); - Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Post("/api/v1/statuses", ("status", "lost"), ("in_reply_to_id", "000000000000000000000000"))).Status); - } - - [Fact] - public async Task A_reply_to_a_remote_post_mentions_its_author_and_reaches_its_inbox() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - _peer.WebFinger(bob); - var parent = await _host.PublicPostFrom(bob, alice); - var bobId = (await _host.Known(bob)).ID; - - var reply = await alice.Status($"@{bob.Handle()} well said", ("in_reply_to_id", parent.ID)); - - Assert.Equal(parent.ID, reply.Text("in_reply_to_id")); - Assert.Equal(bobId, reply.Text("in_reply_to_account_id")); - var mention = Assert.Single(reply["mentions"]!.AsArray()); - Assert.Equal((bobId, bob.Handle()), (mention.Text("id"), mention.Text("acct"))); - var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create"); - var note = create["object"]!; - Assert.Equal(parent.ObjectURI, note.Text("inReplyTo")); - Assert.Contains(bob.Id, Strings(note["cc"])); - Assert.Contains(note["tag"]!.AsArray(), t => t.Text("type") == "Mention" && t.Text("href") == bob.Id); - Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count")); - } - - // a remote thread opened by a persona has its replies read from its server (here its FEP-7888 context), at most hourly - [Fact] - public async Task Opening_a_remote_thread_reads_the_replies_that_never_reached_us() - { - var token = TestContext.Current.CancellationToken; - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var carol = new RemoteActor(_peer, "carol"); - var context = $"{bob.Origin()}/contexts/{Guid.NewGuid():N}"; - var post = await _host.PublicPostFrom(bob, alice, shape: note => note["context"] = context); - var answer = carol.Create("

said elsewhere

", new[] { MastodonHelpers.Public }, new[] { carol.Id + "/followers" }, - note => note["inReplyTo"] = post.ObjectURI)["object"]!; - var answerId = answer["id"]!.GetValue(); - _peer.Serve(new Uri(answerId).AbsolutePath, answer.ToJsonString()); - _peer.Serve(new Uri(context).AbsolutePath, new JsonObject - { - ["id"] = context, ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(post.ObjectURI, answerId) - }.ToJsonString()); - async Task Queued() => (await DB.Default.Find().Match(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID)).ExecuteAsync(token)).Count; - - (await _host.Client().Get($"/api/v1/statuses/{post.ID}/context")).Ok(); - Assert.Equal(0, await Queued()); - Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray()); - Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID), token)); - - var descendants = (await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray(); - Assert.Equal(answerId, Assert.Single(descendants).Text("uri")); - Assert.Equal(1, await Queued()); - } - - // Akkoma marks a post with sensitive media "sensitive" and leaves its summary empty: the media is hidden, the words are - // not, and no warning is made up for them (found by decePub's e2e tests on the town) - [Fact] - public async Task A_remote_post_sensitive_without_a_summary_hides_its_media_and_not_its_words() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - _peer.WebFinger(bob); - var post = await _host.PublicPostFrom(bob, alice, shape: note => { note["sensitive"] = true; note["summary"] = ""; }); - - var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body; - - Assert.True(status["sensitive"]!.GetValue()); - Assert.Equal("", status.Text("spoiler_text")); - } - - // a status asked for by id is shown even when the viewer blocked or muted its author, as Mastodon shows it: only lists - // leave such posts out (it once answered 200 with null, and decePub's thread page showed nothing) - [Fact] - public async Task A_status_of_an_author_the_viewer_blocked_is_still_shown_by_its_id() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - _peer.WebFinger(bob); - var post = await _host.PublicPostFrom(bob, alice); - var bobId = (await _host.Known(bob)).ID; - (await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok(); - - var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body; - - Assert.Equal(post.ID, status.Text("id")); - } - - // a reader who deletes the @name decePub prefills still answers its author: GoToSocial dropped such a reply, addressed - // to nobody there (found by decePub's e2e tests on the town) - [Fact] - public async Task A_reply_without_the_authors_name_still_names_the_author_in_cc() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - _peer.WebFinger(bob); - var parent = await _host.PublicPostFrom(bob, alice); - - var reply = await alice.Status("no name in this one", ("in_reply_to_id", parent.ID)); - - Assert.Empty(reply["mentions"]!.AsArray()); - var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create"); - Assert.Contains(bob.Id, Strings(create["object"]!["cc"])); - } - - [Fact] - public async Task A_poll_is_created_validated_and_federated_as_a_question() - { - var alice = await _host.Mastodon("alice"); - - var status = await alice.Status("tea or coffee?", ("poll[options][]", "tea"), ("poll[options][]", "coffee"), ("poll[expires_in]", "600"), - ("poll[multiple]", "true")); - - var poll = status["poll"]!; - Assert.Equal(new[] { "tea", "coffee" }, poll["options"]!.AsArray().Select(o => o.Text("title"))); - Assert.True(poll.Flag("multiple")); - Assert.False(poll.Flag("expired")); - Assert.NotNull(poll.Text("expires_at")); - Assert.Equal(status.Text("id"), (await _host.Client().Get($"/api/v1/polls/{status.Text("id")}")).Ok().Body.Text("id")); - var question = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body; - Assert.Equal("Question", question.Text("type")); - Assert.Equal(2, question["anyOf"]!.AsArray().Count); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "one?"), ("poll[options][]", "only"), - ("poll[expires_in]", "600"))).Status); - } - - [Fact] - public async Task Media_is_attached_only_by_its_owner() - { - var alice = await _host.Mastodon("alice"); - var mallory = await _host.Mastodon("mallory"); - var upload = await Image(alice); - - var status = await alice.Status("look", ("media_ids[]", upload.Text("id"))); - - var attachment = Assert.Single(status["media_attachments"]!.AsArray()); - Assert.Equal(upload.Text("id"), attachment.Text("id")); - Assert.Equal("image", attachment.Text("type")); - var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body; - Assert.Equal(upload.Text("url"), Assert.Single(note["attachment"]!.AsArray()).Text("url")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await mallory.Client.Post("/api/v1/statuses", ("status", "mine"), ("media_ids[]", (await Image(alice)).Text("id")))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "again"), ("media_ids[]", upload.Text("id")))).Status); - } - - [Fact] - public async Task A_quote_of_a_local_post_is_accepted_listed_and_revocable() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var original = await carol.Status("quote me"); - var closed = await carol.Status("do not quote me", ("quote_approval_policy", "nobody")); - - var quote = await alice.Status("so true", ("quoted_status_id", original.Text("id"))); - - Assert.Equal("accepted", quote["quote"].Text("state")); - Assert.Equal(original.Text("id"), quote["quote"]!["quoted_status"].Text("id")); - Assert.Equal(1, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count")); - Assert.Equal(new[] { quote.Text("id") }, (await _host.Client().Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Ids); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "anyway"), ("quoted_status_id", closed.Text("id")))).Status); - Assert.Empty(closed["quote_approval"]!["automatic"]!.AsArray()); - - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Status); - var revoked = (await carol.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Ok(); - - Assert.Equal(quote.Text("id"), revoked.Body.Text("id")); - Assert.Equal("revoked", revoked.Body["quote"].Text("state")); - Assert.Empty((await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Array); - Assert.Equal(0, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count")); - } - - [Fact] - public async Task An_edit_keeps_its_history_shows_its_source_and_federates_an_update() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var other = await _host.Mastodon("other"); - var bob = new RemoteActor(_peer, "bob"); - await _host.FollowedBy(alice, bob); - var status = await alice.Status("first version", ("spoiler_text", "cw")); - var id = status.Text("id"); - - var edited = (await alice.Client.Put($"/api/v1/statuses/{id}", ("status", "second version"))).Ok(); - - Assert.NotNull(edited.Body.Text("edited_at")); - Assert.Contains("second version", edited.Body.Text("content")); - Assert.Contains("second version", (await other.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Text("content")); - var history = (await _host.Client().Get($"/api/v1/statuses/{id}/history")).Ok().Array; - Assert.Equal(2, history.Count); - Assert.Contains("first version", history[0].Text("content")); - Assert.Equal("cw", history[0].Text("spoiler_text")); - Assert.Contains("second version", history[1].Text("content")); - Assert.Equal(alice.Id, history[1]!["account"].Text("id")); - var source = (await alice.Client.Get($"/api/v1/statuses/{id}/source")).Ok(); - Assert.Equal(("second version", ""), (source.Body.Text("text"), source.Body.Text("spoiler_text"))); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get($"/api/v1/statuses/{id}/source")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/statuses/{id}", ("status", "hijacked"))).Status); - var update = Assert.Single(await bob.Delivered(since), d => d.Type() == "Update"); - Assert.Equal(status.Text("uri"), update["object"].Text("id")); - Assert.Contains("second version", update["object"].Text("content")); - } - - [Fact] - public async Task Delete_returns_the_text_for_a_redraft_and_leaves_a_tombstone() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var other = await _host.Mastodon("other"); - var bob = new RemoteActor(_peer, "bob"); - await _host.FollowedBy(alice, bob); - var status = await alice.Status("regrettable", ("spoiler_text", "hot take")); - var id = status.Text("id"); - var scribble = $"/peasants/{alice.UserName}/scribbles/{id}"; - Assert.Equal(HttpStatusCode.OK, (await _host.ActivityPub(scribble)).Status); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Delete($"/api/v1/statuses/{id}")).Status); - - var deleted = (await alice.Client.Delete($"/api/v1/statuses/{id}")).Ok(); - - Assert.Equal(id, deleted.Body.Text("id")); - Assert.Equal("regrettable", deleted.Body.Text("text")); - Assert.Equal("hot take", deleted.Body.Text("spoiler_text")); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Delete($"/api/v1/statuses/{id}")).Status); - var tombstone = await _host.ActivityPub(scribble); - Assert.Equal(HttpStatusCode.Gone, tombstone.Status); - Assert.Equal("Tombstone", tombstone.Body.Text("type")); - Assert.Equal(status.Text("uri"), tombstone.Body.Text("id")); - var delete = Assert.Single(await bob.Delivered(since), d => d.Type() == "Delete"); - Assert.Equal(status.Text("uri"), delete["object"].Text("id")); - } - - [Fact] - public async Task Favourites_and_reblogs_count_and_federate_with_their_undos() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var bob = new RemoteActor(_peer, "bob"); - var remote = await _host.PublicPostFrom(bob, alice); - var path = $"/api/v1/statuses/{remote.ID}"; - - var liked = (await alice.Client.Post(path + "/favourite")).Ok(); - Assert.True(liked.Body.Flag("favourited")); - Assert.Equal(1, liked.Body.Number("favourites_count")); - Assert.Equal(new[] { alice.Id }, (await _host.Client().Get(path + "/favourited_by")).Ok().Ids); - Assert.Equal(1, (await alice.Client.Post(path + "/favourite")).Ok().Body.Number("favourites_count")); - var unliked = (await alice.Client.Post(path + "/unfavourite")).Ok(); - Assert.False(unliked.Body.Flag("favourited")); - Assert.Equal(0, unliked.Body.Number("favourites_count")); - - var reblog = (await alice.Client.Post(path + "/reblog")).Ok(); - Assert.Equal(remote.ID, reblog.Body["reblog"].Text("id")); - Assert.True(reblog.Body["reblog"].Flag("reblogged")); - Assert.Equal(1, reblog.Body["reblog"].Number("reblogs_count")); - Assert.Equal(new[] { alice.Id }, (await carol.Client.Get(path + "/reblogged_by")).Ok().Ids); - var unreblogged = (await alice.Client.Post(path + "/unreblog")).Ok(); - Assert.False(unreblogged.Body.Flag("reblogged")); - Assert.Equal(0, unreblogged.Body.Number("reblogs_count")); - Assert.Empty((await carol.Client.Get(path + "/reblogged_by")).Ok().Array); - - var sent = await bob.Delivered(since); - var like = Assert.Single(sent, d => d.Type() == "Like"); - Assert.Equal((alice.Uri, remote.ObjectURI), (like.Text("actor"), like.Text("object"))); - var announce = Assert.Single(sent, d => d.Type() == "Announce"); - Assert.Equal(remote.ObjectURI, announce.Text("object")); - var undos = sent.Where(d => d.Type() == "Undo").ToList(); - Assert.Equal(new[] { "Announce", "Like" }, undos.Select(u => u["object"].Text("type")).Order()); - Assert.Equal(like.Text("id"), undos.Single(u => u["object"].Text("type") == "Like")["object"].Text("id")); - - var local = await carol.Status("a local one"); - (await alice.Client.Post($"/api/v1/statuses/{local.Text("id")}/favourite")).Ok(); - var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array); - Assert.Equal((alice.Id, local.Text("id")), (notification["account"].Text("id"), notification["status"].Text("id"))); - var quiet = await carol.Status("followers only", ("visibility", "private")); - (await alice.Client.Post($"/api/v1/accounts/{carol.Id}/follow")).Ok(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/reblog")).Status); - Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited")); - } - - // a downvote is a Dislike, one vote a post: a downvote replaces the favourite and a favourite the downvote, each by its - // own activity alone; a vote on a post in a community goes to the community, which counts it, and to its author only - // when on another server - [Fact] - public async Task A_downvote_replaces_the_favourite_and_votes_reach_the_posts_community() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob", origin: _peer.B); - var community = new RemoteActor(_peer, "pies", type: "Group"); - await _host.Get().GetActor(community.Id, refresh: true, TestContext.Current.CancellationToken); - var remote = await _host.PublicPostFrom(bob, alice, shape: note => note["audience"] = community.Id); - var neighbour = new RemoteActor(_peer, "neighbour"); - var nearby = await _host.PublicPostFrom(neighbour, alice, shape: note => note["audience"] = community.Id); - (await alice.Client.Post($"/api/v1/statuses/{nearby.ID}/favourite")).Ok(); - Assert.Empty(await neighbour.Delivered(since)); - var path = $"/api/v1/statuses/{remote.ID}"; - - (await alice.Client.Post(path + "/favourite")).Ok(); - var down = (await alice.Client.Post(path + "/downvote")).Ok().Body; - Assert.False(down.Flag("favourited")); - Assert.Equal((0, 1, true), (down["privapub"]!["votes"].Number("up"), down["privapub"]!["votes"].Number("down"), down["privapub"]!["votes"].Flag("downvoted"))); - Assert.Equal(1, (await alice.Client.Post(path + "/downvote")).Ok().Body["privapub"]!["votes"].Number("down")); - var up = (await alice.Client.Post(path + "/favourite")).Ok().Body; - Assert.Equal((true, 0, false), (up.Flag("favourited"), up["privapub"]!["votes"].Number("down"), up["privapub"]!["votes"].Flag("downvoted"))); - Assert.Null((await _host.Client().Get(path)).Ok().Body["privapub"]!["votes"]!["downvoted"]); - (await alice.Client.Post(path + "/downvote")).Ok(); - var none = (await alice.Client.Post(path + "/undownvote")).Ok().Body; - Assert.Equal((false, 0, 0), (none.Flag("favourited"), none.Number("favourites_count"), none["privapub"]!["votes"].Number("down"))); - - foreach (var inbox in new[] { bob, community }) - { - var sent = (await inbox.Delivered(since)).Where(d => (d.Type() == "Undo" ? d["object"].Text("object") : d.Text("object")) == remote.ObjectURI).ToList(); - Assert.Equal(new[] { "Like", "Dislike", "Like", "Dislike", "Undo:Dislike" }, - sent.Select(d => d.Type() == "Undo" ? "Undo:" + d["object"].Text("type") : d.Type())); - var dislike = sent.Last(d => d.Type() == "Dislike"); - Assert.Equal((alice.Uri, remote.ObjectURI), (dislike.Text("actor"), dislike.Text("object"))); - Assert.Equal(dislike.Text("id"), sent.Single(d => d.Type() == "Undo")["object"].Text("id")); - } - } - - [Fact] - public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var remote = await _host.PublicPostFrom(bob, alice); - var path = $"/api/v1/statuses/{remote.ID}"; - - foreach (var (on, off) in new[] { ("favourite", "unfavourite"), ("reblog", "unreblog") }) - { - (await alice.Client.Post($"{path}/{on}")).Ok(); - (await alice.Client.Post($"{path}/{off}")).Ok(); - (await alice.Client.Post($"{path}/{on}")).Ok(); - } - - var sent = await bob.Delivered(since); - foreach (var type in new[] { "Like", "Announce" }) - { - var given = sent.Where(d => d.Type() == type).ToList(); - Assert.Equal(2, given.Count); - Assert.NotEqual(given[0].Text("id"), given[1].Text("id")); - var undo = Assert.Single(sent, d => d.Type() == "Undo" && d["object"].Text("type") == type); - Assert.Equal(given[0].Text("id"), undo["object"].Text("id")); - } - var status = (await alice.Client.Get(path)).Ok().Body; - Assert.True(status.Flag("favourited")); - Assert.True(status.Flag("reblogged")); - Assert.Equal(1, status.Number("reblogs_count")); - } - - [Fact] - public async Task A_boost_reaches_the_authors_server_once_through_its_shared_inbox_and_a_like_its_own_inbox() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var remote = await _host.PublicPostFrom(bob, alice); - await DB.Default.Update().Match(f => f.ActorURI == bob.Id).Modify(f => f.SharedInboxURL, bob.SharedInbox) - .ExecuteAsync(TestContext.Current.CancellationToken); - - (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok(); - (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/unreblog")).Ok(); - (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/favourite")).Ok(); - - var shared = await Jobs.Deliveries(bob.SharedInbox, since, TestContext.Current.CancellationToken); - Assert.Single(shared, d => d.Type() == "Announce"); - Assert.Single(shared, d => d.Type() == "Undo"); - var own = await bob.Delivered(since); - Assert.DoesNotContain(own, d => d.Type() is "Announce" or "Undo"); - Assert.Single(own, d => d.Type() == "Like"); - } - - [Fact] - public async Task Bookmarks_are_kept_listed_and_removed() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var first = await carol.Status("worth keeping"); - var second = await carol.Status("this too"); - - Assert.True((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/bookmark")).Ok().Body.Flag("bookmarked")); - (await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok(); - (await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok(); - - Assert.Equal(new[] { second.Text("id"), first.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids); - Assert.Empty((await carol.Client.Get("/api/v1/bookmarks")).Ok().Array); - var page = (await alice.Client.Get("/api/v1/bookmarks?limit=1")).Ok(); - Assert.Equal(new[] { second.Text("id") }, page.Ids); - Assert.Equal(new[] { first.Text("id") }, (await alice.Client.Get(page.Link("next"))).Ok().Ids); - Assert.False((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/unbookmark")).Ok().Body.Flag("bookmarked")); - Assert.Equal(new[] { second.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/statuses/000000000000000000000000/bookmark")).Status); - } - - [Fact] - public async Task Pins_take_only_the_owners_public_posts() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var mine = await alice.Status("pin me"); - var quiet = await alice.Status("not this", ("visibility", "private")); - var theirs = await carol.Status("not yours"); - - Assert.True((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/pin")).Ok().Body.Flag("pinned")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{theirs.Text("id")}/pin")).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status); - var featured = (await _host.ActivityPub($"/peasants/{alice.UserName}/trophies")).Ok().Body; - Assert.Equal(mine.Text("uri"), Assert.Single(featured["orderedItems"]!.AsArray()).Text("id")); - - for (var i = 0; i < 4; i++) - (await alice.Client.Post($"/api/v1/statuses/{(await alice.Status($"more {i}")).Text("id")}/pin")).Ok(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{(await alice.Status("one too many")).Text("id")}/pin")).Status); - - Assert.False((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/unpin")).Ok().Body.Flag("pinned")); - Assert.Equal(4, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses?pinned=true")).Ok().Array.Count); - } - - [Fact] - public async Task The_interaction_policy_sets_who_may_quote_and_matches_the_activitypub_note() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - await _host.FollowedBy(alice, bob); - var open = await alice.Status("quotable"); - var quiet = await alice.Status("followers only", ("visibility", "private")); - var scribble = $"/peasants/{alice.UserName}/scribbles/{open.Text("id")}"; - string CanQuote(JsonNode note) => Assert.Single(note["interactionPolicy"]!["canQuote"]!["automaticApproval"]!.AsArray())!.GetValue(); - - Assert.Equal(new[] { "public" }, Strings(open["quote_approval"]!["automatic"])); - Assert.Equal(MastodonHelpers.Public, CanQuote((await _host.ActivityPub(scribble)).Ok().Body)); - - var followers = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "followers"))).Ok(); - Assert.Equal(new[] { "followers" }, Strings(followers.Body["quote_approval"]!["automatic"])); - Assert.Equal(alice.Uri + "/groupies", CanQuote((await _host.ActivityPub(scribble)).Ok().Body)); - - var nobody = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "nobody"))).Ok(); - Assert.Empty(nobody.Body["quote_approval"]!["automatic"]!.AsArray()); - Assert.Equal(alice.Uri, CanQuote((await _host.ActivityPub(scribble)).Ok().Body)); - - var forced = (await alice.Client.Put($"/api/v1/statuses/{quiet.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Ok(); - Assert.Empty(forced.Body["quote_approval"]!["automatic"]!.AsArray()); - var updates = (await bob.Delivered(since)).Where(d => d.Type() == "Update").ToList(); - Assert.Equal(3, updates.Count); - Assert.Equal(alice.Uri, CanQuote(updates.Single(u => u["object"].Text("id") == quiet.Text("uri"))["object"])); - Assert.Equal(HttpStatusCode.UnprocessableEntity, - (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "everyone"))).Status); - Assert.Equal(HttpStatusCode.NotFound, - (await (await _host.Mastodon("other")).Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Status); - } - - [Fact] - public async Task A_stranger_gets_404_for_followers_only_and_direct_posts() - { - var alice = await _host.Mastodon("alice"); - var follower = await _host.Mastodon("follower"); - var friend = await _host.Mastodon("friend"); - var stranger = await _host.Mastodon("stranger"); - (await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - var quiet = await alice.Status("followers only", ("visibility", "private")); - var direct = await alice.Status($"@{friend.UserName} only you", ("visibility", "direct")); - var anonymous = _host.Client(); - - foreach (var id in new[] { quiet.Text("id"), direct.Text("id") }) - { - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}/context")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/favourite")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/bookmark")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}/favourited_by")).Status); - } - Assert.Empty((await stranger.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Array); - Assert.Equal(new[] { quiet.Text("id") }, (await follower.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids); - Assert.Equal(new[] { direct.Text("id") }, (await friend.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids); - Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}/context")).Status); - Assert.Equal(quiet.Text("id"), (await follower.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", quiet.Text("id")))).Ok().Body.Text("in_reply_to_id")); - Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{quiet.Text("id")}")).Status); - } - - [Fact] - public async Task A_located_post_is_never_reachable_by_id_by_anyone_else() - { - var alice = await _host.Mastodon("alice"); - var other = await _host.Mastodon("other"); - var id = await _host.Located(alice, "the bench by the river"); - var anonymous = _host.Client(); - - foreach (var client in new[] { other.Client, anonymous }) - { - foreach (var path in new[] { "", "/context", "/history", "/favourited_by", "/reblogged_by", "/quotes" }) - Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/statuses/{id}{path}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/privapub/v1/statuses/{id}/provenance")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/pleroma/statuses/{id}/reactions")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/polls/{id}")).Status); - Assert.Empty((await client.Get($"/api/v1/statuses?id[]={id}")).Ok().Array); - Assert.DoesNotContain(id, (await client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Ids); - } - foreach (var action in new[] { "favourite", "reblog", "bookmark", "pin" }) - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post($"/api/v1/statuses/{id}/{action}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/pleroma/statuses/{id}/reactions/%F0%9F%91%8D")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "qt"), ("quoted_status_id", id))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{id}")).Status); - } - - [Fact] - public async Task Creating_honours_the_idempotency_key_the_scopes_and_needs_a_recipient_for_a_direct_message() - { - var alice = await _host.Mastodon("alice"); - var reader = await _host.Mastodon("reader", scopes: "read"); - var key = Guid.NewGuid().ToString("N"); - Task Keyed(string text) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary { ["status"] = text }) }; - request.Headers.Add("Idempotency-Key", key); - return alice.Client.Exchange(request); - } - - var first = (await Keyed("once")).Ok(); - var second = (await Keyed("once again")).Ok(); - - Assert.Equal(first.Body.Text("id"), second.Body.Text("id")); - Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); - var noRecipient = await alice.Client.Post("/api/v1/statuses", ("status", "to nobody"), ("visibility", "direct")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, noRecipient.Status); - Assert.Contains("recipient", noRecipient.Body.Text("error")); - Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post("/api/v1/statuses", ("status", "read only"))).Status); - Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post($"/api/v1/statuses/{first.Body.Text("id")}/favourite")).Status); - Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get($"/api/v1/statuses/{first.Body.Text("id")}")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post("/api/v1/statuses", ("status", "anonymous"))).Status); - } - - [Fact] - public async Task Local_polls_take_one_vote_per_persona_and_never_from_their_author() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var poll = await alice.Status("pick one", ("poll[options][]", "red"), ("poll[options][]", "blue"), ("poll[expires_in]", "3600")); - var path = $"/api/v1/polls/{poll.Text("id")}"; - - var voted = (await carol.Client.Post(path + "/votes", ("choices[]", "1"))).Ok(); - - Assert.True(voted.Body.Flag("voted")); - Assert.Equal(new[] { 1 }, voted.Body["own_votes"]!.AsArray().Select(v => v!.GetValue())); - Assert.Equal(1, voted.Body.Number("votes_count")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await carol.Client.Post(path + "/votes", ("choices[]", "0"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post(path + "/votes", ("choices[]", "0"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("dave")).Client.Post(path + "/votes", ("choices[]", "0"), ("choices[]", "1"))).Status); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("erin")).Client.Post(path + "/votes", ("choices[]", "7"))).Status); - var anonymous = (await _host.Client().Get(path)).Ok(); - Assert.Null(anonymous.Body["voted"]); - Assert.Equal(new[] { 0, 1 }, anonymous.Body["options"]!.AsArray().Select(o => o.Number("votes_count"))); - } - - [Fact] - public async Task A_vote_on_a_remote_poll_goes_only_to_its_author_without_a_published_date() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var carol = new RemoteActor(_peer, "carol"); - await _host.FollowedBy(alice, carol); - var question = await _host.PublicPostFrom(bob, alice, "

which?

", note => - { - note["type"] = "Question"; - note["oneOf"] = new JsonArray( - new JsonObject { ["type"] = "Note", ["name"] = "this", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } }, - new JsonObject { ["type"] = "Note", ["name"] = "that", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } }); - note["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"); - }); - - var voted = (await alice.Client.Post($"/api/v1/polls/{question.ID}/votes", ("choices[]", "1"))).Ok(); - - Assert.True(voted.Body.Flag("voted")); - var vote = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create"); - Assert.Equal("that", vote["object"].Text("name")); - Assert.Equal(question.ObjectURI, vote["object"].Text("inReplyTo")); - Assert.Null(vote["object"]!["published"]); - Assert.Equal(new[] { bob.Id }, Strings(vote["object"]!["to"])); - Assert.DoesNotContain(await carol.Delivered(since), d => d.Type() == "Create"); - } - - [Fact] - public async Task Deleting_a_reblog_undoes_it() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var remote = await _host.PublicPostFrom(bob, alice); - var reblog = (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok().Body; - - (await alice.Client.Delete($"/api/v1/statuses/{reblog.Text("id")}")).Ok(); - - Assert.False((await alice.Client.Get($"/api/v1/statuses/{remote.ID}")).Ok().Body.Flag("reblogged")); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{reblog.Text("id")}")).Status); - var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo"); - Assert.Equal("Announce", undo["object"].Text("type")); - } - - [Fact] - public async Task Many_statuses_are_fetched_by_id_in_one_call() - { - var alice = await _host.Mastodon("alice"); - var first = await alice.Status("one"); - var second = await alice.Status("two"); - - var answer = (await _host.Client().Get($"/api/v1/statuses?id[]={first.Text("id")}&id[]={second.Text("id")}&id[]=junk")).Ok(); - - Assert.Equal(new[] { first.Text("id"), second.Text("id") }, answer.Ids.Order()); - Assert.Empty((await _host.Client().Get("/api/v1/statuses")).Ok().Array); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonStreamingTests.cs b/PrivaPub.Tests/Http/MastodonStreamingTests.cs deleted file mode 100644 index 2dd0011..0000000 --- a/PrivaPub.Tests/Http/MastodonStreamingTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Net.WebSockets; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Mastodon's streaming API: a WebSocket whose streams are subscribed by message or in the URL, and server-sent events - // per stream; each event mapped for its persona, never what it may not see - [Trait("Category", "Integration")] - public sealed class MastodonStreamingTests : IAsyncLifetime - { - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task Connect(Mastodon account, string query = "") - { - var client = _host.Server.CreateWebSocketClient(); - var uri = new Uri($"ws://{PrivaPubHost.Host}/api/v1/streaming?access_token={account.Token}{query}"); - var socket = await client.ConnectAsync(uri, Token); - // the subscription is made as the socket opens: give the server a moment to hear it - await Task.Delay(200, Token); - return socket; - } - - static async Task Say(WebSocket socket, JsonObject message) => - await socket.SendAsync(Encoding.UTF8.GetBytes(message.ToJsonString()), WebSocketMessageType.Text, true, Token); - - // the next message for which `wanted` holds, within a few seconds, or null - static async Task Next(WebSocket socket, Func wanted, int seconds = 5) - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token); - timeout.CancelAfter(TimeSpan.FromSeconds(seconds)); - var buffer = new byte[65536]; - try - { - while (true) - { - var text = new StringBuilder(); - WebSocketReceiveResult received; - do - { - received = await socket.ReceiveAsync(buffer, timeout.Token); - text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count)); - } - while (!received.EndOfMessage); - if (JsonNode.Parse(text.ToString()) is JsonObject message && wanted(message)) - return message; - } - } - catch (OperationCanceledException) - { - return null; - } - } - - static string Id(JsonObject message) => JsonNode.Parse(message["payload"]!.GetValue())!["id"]!.GetValue(); - - [Fact] - public async Task Health_is_answered_and_a_stream_needs_a_persona() - { - var health = await _host.Client().GetAsync("/api/v1/streaming/health", Token); - Assert.Equal("OK", await health.Content.ReadAsStringAsync(Token)); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().GetAsync("/api/v1/streaming/public", Token)).StatusCode); - var instance = (await _host.Client().Get("/api/v2/instance")).Ok().Body; - Assert.StartsWith("ws", instance["configuration"]!["urls"]!["streaming"]!.GetValue()); - } - - [Fact] - public async Task The_user_stream_tells_home_posts_notifications_edits_and_deletions() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var carol = await _host.Mastodon("carol"); - var stranger = await _host.Mastodon("stranger"); - (await alice.Client.Post($"/api/v1/accounts/{bob.Id}/follow")).Ok(); - using var socket = await Connect(alice, "&stream=user"); - using var elsewhere = await Connect(stranger, "&stream=user"); - - var post = (await bob.Status("streamed to alice")).Text("id"); - var update = await Next(socket, m => m.Text("event") == "update"); - Assert.NotNull(update); - Assert.Equal(new[] { "user" }, update!["stream"]!.AsArray().Select(s => s!.GetValue())); - Assert.Equal(post, Id(update)); - - await carol.Status($"@{alice.UserName} hello there", ("visibility", "direct")); - var notification = await Next(socket, m => m.Text("event") == "notification"); - Assert.Equal("mention", JsonNode.Parse(notification!["payload"]!.GetValue())!.Text("type")); - - (await bob.Client.Put($"/api/v1/statuses/{post}", ("status", "streamed, then edited"))).Ok(); - var edited = await Next(socket, m => m.Text("event") == "status.update"); - Assert.Contains("then edited", JsonNode.Parse(edited!["payload"]!.GetValue())!.Text("content")); - - (await bob.Client.Delete($"/api/v1/statuses/{post}")).Ok(); - Assert.NotNull(await Next(socket, m => m.Text("event") == "delete" && m["payload"]!.GetValue() == post)); - // a persona whose home never held it hears nothing of it, not even its id - Assert.Null(await Next(elsewhere, m => m.Text("event") is "update" or "delete" && m["payload"]!.GetValue().Contains(post), seconds: 2)); - } - - [Fact] - public async Task A_hashtag_stream_subscribed_by_message_tells_public_posts_only_and_never_a_blocked_authors() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var dave = await _host.Mastodon("dave"); - (await alice.Client.Post($"/api/v1/accounts/{dave.Id}/block")).Ok(); - var tag = $"st{Guid.NewGuid():N}"[..12]; - using var socket = await Connect(alice); - await Say(socket, new JsonObject { ["type"] = "subscribe", ["stream"] = "hashtag", ["tag"] = tag }); - await Task.Delay(200, Token); - - await dave.Status($"blocked #{tag}"); - await bob.Status($"quiet #{tag}", ("visibility", "unlisted")); - var open = (await bob.Status($"open #{tag}")).Text("id"); - var told = await Next(socket, m => m.Text("event") == "update"); - Assert.Equal(open, Id(told!)); - Assert.Equal(new[] { "hashtag", tag }, told["stream"]!.AsArray().Select(s => s!.GetValue())); - Assert.Null(await Next(socket, m => m.Text("event") == "update", seconds: 2)); - - await Say(socket, new JsonObject { ["type"] = "unsubscribe", ["stream"] = "hashtag", ["tag"] = tag }); - await Task.Delay(200, Token); - await bob.Status($"again #{tag}"); - Assert.Null(await Next(socket, m => m.Text("event") == "update", seconds: 2)); - - await Say(socket, new JsonObject { ["type"] = "subscribe", ["stream"] = "list", ["list"] = "000000000000000000000000" }); - Assert.Equal("Unknown list", (await Next(socket, m => m["error"] != null))!.Text("error")); - } - - [Fact] - public async Task Server_sent_events_tell_a_personas_notifications() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/streaming/user/notification"); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", alice.Token); - using var response = await _host.Client().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Token); - Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); - using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(Token)); - Assert.Equal(":)", await reader.ReadLineAsync(Token)); - - (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token); - timeout.CancelAfter(TimeSpan.FromSeconds(5)); - string line; - while ((line = await reader.ReadLineAsync(timeout.Token)) != null && line != "event: notification") - { - } - Assert.Equal("event: notification", line); - var data = await reader.ReadLineAsync(timeout.Token); - Assert.Equal("follow", JsonNode.Parse(data!["data: ".Length..])!.Text("type")); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonTagsTests.cs b/PrivaPub.Tests/Http/MastodonTagsTests.cs deleted file mode 100644 index ad558f1..0000000 --- a/PrivaPub.Tests/Http/MastodonTagsTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Hashtags: a tag's week of use in public posts, and followed tags, whose public posts come to the persona's home - [Trait("Category", "Integration")] - public sealed class MastodonTagsTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static string NewTag() => $"t{Guid.NewGuid():N}"[..12]; - - [Fact] - public async Task A_tag_shows_its_week_of_public_use_and_whether_the_persona_follows_it() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var tag = NewTag(); - await alice.Status($"about #{tag}"); - await alice.Status($"more about #{tag.ToUpperInvariant()}"); - await bob.Status($"also #{tag}"); - await bob.Status($"quietly #{tag}", ("visibility", "unlisted")); - - var shown = (await _host.Client().Get($"/api/v1/tags/{tag}")).Ok().Object; - Assert.Equal(tag, shown.Text("name")); - Assert.False(shown.Flag("following")); - var history = shown["history"]!.AsArray(); - Assert.Equal(7, history.Count); - Assert.Equal(("3", "2"), (history[0].Text("uses"), history[0].Text("accounts"))); - Assert.Equal(new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds().ToString(), history[0].Text("day")); - - Assert.True((await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok().Body.Flag("following")); - Assert.True((await alice.Client.Get($"/api/v1/tags/%23{tag.ToUpperInvariant()}")).Ok().Body.Flag("following")); - Assert.Equal(new[] { tag }, (await alice.Client.Get("/api/v1/followed_tags")).Ok().Array.Select(t => t.Text("name"))); - Assert.False((await bob.Client.Get($"/api/v1/tags/{tag}")).Ok().Body.Flag("following")); - Assert.False((await alice.Client.Post($"/api/v1/tags/{tag}/unfollow")).Ok().Body.Flag("following")); - Assert.Empty((await alice.Client.Get("/api/v1/followed_tags")).Ok().Array); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post($"/api/v1/tags/{tag}/follow")).Status); - } - - [Fact] - public async Task A_followed_tags_public_posts_come_home_from_here_and_from_other_servers() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var carol = await _host.Mastodon("carol"); - var remote = new RemoteActor(_peer, "dora"); - var tag = NewTag(); - (await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok(); - - var open = (await bob.Status($"news about #{tag}")).Text("id"); - var quiet = (await bob.Status($"quiet news #{tag}", ("visibility", "unlisted"))).Text("id"); - var answer = (await bob.Status($"and #{tag} again", ("in_reply_to_id", open))).Text("id"); - var untagged = (await bob.Status("nothing tagged")).Text("id"); - var far = await _host.PublicPostFrom(remote, carol, $"

far away #{tag}

", note => note["tag"]!.AsArray().Add(new JsonObject - { - ["type"] = "Hashtag", - ["name"] = "#" + tag, - ["href"] = $"{remote.Origin()}/tags/{tag}" - })); - - var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids.ToList(); - Assert.Contains(open, home); - Assert.Contains(far.ID, home); - Assert.DoesNotContain(quiet, home); - Assert.DoesNotContain(answer, home); - Assert.DoesNotContain(untagged, home); - Assert.DoesNotContain(open, (await carol.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonTimelinesTests.cs b/PrivaPub.Tests/Http/MastodonTimelinesTests.cs deleted file mode 100644 index 6c4ecca..0000000 --- a/PrivaPub.Tests/Http/MastodonTimelinesTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class MastodonTimelinesTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task The_home_timeline_pages_with_max_since_and_min_id() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var posts = new List(); - for (var i = 1; i <= 4; i++) - posts.Add((await alice.Status($"post {i}")).Text("id")); - (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - var afterFollow = (await alice.Status("post 5")).Text("id"); - var (p1, p2, p3, p4) = (posts[0], posts[1], posts[2], posts[3]); - - var first = (await alice.Client.Get("/api/v1/timelines/home?limit=2")).Ok(); - Assert.Equal(new[] { afterFollow, p4 }, first.Ids); - Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get(first.Link("next"))).Ok().Ids); - Assert.Empty((await alice.Client.Get(first.Link("prev"))).Ok().Array); - Assert.Equal(new[] { p2, p1 }, (await alice.Client.Get($"/api/v1/timelines/home?max_id={p3}&limit=2")).Ok().Ids); - Assert.Equal(new[] { afterFollow, p4 }, (await alice.Client.Get($"/api/v1/timelines/home?since_id={p1}&limit=2")).Ok().Ids); - Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p1}&limit=2")).Ok().Ids); - Assert.Equal(new[] { p3 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p2}&max_id={p4}")).Ok().Ids); - - Assert.Equal(new[] { afterFollow }, (await bob.Client.Get("/api/v1/timelines/home")).Ok().Ids); - Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/timelines/home")).Status); - } - - static async Task> Window(HttpClient client, string filter, string since, string until) - { - var ids = new List(); - for (var page = 0; page < 50; page++) - { - var batch = (await client.Get($"/api/v1/timelines/public?{filter}&since_id={since}&max_id={until}&limit=40")).Ok().Ids.ToList(); - if (batch.Count == 0) - break; - ids.AddRange(batch); - until = batch[^1]; - } - return ids; - } - - [Fact] - public async Task The_public_timeline_splits_local_from_remote_and_leaves_out_unlisted_posts() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var since = MastodonHelpers.IdBefore(DateTime.UtcNow); - var remote = await _host.PublicPostFrom(bob, alice); - var open = (await alice.Status("hello world")).Text("id"); - var unlisted = (await alice.Status("hello quietly", ("visibility", "unlisted"))).Text("id"); - var until = MastodonHelpers.IdBefore(DateTime.UtcNow.AddSeconds(1)); - var anonymous = _host.Client(); - - var everything = await Window(anonymous, "local=false", since, until); - Assert.Contains(open, everything); - Assert.Contains(remote.ID, everything); - Assert.DoesNotContain(unlisted, everything); - var local = await Window(anonymous, "local=true", since, until); - Assert.Contains(open, local); - Assert.DoesNotContain(remote.ID, local); - var federated = await Window(alice.Client, "remote=true", since, until); - Assert.Contains(remote.ID, federated); - Assert.DoesNotContain(open, federated); - } - - [Fact] - public async Task The_tag_timeline_is_open_to_anonymous_readers_and_shows_only_public_posts() - { - var alice = await _host.Mastodon("alice"); - var tag = $"t{Guid.NewGuid():N}"[..16]; - var open = (await alice.Status($"about #{tag}")).Text("id"); - await alice.Status($"quietly about #{tag}", ("visibility", "unlisted")); - await alice.Status($"privately about #{tag}", ("visibility", "private")); - var later = (await alice.Status($"more about #{tag.ToUpperInvariant()}")).Text("id"); - - var tagged = (await _host.Client().Get($"/api/v1/timelines/tag/{tag}")).Ok(); - - Assert.Equal(new[] { later, open }, tagged.Ids); - Assert.Equal(new[] { later }, (await _host.Client().Get($"/api/v1/timelines/tag/{tag}?local=true&limit=1")).Ok().Ids); - Assert.Equal(new[] { later, open }, (await alice.Client.Get($"/api/v1/timelines/tag/%23{tag}")).Ok().Ids); - } - - [Fact] - public async Task Favourites_list_what_the_persona_liked_newest_first() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var first = (await carol.Status("one")).Text("id"); - var second = (await carol.Status("two")).Text("id"); - (await alice.Client.Post($"/api/v1/statuses/{first}/favourite")).Ok(); - (await alice.Client.Post($"/api/v1/statuses/{second}/favourite")).Ok(); - - Assert.Equal(new[] { second, first }, (await alice.Client.Get("/api/v1/favourites")).Ok().Ids); - var page = (await alice.Client.Get("/api/v1/favourites?limit=1")).Ok(); - Assert.Equal(new[] { second }, page.Ids); - Assert.Equal(new[] { first }, (await alice.Client.Get(page.Link("next"))).Ok().Ids); - Assert.Empty((await carol.Client.Get("/api/v1/favourites")).Ok().Array); - } - - [Fact] - public async Task Conversations_list_direct_messages_to_their_participants_only() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var stranger = await _host.Mastodon("stranger"); - (await alice.Status($"@{bob.UserName} psst", ("visibility", "direct"))).Text("id"); - var answer = (await bob.Status($"@{alice.UserName} what?", ("visibility", "direct"))).Text("id"); - - var mine = Assert.Single((await alice.Client.Get("/api/v1/conversations")).Ok().Array); - Assert.Equal(new[] { bob.Id }, mine!["accounts"]!.AsArray().Select(a => a.Text("id"))); - Assert.Equal(answer, mine["last_status"].Text("id")); - var theirs = Assert.Single((await bob.Client.Get("/api/v1/conversations")).Ok().Array); - Assert.Equal(mine.Text("id"), theirs.Text("id")); - Assert.Equal(new[] { alice.Id }, theirs!["accounts"]!.AsArray().Select(a => a.Text("id"))); - Assert.Empty((await stranger.Client.Get("/api/v1/conversations")).Ok().Array); - - var read = (await bob.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Ok(); - Assert.Equal(mine.Text("id"), read.Body.Text("id")); - Assert.Equal(answer, read.Body["last_status"].Text("id")); - Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Status); - Assert.DoesNotContain(answer, (await stranger.Client.Get($"/api/v1/accounts/{bob.Id}/statuses")).Ok().Ids); - } - - [Fact] - public async Task Conversations_page_by_their_newest_message_and_know_what_is_unread_read_and_removed() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var carol = await _host.Mastodon("carol"); - await bob.Status($"@{alice.UserName} first", ("visibility", "direct")); - var fromCarol = (await carol.Status($"@{alice.UserName} second", ("visibility", "direct"))).Text("id"); - // writing in a conversation reads it - await alice.Status($"@{bob.UserName} third", ("visibility", "direct")); - async Task List() => (await alice.Client.Get("/api/v1/conversations")).Ok().Array; - - var all = await List(); - Assert.Equal(2, all.Count); - var (withBob, withCarol) = (all[0].Text("id"), all[1].Text("id")); - Assert.Equal(new[] { bob.Id }, all[0]!["accounts"]!.AsArray().Select(a => a.Text("id"))); - Assert.False(all[0].Flag("unread")); - Assert.True(all[1].Flag("unread")); - Assert.Equal(fromCarol, all[1]!["last_status"].Text("id")); - - var page = (await alice.Client.Get("/api/v1/conversations?limit=1")).Ok(); - Assert.Equal(withBob, Assert.Single(page.Array).Text("id")); - Assert.Equal(withCarol, Assert.Single((await alice.Client.Get(page.Link("next"))).Ok().Array).Text("id")); - - Assert.False((await alice.Client.Post($"/api/v1/conversations/{withCarol}/read")).Ok().Body.Flag("unread")); - Assert.False((await List()).Single(c => c.Text("id") == withCarol).Flag("unread")); - - // off the list until a newer message, which brings it back unread; carol's own list never changed - (await alice.Client.Delete($"/api/v1/conversations/{withCarol}")).Ok(); - Assert.Equal(new[] { withBob }, (await List()).Select(c => c.Text("id"))); - Assert.Equal(withCarol, Assert.Single((await carol.Client.Get("/api/v1/conversations")).Ok().Array).Text("id")); - var again = (await carol.Status($"@{alice.UserName} are you there?", ("visibility", "direct"))).Text("id"); - var back = (await List())[0]; - Assert.Equal((withCarol, again, true), (back.Text("id"), back!["last_status"].Text("id"), back.Flag("unread"))); - Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Delete($"/api/v1/conversations/{withCarol}")).Status); - } - - [Fact] - public async Task Markers_are_saved_versioned_and_read_back_per_timeline() - { - var alice = await _host.Mastodon("alice"); - - var saved = (await alice.Client.Post("/api/v1/markers", ("home[last_read_id]", "100"))).Ok(); - Assert.Equal("100", saved.Body["home"].Text("last_read_id")); - Assert.Equal(1, saved.Body["home"].Number("version")); - Assert.Null(saved.Body["notifications"]); - - var again = (await alice.Client.Json(HttpMethod.Post, "/api/v1/markers", new JsonObject - { - ["home"] = new JsonObject { ["last_read_id"] = "200" }, - ["notifications"] = new JsonObject { ["last_read_id"] = "7" } - })).Ok(); - Assert.Equal(("200", 2), (again.Body["home"].Text("last_read_id"), again.Body["home"].Number("version"))); - Assert.Equal(("7", 1), (again.Body["notifications"].Text("last_read_id"), again.Body["notifications"].Number("version"))); - - var home = (await alice.Client.Get("/api/v1/markers?timeline[]=home")).Ok().Object; - Assert.Equal(new[] { "home" }, home.Select(p => p.Key)); - Assert.Equal("200", home["home"].Text("last_read_id")); - Assert.Equal(new[] { "home", "notifications" }, (await alice.Client.Get("/api/v1/markers")).Ok().Object.Select(p => p.Key).Order()); - Assert.Empty((await (await _host.Mastodon("other")).Client.Get("/api/v1/markers")).Ok().Object); - } - - [Fact] - public async Task A_notification_about_a_deleted_post_is_gone_not_null() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var post = (await alice.Status("short lived")).Text("id"); - (await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - var id = Assert.Single((await alice.Client.Get("/api/v1/notifications")).Ok().Array).Text("id"); - - (await alice.Client.Delete($"/api/v1/statuses/{post}")).Ok(); - - Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array); - var gone = await alice.Client.Get($"/api/v1/notifications/{id}"); - Assert.Equal(HttpStatusCode.NotFound, gone.Status); - Assert.Equal("Record not found", gone.Body.Text("error")); - } - - [Fact] - public async Task Notifications_filter_page_dismiss_and_clear() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var post = (await alice.Status("notice me")).Text("id"); - (await carol.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); - (await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - var mention = (await carol.Status($"hey @{alice.UserName}")).Text("id"); - (await carol.Client.Post($"/api/v1/statuses/{post}/reblog")).Ok(); - - var all = (await alice.Client.Get("/api/v1/notifications")).Ok(); - Assert.Equal(new[] { "reblog", "mention", "favourite", "follow" }, all.Array.Select(n => n.Text("type"))); - Assert.All(all.Array, n => Assert.Equal(carol.Id, n!["account"].Text("id"))); - Assert.Equal(mention, all.Array[1]!["status"].Text("id")); - Assert.Equal(new[] { "mention" }, (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n.Text("type"))); - Assert.Equal(new[] { "mention", "favourite" }, - (await alice.Client.Get("/api/v1/notifications?exclude_types[]=follow&exclude_types[]=reblog")).Ok().Array.Select(n => n.Text("type"))); - Assert.Equal(4, (await alice.Client.Get($"/api/v1/notifications?account_id={carol.Id}")).Ok().Array.Count); - var page = (await alice.Client.Get("/api/v1/notifications?limit=3")).Ok(); - Assert.Equal(new[] { "follow" }, (await alice.Client.Get(page.Link("next"))).Ok().Array.Select(n => n.Text("type"))); - Assert.Equal(4, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); - - var followId = all.Array[3].Text("id"); - Assert.Equal("follow", (await alice.Client.Get($"/api/v1/notifications/{followId}")).Ok().Body.Text("type")); - Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Get($"/api/v1/notifications/{followId}")).Status); - (await carol.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok(); - Assert.Equal(HttpStatusCode.OK, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status); - (await alice.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok(); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status); - Assert.Equal(3, (await alice.Client.Get("/api/v1/notifications")).Ok().Array.Count); - - (await alice.Client.Post("/api/v1/notifications/clear")).Ok(); - Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array); - Assert.Equal(0, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonTrendsTests.cs b/PrivaPub.Tests/Http/MastodonTrendsTests.cs deleted file mode 100644 index 54a35c5..0000000 --- a/PrivaPub.Tests/Http/MastodonTrendsTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Social; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -namespace PrivaPub.Tests.Http -{ - // Trends from what PrivaPub holds (hashtags and links by how many authors use them, posts by their interactions, - // from discoverable authors only) and the directory of accounts that let themselves be discovered - [Trait("Category", "Integration")] - public sealed class MastodonTrendsTests : IAsyncLifetime - { - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - void Fresh() => _host.Get().Forget(); - - static string NewTag() => $"tr{Guid.NewGuid():N}"[..12]; - - [Fact] - public async Task A_hashtag_trends_once_two_authors_use_it_today_and_never_from_one_or_behind_a_warning() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var carol = await _host.Mastodon("carol"); - var shared = NewTag(); - var lonely = NewTag(); - var warned = NewTag(); - await alice.Status($"#{shared} is good"); - await bob.Status($"#{shared} indeed"); - for (var i = 0; i < 4; i++) - await carol.Status($"#{lonely} again {i}"); - await alice.Status($"#{warned}", ("spoiler_text", "careful")); - await bob.Status($"#{warned}", ("spoiler_text", "careful")); - Fresh(); - - var trends = (await _host.Client().Get("/api/v1/trends/tags?limit=20")).Ok().Array; - var trend = Assert.Single(trends, t => t.Text("name") == shared)!; - Assert.Equal(("2", "2"), (trend["history"]![0].Text("uses"), trend["history"]![0].Text("accounts"))); - Assert.DoesNotContain(trends, t => t.Text("name") == lonely); - Assert.DoesNotContain(trends, t => t.Text("name") == warned); - Assert.Contains((await alice.Client.Get("/api/v1/trends?limit=20")).Ok().Array, t => t.Text("name") == shared); - } - - [Fact] - public async Task A_post_trends_by_its_interactions_when_its_author_is_discoverable() - { - var alice = await _host.Mastodon("alice"); - var hidden = await _host.Mastodon("hidden"); - var bob = await _host.Mastodon("bob"); - (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); - var liked = (await alice.Status("worth a like")).Text("id"); - var quiet = (await hidden.Status("worth a like too")).Text("id"); - var answer = (await alice.Status("a reply", ("in_reply_to_id", liked))).Text("id"); - foreach (var id in new[] { liked, quiet, answer }) - { - (await bob.Client.Post($"/api/v1/statuses/{id}/favourite")).Ok(); - (await bob.Client.Post($"/api/v1/statuses/{id}/reblog")).Ok(); - } - Fresh(); - - var trending = (await _host.Client().Get("/api/v1/trends/statuses?limit=40")).Ok().Ids.ToList(); - Assert.Contains(liked, trending); - Assert.DoesNotContain(quiet, trending); - Assert.DoesNotContain(answer, trending); - } - - [Fact] - public async Task A_link_trends_once_two_authors_share_it() - { - var alice = await _host.Mastodon("alice"); - var bob = await _host.Mastodon("bob"); - var url = $"https://news.example/{Guid.NewGuid():N}"; - foreach (var account in new[] { alice, bob }) - { - var id = (await account.Status($"read {url}")).Text("id"); - await DB.Default.Update().MatchID(id).Modify(p => p.Link, new PostLink { Href = url, Title = "The news" }).ExecuteAsync(Token); - } - Fresh(); - - var link = Assert.Single((await _host.Client().Get("/api/v1/trends/links?limit=20")).Ok().Array, l => l.Text("url") == url)!; - Assert.Equal("The news", link.Text("title")); - Assert.Equal("2", link["history"]![0].Text("accounts")); - } - - [Fact] - public async Task The_directory_lists_discoverable_accounts_by_activity_or_by_joining_and_never_by_creation() - { - var alice = await _host.Mastodon("alice"); - var hidden = await _host.Mastodon("hidden"); - (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); - await hidden.Status("not listed"); - await alice.Status("listed"); - - // by their latest public post, newest first: other tests post too, so the directory is read until alice - var active = new List(); - for (var offset = 0; offset < 4000 && !active.Contains(alice.Id); offset += 80) - { - var page = (await _host.Client().Get($"/api/v1/directory?order=active&local=true&limit=80&offset={offset}")).Ok().Ids.ToList(); - active.AddRange(page); - if (page.Count < 80) - break; - } - Assert.Contains(alice.Id, active); - Assert.DoesNotContain(hidden.Id, active); - // by the day each says it joined, newest first: a persona's is its published day, set some days back - // (each page in order: other tests make personas between two pages) - var joined = new List(); - for (var offset = 0; offset < 4000; offset += 80) - { - var page = (await _host.Client().Get($"/api/v1/directory?order=new&local=true&limit=80&offset={offset}")).Ok().Array; - var days = page.Select(a => a.Text("created_at")[..10]).ToList(); - Assert.Equal(days.OrderByDescending(d => d, StringComparer.Ordinal), days); - joined.AddRange(page!); - if (page.Count < 80 || joined.Any(a => a.Text("id") == alice.Id)) - break; - } - Assert.Contains(joined, a => a.Text("id") == alice.Id); - Assert.DoesNotContain(joined, a => a.Text("id") == hidden.Id); - } - } -} diff --git a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs b/PrivaPub.Tests/Http/MastodonWordSearchTests.cs deleted file mode 100644 index 3954da0..0000000 --- a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked - // or was named in, and public posts of authors who let themselves be indexed - [Trait("Category", "Integration")] - public sealed class MastodonWordSearchTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task Words_find_what_the_persona_wrote_or_touched_and_what_indexable_authors_made_public() - { - var reader = await _host.Mastodon("reader"); - var open = await _host.Mastodon("open"); - var shy = await _host.Mastodon("shy"); - (await open.Client.Patch("/api/v1/accounts/update_credentials", ("indexable", "true"))).Ok(); - var word = $"w{Guid.NewGuid():N}"[..12]; - - var own = (await reader.Status($"my {word}")).Text("id"); - var indexed = (await open.Status($"open {word}")).Text("id"); - var quietIndexed = (await open.Status($"followers {word}", ("visibility", "private"))).Text("id"); - var unindexed = (await shy.Status($"shy {word}")).Text("id"); - var liked = (await shy.Status($"liked {word}")).Text("id"); - var named = (await shy.Status($"@{reader.UserName} about {word}")).Text("id"); - var warned = (await shy.Status("calm", ("spoiler_text", $"about {word}"))).Text("id"); - (await reader.Client.Post($"/api/v1/statuses/{liked}/favourite")).Ok(); - (await reader.Client.Post($"/api/v1/statuses/{warned}/bookmark")).Ok(); - - var found = (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body["statuses"]!.AsArray() - .Select(s => s.Text("id")).ToList(); - Assert.Equal(new[] { warned, named, liked, indexed, own }, found); - Assert.DoesNotContain(unindexed, found); - Assert.DoesNotContain(quietIndexed, found); - Assert.Empty((await _host.Client().Get($"/api/v2/search?q={word}&type=statuses")).Ok().Body["statuses"]!.AsArray()); - Assert.Equal(new[] { named }, - (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id"))); - } - - // FEP-268d: a remote account's searchableBy outranks its indexable, and a post's own outranks its author's - [Fact] - public async Task A_remote_searchable_by_decides_before_indexable() - { - var token = TestContext.Current.CancellationToken; - await using var peer = await Peer.Start(); - var reader = await _host.Mastodon("reader"); - var keeper = await _host.Mastodon("keeper"); - var word = $"s{Guid.NewGuid():N}"[..12]; - - async Task Author(string name, bool indexable, Func searchableBy) - { - var author = new RemoteActor(peer, name); - var document = author.Document(); - document["indexable"] = indexable; - if (searchableBy != default) - document["searchableBy"] = searchableBy(author); - peer.Serve($"/users/{author.Name}", document.ToJsonString()); - await DB.Default.SaveAsync(new Following { AvatarId = keeper.Id, TargetActorURI = author.Id, State = FollowState.Accepted }, token); - return author; - } - async Task Says(RemoteActor author, string text, JsonArray searchableBy = default) - { - var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["published"] = DateTime.UtcNow.ToString("O") - }; - if (searchableBy != default) - note["searchableBy"] = searchableBy; - using var client = _host.Client(); - await client.SendAsync(author.SignedPost("/human-centipede", new JsonObject - { - ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id, - ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["object"] = note - }), token); - Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token)); - return (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token)).ID; - } - - var open = await Says(await Author("open", indexable: false, _ => new JsonArray(Addressing.Public)), $"open {word}"); - var closed = await Says(await Author("closed", indexable: true, _ => new JsonArray()), $"closed {word}"); - var friendly = await Author("friendly", indexable: false, a => new JsonArray(a.Id + "/followers")); - var friends = await Says(friendly, $"friends {word}"); - var quiet = await Author("quiet", indexable: false, default); - var perPost = await Says(quiet, $"per post {word}", new JsonArray(Addressing.Public)); - var plain = await Says(quiet, $"plain {word}"); - - static List Found(JsonNode body) => body["statuses"]!.AsArray().Select(s => s.Text("id")).ToList(); - var before = Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body); - Assert.Contains(open, before); - Assert.Contains(perPost, before); - Assert.DoesNotContain(closed, before); - Assert.DoesNotContain(friends, before); - Assert.DoesNotContain(plain, before); - - await DB.Default.SaveAsync(new Following { AvatarId = reader.Id, TargetActorURI = friendly.Id, State = FollowState.Accepted }, token); - Assert.Contains(friends, Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body)); - } - - // a handle asks for its account: no posts sharing its words, and no hashtag made of it - [Fact] - public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag() - { - var reader = await _host.Mastodon("reader"); - var named = await _host.Mastodon("named"); - await reader.Status($"talking of {named.UserName} here"); - - foreach (var q in new[] { $"@{named.UserName}", $"@{named.UserName}@{PrivaPubHost.Host}", $"{named.UserName}@{PrivaPubHost.Host}" }) - { - var body = (await reader.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(q)}")).Ok().Body; - Assert.Contains(body["accounts"]!.AsArray(), a => a.Text("id") == named.Id); - Assert.Empty(body["statuses"]!.AsArray()); - Assert.Empty(body["hashtags"]!.AsArray()); - } - Assert.Equal("cats", Assert.Single((await reader.Client.Get("/api/v2/search?q=%23Cats")).Ok().Body["hashtags"]!.AsArray()).Text("name")); - } - } -} diff --git a/PrivaPub.Tests/Http/MediaLifecycleTests.cs b/PrivaPub.Tests/Http/MediaLifecycleTests.cs deleted file mode 100644 index 245a888..0000000 --- a/PrivaPub.Tests/Http/MediaLifecycleTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Media; -using PrivaPub.Models.Media; -using PrivaPub.Services; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - // A file lives exactly as long as something holds it: a deleted post, an edit leaving media out, a replaced picture and - // a removed root each trash theirs, which stops /media/files serving them at once; the janitor deletes them later. - [Trait("Category", "Integration")] - public sealed class MediaLifecycleTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - async Task Upload(Mastodon account, string name) - { - var form = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(64, 48), "image/jpeg", name)); - return (await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = form })).Ok().Body.Text("id"); - } - - async Task Served(string url) => - (await _host.Client().GetAsync(new Uri(url).PathAndQuery, Token)).StatusCode; - - [Fact] - public async Task A_deleted_posts_media_stop_being_served() - { - var alice = await _host.Mastodon("alice"); - var id = await Upload(alice, "a.jpg"); - var status = (await alice.Client.Post("/api/v1/statuses", ("status", "with a picture"), ("media_ids[]", id))).Ok(); - var url = status.Body["media_attachments"]![0]!["url"]!.GetValue(); - var preview = status.Body["media_attachments"]![0]!["preview_url"]!.GetValue(); - Assert.Equal(HttpStatusCode.OK, await Served(url)); - - (await alice.Client.Delete($"/api/v1/statuses/{status.Body.Text("id")}")).Ok(); - - Assert.Equal(HttpStatusCode.NotFound, await Served(url)); - Assert.Equal(HttpStatusCode.NotFound, await Served(preview)); - Assert.Equal("post deleted", (await DB.Default.Find().OneAsync(id, Token)).TrashReason); - // the file waits in the trash, beside what /media/files serves - var relative = url[(url.IndexOf("/media/files/", StringComparison.Ordinal) + "/media/files/".Length)..]; - Assert.True(File.Exists(Path.Combine(_host.Get().TrashRoot, relative))); - Assert.Equal(HttpStatusCode.NotFound, await Served(url.Replace("/media/files/", "/media/files/.trash/"))); - } - - [Fact] - public async Task An_edit_that_leaves_media_out_trashes_them() - { - var alice = await _host.Mastodon("alice"); - var kept = await Upload(alice, "kept.jpg"); - var dropped = await Upload(alice, "dropped.jpg"); - var status = (await alice.Client.Post("/api/v1/statuses", ("status", "two pictures"), ("media_ids[]", kept), ("media_ids[]", dropped))).Ok(); - var urls = status.Body["media_attachments"]!.AsArray().ToDictionary(m => m!["id"]!.GetValue(), m => m!["url"]!.GetValue()); - - (await alice.Client.Put($"/api/v1/statuses/{status.Body.Text("id")}", ("status", "one picture"), ("media_ids[]", kept))).Ok(); - - Assert.Equal(HttpStatusCode.OK, await Served(urls[kept])); - Assert.Equal(HttpStatusCode.NotFound, await Served(urls[dropped])); - Assert.Null((await DB.Default.Find().OneAsync(kept, Token)).TrashedAt); - Assert.Equal("edited out", (await DB.Default.Find().OneAsync(dropped, Token)).TrashReason); - } - - // a posted picture's description and focus change by editing its post (media_attributes), not by PUT, as on Mastodon - [Fact] - public async Task An_edit_describes_its_media_again_and_keeps_what_it_is_not_sent() - { - var alice = await _host.Mastodon("alice"); - var id = await Upload(alice, "a.jpg"); - var status = (await alice.Client.Post("/api/v1/statuses", ("status", "described later"), ("media_ids[]", id), - ("spoiler_text", "a warning"), ("sensitive", "true"), ("language", "it"))).Ok(); - var statusId = status.Body.Text("id"); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put($"/api/v1/media/{id}", ("description", "too late"))).Status); - - var edited = (await alice.Client.Json(HttpMethod.Put, $"/api/v1/statuses/{statusId}", new System.Text.Json.Nodes.JsonObject - { - ["status"] = "described now", - ["spoiler_text"] = "a warning", - ["sensitive"] = true, - ["media_ids"] = new System.Text.Json.Nodes.JsonArray(id), - ["media_attributes"] = new System.Text.Json.Nodes.JsonArray(new System.Text.Json.Nodes.JsonObject { ["id"] = id, ["description"] = "a red square", ["focus"] = "0.5,0.5" }) - })).Ok(); - - Assert.Equal("a red square", edited.Body["media_attachments"]![0]!.Text("description")); - Assert.Equal(0.5, edited.Body["media_attachments"]![0]!["meta"]!["focus"]!["x"]!.GetValue()); - Assert.Equal("a warning", edited.Body.Text("spoiler_text")); - Assert.True(edited.Body["sensitive"]!.GetValue()); - Assert.Equal("it", edited.Body.Text("language")); - Assert.Equal("a red square", (await DB.Default.Find().OneAsync(id, Token)).Description); - - // an edit that doesn't send media keeps them, described as they were; an empty media_ids takes them away - var kept = (await alice.Client.Put($"/api/v1/statuses/{statusId}", ("status", "media kept"))).Ok(); - Assert.Equal("a red square", kept.Body["media_attachments"]![0]!.Text("description")); - var bare = (await alice.Client.Json(HttpMethod.Put, $"/api/v1/statuses/{statusId}", new System.Text.Json.Nodes.JsonObject - { - ["status"] = "no picture now", - ["media_ids"] = new System.Text.Json.Nodes.JsonArray() - })).Ok(); - Assert.Empty(bare.Body["media_attachments"]!.AsArray()); - Assert.Equal("edited out", (await DB.Default.Find().OneAsync(id, Token)).TrashReason); - } - - [Fact] - public async Task A_replaced_avatar_is_trashed_and_a_picture_that_is_not_one_is_refused() - { - var alice = await _host.Mastodon("alice"); - async Task Picture(byte[] bytes, string type, string name) => - await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") - { - Content = MastodonHelpers.Multipart(("avatar", bytes, type, name)) - }); - var first = (await Picture(MastodonHelpers.JpegWithMetadata(300, 300), "image/jpeg", "one.jpg")).Ok().Body.Text("avatar"); - var second = (await Picture(MastodonHelpers.JpegWithMetadata(320, 300), "image/jpeg", "two.jpg")).Ok().Body.Text("avatar"); - - Assert.NotEqual(first, second); - Assert.Equal(HttpStatusCode.NotFound, await Served(first)); - Assert.Equal(HttpStatusCode.OK, await Served(second)); - var row = await DB.Default.Find().Match(m => m.ProfileOfAvatarId == alice.Persona.Id && m.Kind == "avatar" && m.TrashedAt == null).ExecuteSingleAsync(Token); - Assert.EndsWith(row.FilePath, second); - - // not an image: 422, and the avatar stays as it was - var refused = await Picture(""u8.ToArray(), "image/svg+xml", "x.svg"); - Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status); - Assert.Equal(second, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok().Body.Text("avatar")); - } - - // Mastodon's DELETE /api/v1/profile/avatar and /header: the picture goes, the default shows again - [Fact] - public async Task A_picture_is_removed_and_the_default_shows_again() - { - var alice = await _host.Mastodon($"pic{Guid.NewGuid():N}"[..12]); - var form = MastodonHelpers.Multipart(("avatar", MastodonHelpers.JpegWithMetadata(200, 200), "image/jpeg", "me.jpg"), - ("header", MastodonHelpers.JpegWithMetadata(900, 300), "image/jpeg", "top.jpg")); - var set = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") { Content = form })).Ok(); - var avatar = set.Body.Text("avatar"); - var header = set.Body.Text("header"); - - var removed = (await alice.Client.Delete("/api/v1/profile/avatar")).Ok(); - - Assert.Contains("missing", removed.Body.Text("avatar")); - Assert.Equal(header, removed.Body.Text("header")); - Assert.Equal(HttpStatusCode.NotFound, await Served(avatar)); - Assert.Equal(HttpStatusCode.OK, await Served(header)); - - Assert.Contains("missing", (await alice.Client.Delete("/api/v1/profile/header")).Ok().Body.Text("header")); - Assert.Equal(HttpStatusCode.NotFound, await Served(header)); - Assert.False(await DB.Default.Find().Match(m => m.ProfileOfAvatarId == alice.Persona.Id && m.TrashedAt == null).ExecuteAnyAsync(Token)); - } - - [Fact] - public async Task A_removed_roots_media_and_pictures_are_trashed_and_its_scheduled_posts_dropped() - { - var gone = await _host.Mastodon($"gone{Guid.NewGuid():N}"[..12]); - var posted = await Upload(gone, "posted.jpg"); - var url = (await gone.Client.Post("/api/v1/statuses", ("status", "soon gone"), ("media_ids[]", posted))).Ok() - .Body["media_attachments"]![0]!["url"]!.GetValue(); - var held = await Upload(gone, "held.jpg"); - (await gone.Client.Post("/api/v1/statuses", ("status", "later"), ("media_ids[]", held), - ("scheduled_at", DateTime.UtcNow.AddHours(2).ToString("O")))).Ok(); - var avatar = (await gone.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") - { - Content = MastodonHelpers.Multipart(("avatar", MastodonHelpers.JpegWithMetadata(200, 200), "image/jpeg", "me.jpg")) - })).Ok().Body.Text("avatar"); - - Assert.True(await _host.Get().Remove(gone.Persona.Root.Id, Token)); - - Assert.Equal(HttpStatusCode.NotFound, await Served(url)); - Assert.Equal(HttpStatusCode.NotFound, await Served(avatar)); - Assert.False(await DB.Default.Find().Match(m => m.OwnerAvatarId == gone.Persona.Id && m.TrashedAt == null).ExecuteAnyAsync(Token)); - Assert.False(await DB.Default.Find().Match(s => s.AvatarId == gone.Persona.Id).ExecuteAnyAsync(Token)); - } - - // what a login's media take: every persona's uploads and pictures, trashed ones no longer - [Fact] - public async Task A_logins_storage_counts_what_its_personas_keep() - { - var alice = await _host.Mastodon($"store{Guid.NewGuid():N}"[..12]); - using var root = _host.As(alice.Persona.Root.Jwt); - async Task Storage() => await (await root.GetAsync("/clientapi/user/storage", Token)).JsonBody(); - Assert.Equal(0, Storage().Result["mediaBytes"]!.GetValue()); - - var id = await Upload(alice, "a.jpg"); - var stored = await Storage(); - Assert.True(stored["mediaBytes"]!.GetValue() > 0); - Assert.Equal(1, stored["mediaFiles"]!.GetValue()); - Assert.Null(stored["quotaBytes"]); - - await _host.Get().Trash(m => m.ID == id, "test", Token); - Assert.Equal(0, (await Storage())["mediaFiles"]!.GetValue()); - } - - [Fact] - public async Task The_audit_adopts_todays_pictures_and_trashes_what_nothing_holds() - { - var media = _host.Get(); - var bob = await _host.Mastodon($"bob{Guid.NewGuid():N}"[..12]); - // a picture shown from before pictures had rows, and a leftover nothing holds - var picture = $"2020/01/{Guid.NewGuid():N}.jpg"; - var leftover = $"2020/01/{Guid.NewGuid():N}.jpg"; - foreach (var relative in new[] { picture, leftover }) - { - Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(media.Root, relative))!); - await File.WriteAllBytesAsync(Path.Combine(media.Root, relative), new byte[] { 1, 2, 3 }, Token); - } - await DB.Default.Update().MatchID(bob.Persona.Id).Modify(a => a.PictureURL, media.Url(picture)).ExecuteAsync(Token); - - var looked = await MediaAudit.Run(media, fix: false, Token); - Assert.True(looked.Adopted >= 1); - Assert.True(looked.Unheld >= 1); - Assert.True(File.Exists(Path.Combine(media.Root, leftover))); - - await MediaAudit.Run(media, fix: true, Token); - - Assert.True(await DB.Default.Find().Match(m => m.ProfileOfAvatarId == bob.Persona.Id && m.FilePath == picture && m.TrashedAt == null).ExecuteAnyAsync(Token)); - Assert.Equal(HttpStatusCode.OK, await Served(media.Url(picture))); - Assert.Equal(HttpStatusCode.NotFound, await Served(media.Url(leftover))); - Assert.True(File.Exists(Path.Combine(media.TrashRoot, leftover))); - Assert.True(await DB.Default.Find().Match(m => m.FilePath == leftover && m.TrashedAt != null).ExecuteAnyAsync(Token)); - } - } -} diff --git a/PrivaPub.Tests/Http/MessageSignatureTests.cs b/PrivaPub.Tests/Http/MessageSignatureTests.cs deleted file mode 100644 index 1195bcf..0000000 --- a/PrivaPub.Tests/Http/MessageSignatureTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using MongoDB.Entities; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -using PrivaPub.Federation.Signing; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // RFC 9421 message signatures, as WordPress's ActivityPub plugin, Ghost and Fedify send them first: a delivery so signed - // is taken in one request, and refused when its body or its target is not what was signed - [Trait("Category", "Integration")] - public sealed class MessageSignatureTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task A_delivery_signed_as_rfc9421_is_taken_and_one_whose_body_or_target_differs_is_refused() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var create = bob.Create("

signed the new way

", new[] { alice.Uri }); - var noteId = create["object"]!["id"]!.GetValue(); - using var client = _host.Client(); - - var tampered = bob.MessageSignedPost(alice.Mouth, create); - tampered.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(create.ToJsonString().Replace("new way", "other way"))); - tampered.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); - tampered.Content.Headers.TryAddWithoutValidation("Content-Digest", bob.MessageSignedPost(alice.Mouth, create).Content!.Headers.GetValues("Content-Digest").First()); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(tampered, Token)).StatusCode); - var elsewhere = bob.MessageSignedPost(alice.Mouth, create, signedFor: "/human-centipede"); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(elsewhere, Token)).StatusCode); - - Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.MessageSignedPost(alice.Mouth, create), Token)).StatusCode); - Assert.Equal(1, await _host.RunInbox(Id(create), Token)); - Assert.Contains("signed the new way", (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml); - } - - static string Id(JsonObject activity) => activity["id"]!.GetValue(); - - // a signed fetch (a GET, no body) verifies the same way, and so does RSA-PSS with SHA-512 - [Fact] - public void A_signed_fetch_and_an_rsa_pss_signature_verify() - { - using var key = RSA.Create(2048); - var message = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1"); - MessageSignatures.Sign(message, "https://peer.example/users/bob#main-key", key.ExportPkcs8PrivateKeyPem(), body: null); - var request = Request(message); - var signature = RequestSignature.Of(request); - Assert.Equal("rfc9421:rsa-v1_5-sha256", signature.Scheme); - Assert.Null(signature.Problem(request, body: null)); - Assert.True(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://privapub.test"))); - Assert.False(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://elsewhere.example"))); - - var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var parameters = $"(\"@method\" \"@target-uri\");created={created};keyid=\"bob\";alg=\"rsa-pss-sha512\""; - var signatureBase = $"\"@method\": GET\n\"@target-uri\": https://privapub.test/peasants/alice/scribbles/1\n\"@signature-params\": {parameters}"; - var signed = key.SignData(Encoding.UTF8.GetBytes(signatureBase), HashAlgorithmName.SHA512, RSASignaturePadding.Pss); - var pss = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1"); - pss.Headers.TryAddWithoutValidation("Signature-Input", $"pss={parameters}"); - pss.Headers.TryAddWithoutValidation("Signature", $"pss=:{Convert.ToBase64String(signed)}:"); - var pssRequest = Request(pss); - var pssSignature = RequestSignature.Of(pssRequest); - Assert.True(pssSignature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), pssSignature.Signed(pssRequest, "https://privapub.test"))); - } - - static HttpRequest Request(HttpRequestMessage message) - { - var context = new DefaultHttpContext(); - context.Request.Method = message.Method.Method; - context.Request.Host = new HostString(message.RequestUri!.Host); - context.Request.Path = message.RequestUri.AbsolutePath; - context.Features.Get()!.RawTarget = message.RequestUri.PathAndQuery; - foreach (var (name, values) in message.Headers) - context.Request.Headers[name] = values.ToArray(); - return context.Request; - } - } -} diff --git a/PrivaPub.Tests/Http/NeverFiveHundredTests.cs b/PrivaPub.Tests/Http/NeverFiveHundredTests.cs deleted file mode 100644 index b8a3f63..0000000 --- a/PrivaPub.Tests/Http/NeverFiveHundredTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.Extensions.DependencyInjection; - -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class NeverFiveHundredTests : IAsyncLifetime - { - static readonly string[] Junk = { "x", "000000000000000000000000", "%2e%2e", new string('a', 5000), "5f0c1a2b3c4d5e6f70819203" }; - - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - public static TheoryData Credentials() => new() { "anonymous", "persona", "junk" }; - - [Theory] - [MemberData(nameof(Credentials))] - public async Task No_route_answers_500_to_junk(string credentials) - { - var token = TestContext.Current.CancellationToken; - var bearer = credentials switch - { - "persona" => await _host.MastodonToken(await _host.Persona(await _host.SignUp(), "junk")), - "junk" => "not-a-token", - _ => default - }; - var jwt = credentials == "persona" ? (await _host.SignUp()).Jwt : bearer; - var failures = new List(); - - foreach (var (method, template) in Routes()) - foreach (var value in Junk) - { - var path = Fill(template, value); - using var request = new HttpRequestMessage(new HttpMethod(method), path); - request.Headers.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}"); - var auth = path.StartsWith("/api/", StringComparison.Ordinal) || path.StartsWith("/oauth/", StringComparison.Ordinal) ? bearer : jwt; - if (auth != default) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth); - if (method is "POST" or "PUT" or "PATCH") - request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); - using var client = _host.Client(); - HttpResponseMessage response; - try - { - response = await client.SendAsync(request, token); - } - catch (Exception ex) - { - failures.Add($"{method} {Short(path)} threw {ex.GetType().Name}: {ex.Message}"); - continue; - } - using (response) - { - if ((int)response.StatusCode >= 500) - failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode}"); - else if (path.StartsWith("/api/", StringComparison.Ordinal) && (int)response.StatusCode >= 400 && !await IsMastodonError(response)) - failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode} without a Mastodon error body"); - } - } - - Assert.True(failures.Count == 0, $"{failures.Count} problems as {credentials}:\n" + string.Join("\n", failures.Distinct().Take(80))); - } - - IEnumerable<(string Method, string Template)> Routes() - { - var endpoints = _host.Services.GetRequiredService().Endpoints.OfType(); - foreach (var endpoint in endpoints) - { - var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; - var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/'); - foreach (var method in methods is { Count: > 0 } ? methods : new[] { "GET", "POST" }) - yield return (method, template); - } - } - - static string Fill(string template, string value) - { - var pattern = RoutePatternFactory.Parse(template); - var builder = new StringBuilder(); - foreach (var segment in pattern.PathSegments) - { - builder.Append('/'); - foreach (var part in segment.Parts) - builder.Append(part switch - { - RoutePatternLiteralPart literal => literal.Content, - RoutePatternParameterPart => value, - RoutePatternSeparatorPart separator => separator.Content, - _ => string.Empty - }); - } - return builder.Length == 0 ? "/" : builder.ToString(); - } - - static async Task IsMastodonError(HttpResponseMessage response) - { - var body = await response.Content.ReadAsStringAsync(); - try - { - return JsonNode.Parse(body) is JsonObject error && error["error"] is JsonValue; - } - catch (System.Text.Json.JsonException) - { - return false; - } - } - - static string Short(string path) => path.Length > 120 ? path[..120] + "…" : path; - } -} diff --git a/PrivaPub.Tests/Http/NotificationPolicyTests.cs b/PrivaPub.Tests/Http/NotificationPolicyTests.cs deleted file mode 100644 index c65486d..0000000 --- a/PrivaPub.Tests/Http/NotificationPolicyTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - // Mastodon's notification policy and requests (4.3): accepted by default; a policy filters what strangers, new accounts - // or unasked private mentions send into a request per account, which the persona lets in (and that account from then - // on) or dismisses; or drops it - [Trait("Category", "Integration")] - public sealed class NotificationPolicyTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static async Task> Notifiers(Mastodon persona, string query = "") => - (await persona.Client.Get("/api/v1/notifications" + query)).Ok().Array.Select(n => n!["account"].Text("id")); - - [Fact] - public async Task Everything_is_accepted_until_the_persona_chooses() - { - var alice = await _host.Mastodon("alice"); - var stranger = await _host.Mastodon("stranger"); - var post = (await alice.Status("like me")).Text("id"); - - var policy = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body; - (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - - Assert.Equal(["accept"], new[] { "for_not_following", "for_not_followers", "for_new_accounts", "for_private_mentions", "for_limited_accounts" } - .Select(key => policy.Text(key)).Distinct()); - Assert.Contains(stranger.Id, await Notifiers(alice)); - Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); - } - - [Fact] - public async Task A_strangers_notifications_wait_in_a_request_which_lets_them_and_the_stranger_in() - { - var alice = await _host.Mastodon("alice"); - var friend = await _host.Mastodon("friend"); - var stranger = await _host.Mastodon("stranger"); - (await alice.Client.Post($"/api/v1/accounts/{friend.Id}/follow")).Ok(); - var post = (await alice.Status("like me")).Text("id"); - var policy = (await alice.Client.Patch("/api/v2/notifications/policy", ("for_not_following", "filter"))).Ok().Body; - Assert.Equal("filter", policy.Text("for_not_following")); - - (await friend.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - - Assert.Equal([friend.Id], await Notifiers(alice)); - Assert.Equal(1, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body["count"]!.GetValue()); - Assert.DoesNotContain(stranger.Id, (await alice.Client.Get("/api/v2/notifications")).Ok().Body["accounts"]!.AsArray().Select(a => a.Text("id"))); - var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!; - Assert.Equal((stranger.Id, "1", post), (request["account"].Text("id"), request.Text("notifications_count"), request["last_status"].Text("id"))); - var summary = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body["summary"]!; - Assert.Equal((1, 1), (summary["pending_requests_count"]!.GetValue(), summary["pending_notifications_count"]!.GetValue())); - Assert.Equal([stranger.Id], await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true")); - - (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/accept")).Ok(); - - Assert.Contains(stranger.Id, await Notifiers(alice)); - Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); - var again = (await alice.Status("like me again")).Text("id"); - (await stranger.Client.Post($"/api/v1/statuses/{again}/favourite")).Ok(); - Assert.Equal(2, (await Notifiers(alice, "?types[]=favourite")).Count(id => id == stranger.Id)); - } - - [Fact] - public async Task A_dismissed_request_takes_its_notifications_with_it_and_a_dropped_one_is_never_kept() - { - var alice = await _host.Mastodon("alice"); - var stranger = await _host.Mastodon("stranger"); - var newcomer = await _host.Mastodon("newcomer"); - var post = (await alice.Status("like me")).Text("id"); - // (v1 speaks of filtering only) - Assert.True((await alice.Client.Put("/api/v1/notifications/policy", ("filter_not_following", "true"))).Ok().Body.Flag("filter_not_following")); - (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!; - - (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/dismiss")).Ok(); - - Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); - Assert.Empty(await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true")); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/requests/{request.Text("id")}")).Status); - - (await alice.Client.Patch("/api/v2/notifications/policy", ("for_new_accounts", "drop"))).Ok(); - (await newcomer.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); - Assert.Empty(await Notifiers(alice, $"?account_id={newcomer.Id}&include_filtered=true")); - Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); - } - - [Fact] - public async Task An_unasked_private_mention_is_filtered_and_one_answering_the_persona_is_not() - { - var alice = await _host.Mastodon("alice"); - var stranger = await _host.Mastodon("stranger"); - (await alice.Client.Patch("/api/v2/notifications/policy", ("for_private_mentions", "filter"))).Ok(); - var asked = (await alice.Status($"@{stranger.UserName} write to me", ("visibility", "direct"))).Text("id"); - - await stranger.Status($"@{alice.UserName} hello out of nowhere", ("visibility", "direct")); - await stranger.Status($"@{alice.UserName} as you asked", ("visibility", "direct"), ("in_reply_to_id", asked)); - - var mentions = (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n!["status"].Text("content")).ToList(); - Assert.Contains(mentions, c => c.Contains("as you asked")); - Assert.DoesNotContain(mentions, c => c.Contains("out of nowhere")); - Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); - } - } -} diff --git a/PrivaPub.Tests/Http/OAuthTests.cs b/PrivaPub.Tests/Http/OAuthTests.cs deleted file mode 100644 index 9a3e1e7..0000000 --- a/PrivaPub.Tests/Http/OAuthTests.cs +++ /dev/null @@ -1,346 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class OAuthTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name); - - static async Task SignedIn(HttpClient client, OAuthApp app) - { - var response = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); - return response.StatusCode == HttpStatusCode.OK; - } - - static async Task AssertMastodonError(HttpResponseMessage response, HttpStatusCode status) - { - Assert.Equal(status, response.StatusCode); - Assert.False(string.IsNullOrEmpty((await response.JsonBody())["error"]?.GetValue())); - } - - [Fact] - public async Task The_token_names_the_persona_and_never_the_root() - { - var persona = await NewPersona(); - var root = persona.Root; - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - - var exchanged = await client.Exchange(app, await client.Code(app, persona)); - var body = await exchanged.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, exchanged.StatusCode); - Assert.DoesNotContain(root.Id, body); - Assert.DoesNotContain(root.UserName, body); - var token = JsonNode.Parse(body)!["access_token"]!.GetValue(); - using var api = _host.As(token); - var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); - Assert.Contains($"\"id\":\"{persona.Id}\"", account); - Assert.DoesNotContain(root.Id, account); - Assert.DoesNotContain(root.UserName, account); - - var stored = await ClientApi.StoredTokens(app.ClientId); - Assert.NotEmpty(stored); - foreach (var entry in stored) - { - Assert.Equal(persona.Id, entry["subject"].AsString); - var raw = entry.ToString(); - var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty; - Assert.DoesNotContain(root.Id, raw + payload); - Assert.DoesNotContain(root.UserName, raw + payload); - } - var access = stored.Single(t => ClientApi.IsAccessToken(t)); - Assert.Equal(persona.Id, JsonNode.Parse(ClientApi.JwtPayload(access["payload"].AsString))!["sub"]!.GetValue()); - } - - [Fact] - public async Task A_wrong_password_shows_an_error_and_signs_nobody_in() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - - var response = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains("That username and password do not match.", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.DoesNotContain(response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies : Enumerable.Empty(), - c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal)); - var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); - Assert.StartsWith("/oauth/login?", authorize.Headers.Location!.OriginalString); - } - - [Fact] - public async Task A_login_without_the_antiforgery_token_is_refused() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - await client.GetStringAsync("/oauth/login", TestContext.Current.CancellationToken); - - var response = await client.Form("/oauth/login", ("returnUrl", "/oauth/authorize?x=1"), ("userName", persona.Root.UserName), ("password", persona.Root.Password)); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.False(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal))); - } - - [Theory] - [InlineData("https://evil.example/oauth/authorize?client_id=x")] - [InlineData("//evil.example/oauth/authorize?client_id=x")] - [InlineData("/\\evil.example/oauth/authorize?client_id=x")] - [InlineData("/somewhere/else")] - public async Task The_return_address_never_leaves_the_site(string returnUrl) - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - - var response = await client.SignIn(persona.Root, returnUrl); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.Equal("/oauth/authorize?signed_in=1", response.Headers.Location!.OriginalString); - } - - [Fact] - public async Task Denying_answers_access_denied_and_no_code() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); - - var response = await client.Choose(app, persona.Id, decision: "deny"); - var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Null(ClientApi.CodeIn(page)); - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - var location = response.Headers.Location!.OriginalString; - Assert.StartsWith(ClientApi.OutOfBand + "?error=access_denied", location); - Assert.DoesNotContain("code=", location); - Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); - } - - [Fact] - public async Task Choosing_another_roots_persona_shows_the_choice_again_without_a_code() - { - var mine = await NewPersona(); - var theirs = await NewPersona("theirs"); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(mine.Root, app.ReturnUrl())).StatusCode); - - var response = await client.Choose(app, theirs.Id); - var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(ClientApi.CodeIn(page)); - Assert.Contains(mine.Id, page); - Assert.DoesNotContain(theirs.Id, page); - Assert.DoesNotContain(theirs.UserName, page); - Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); - } - - [Fact] - public async Task A_banned_root_is_sent_back_to_the_login() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); - var choice = await client.Choice(app); - - await ClientApi.Ban(persona.Root.Id); - var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); - var chosen = await client.Decide(choice, persona.Id); - var again = await client.SignIn(persona.Root, app.ReturnUrl()); - - Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); - Assert.StartsWith("/oauth/login?returnUrl=", authorize.Headers.Location!.OriginalString); - Assert.Equal(HttpStatusCode.Redirect, chosen.StatusCode); - Assert.StartsWith("/oauth/login?returnUrl=", chosen.Headers.Location!.OriginalString); - Assert.Equal(HttpStatusCode.OK, again.StatusCode); - Assert.Contains("That username and password do not match.", await again.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task A_code_issued_before_a_ban_buys_no_token() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - var code = await client.Code(app, persona); - - await ClientApi.Ban(persona.Root.Id); - var response = await client.Exchange(app, code); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("invalid_grant", (await response.JsonBody())["error"]!.GetValue()); - } - - [Fact] - public async Task Force_login_asks_for_the_password_again() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); - Assert.True(await SignedIn(client, app)); - - var forced = await client.GetAsync(app.ReturnUrl("force_login=true"), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Redirect, forced.StatusCode); - var location = forced.Headers.Location!.OriginalString; - Assert.StartsWith("/oauth/login?returnUrl=", location); - var returnUrl = Uri.UnescapeDataString(location["/oauth/login?returnUrl=".Length..]); - Assert.Contains("force_login=true", returnUrl); - Assert.DoesNotContain("signed_in", returnUrl); - var signedIn = await client.SignIn(persona.Root, returnUrl); - Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); - Assert.EndsWith("&signed_in=1", signedIn.Headers.Location!.OriginalString); - var page = await client.GetAsync(signedIn.Headers.Location!.OriginalString, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, page.StatusCode); - Assert.Contains(persona.Id, await page.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task An_authorization_code_works_once() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - var code = await client.Code(app, persona); - - var first = await client.Exchange(app, code); - var second = await client.Exchange(app, code); - - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); - Assert.Equal("invalid_grant", (await second.JsonBody())["error"]!.GetValue()); - using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue()); - await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); - } - - [Theory] - [InlineData("password")] - [InlineData("refresh_token")] - public async Task Grants_other_than_code_and_client_credentials_are_refused(string grant) - { - var persona = await NewPersona(); - using var client = _host.Client(); - var app = await client.RegisterApp(); - - var response = await client.Form("/oauth/token", ("grant_type", grant), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), - ("username", persona.Root.UserName), ("password", persona.Root.Password), ("refresh_token", "anything"), ("scope", "read")); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.DoesNotContain("access_token", body); - Assert.Equal("unsupported_grant_type", JsonNode.Parse(body)!["error"]!.GetValue()); - } - - [Fact] - public async Task A_client_credentials_token_is_no_persona() - { - using var client = _host.Client(); - var app = await client.RegisterApp("read write"); - var response = await client.Form("/oauth/token", ("grant_type", "client_credentials"), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), ("scope", "read")); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var token = (await response.JsonBody())["access_token"]!.GetValue(); - using var api = _host.As(token); - - Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/apps/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); - await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); - await AssertMastodonError(await api.GetAsync("/api/v1/notifications", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task A_read_only_token_cannot_post() - { - var token = await _host.MastodonToken(await NewPersona(), "read"); - using var api = _host.As(token); - - var response = await api.Form("/api/v1/statuses", ("status", "nope")); - - await AssertMastodonError(response, HttpStatusCode.Forbidden); - Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); - } - - [Fact] - public async Task The_follow_scope_covers_reading_follows() - { - var token = await _host.MastodonToken(await NewPersona(), "follow"); - using var api = _host.As(token); - - Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/follow_requests", TestContext.Current.CancellationToken)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/blocks", TestContext.Current.CancellationToken)).StatusCode); - await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Forbidden); - } - - [Fact] - public async Task A_revoked_token_stops_working() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - var token = await client.Token(app, persona); - using var api = _host.As(token); - Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); - - var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", app.ClientId), ("client_secret", app.ClientSecret)); - - Assert.Equal(HttpStatusCode.OK, revoked.StatusCode); - await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task The_login_and_authorize_pages_are_hardened() - { - var persona = await NewPersona(); - using var client = _host.Client(cookies: true); - var app = await client.RegisterApp(); - - var login = await client.GetAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(app.ReturnUrl()), TestContext.Current.CancellationToken); - var failed = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); - var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); - - foreach (var response in new[] { login, failed, authorize }) - { - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var csp = string.Join(";", response.Headers.GetValues("Content-Security-Policy")); - Assert.Contains("default-src 'none'", csp); - Assert.Contains("frame-ancestors 'none'", csp); - Assert.Contains("no-store", response.Headers.CacheControl!.ToString()); - } - } - - [Fact] - public async Task The_eleventh_login_from_one_address_in_a_minute_is_refused() - { - using var client = _host.ClientAt("198.51.100.12"); - for (var attempt = 1; attempt <= 10; attempt++) - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken)).StatusCode); - - var eleventh = await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken); - using var elsewhere = _host.ClientAt("198.51.100.13"); - var other = await elsewhere.GetAsync("/oauth/login", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode); - Assert.Equal(HttpStatusCode.OK, other.StatusCode); - } - } -} diff --git a/PrivaPub.Tests/Http/OneAnswerTests.cs b/PrivaPub.Tests/Http/OneAnswerTests.cs deleted file mode 100644 index afde234..0000000 --- a/PrivaPub.Tests/Http/OneAnswerTests.cs +++ /dev/null @@ -1,270 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Federation; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.Host.FederationHelpers; - -using PostEntity = PrivaPub.Models.Post.Post; - -namespace PrivaPub.Tests.Http -{ - // Two views of the same fact give one answer (owner decision 2026-10-04: fix the mismatches). - [Trait("Category", "Integration")] - public sealed class OneAnswerTests : IAsyncLifetime - { - PrivaPubHost _host; - HttpClient _client; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - _client?.Dispose(); - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task Anyone_may_search_and_only_a_signed_in_reader_resolves_or_pages() - { - var reader = await _host.Mastodon("searcher"); - - Assert.Equal(HttpStatusCode.OK, (await _client.Get("/api/v2/search?q=somebody")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&resolve=true")).Status); - Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&offset=5")).Status); - Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v2/search?q=somebody&offset=5")).Status); - } - - [Fact] - public async Task A_remote_account_that_deletes_itself_leaves_nothing_in_the_counts() - { - var token = TestContext.Current.CancellationToken; - var author = await _host.Mastodon("liked"); - var post = await author.Status("like me"); - var fan = new RemoteActor(_peer, "fickle"); - var like = new JsonObject - { - ["id"] = $"{fan.Id}#likes/{Guid.NewGuid():N}", - ["type"] = "Like", - ["actor"] = fan.Id, - ["object"] = post.Text("uri") - }; - Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", like), token)).StatusCode); - await _host.RunInbox(like["id"]!.GetValue(), token); - Assert.Equal(1, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); - - var delete = new JsonObject - { - ["id"] = $"{fan.Id}#delete", - ["type"] = "Delete", - ["actor"] = fan.Id, - ["object"] = fan.Id, - ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public") - }; - Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", delete), token)).StatusCode); - await _host.RunInbox(delete["id"]!.GetValue(), token); - - Assert.Equal(0, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); - Assert.Empty((await author.Client.Get($"/api/v1/statuses/{post.Text("id")}/favourited_by")).Ok().Array); - Assert.False(await DB.Default.Find().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(n => n.FromActorURI == fan.Id).ExecuteAnyAsync(token)); - Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); - } - - [Fact] - public async Task Only_public_and_unlisted_replies_are_counted() - { - var author = await _host.Mastodon("threadstart"); - var replier = await _host.Mastodon("replier"); - var root = await author.Status("say something"); - var id = root.Text("id"); - - await replier.Status("in public", ("in_reply_to_id", id)); - await replier.Status("quietly", ("in_reply_to_id", id), ("visibility", "unlisted")); - await replier.Status("for my followers", ("in_reply_to_id", id), ("visibility", "private")); - await replier.Status($"@{author.UserName} just you", ("in_reply_to_id", id), ("visibility", "direct")); - - Assert.Equal(2, (await author.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Number("replies_count")); - } - - [Fact] - public async Task A_status_longer_than_advertised_is_refused() - { - var author = await _host.Mastodon("wordy"); - var limit = (await _client.Get("/api/v2/instance")).Ok().Body["configuration"]!["statuses"].Number("max_characters"); - - var refused = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit + 1))); - var accepted = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit))); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status); - Assert.Equal(HttpStatusCode.OK, accepted.Status); - } - - [Fact] - public async Task Joining_a_community_by_invitation_is_following_it() - { - var token = TestContext.Current.CancellationToken; - var owner = await _host.Persona(await _host.SignUp(), "commowner"); - var joinerRoot = await _host.SignUp("joiner"); - var joiner = await _host.Persona(joinerRoot, "joiner"); - var group = await _host.Group(owner, community: true); - var groupName = group["userName"]!.GetValue(); - using var client = _host.As(joinerRoot.Jwt); - - var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue() }); - - Assert.Equal(HttpStatusCode.OK, joined.StatusCode); - Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue()); - Assert.True(await DB.Default.Find().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)); - } - - public static TheoryData AnsweredRoutes() => new() - { - "/api/v1/directory", - "/api/v1/tags/cats", - "/api/v1/instance/languages", - "/api/v1/instance/translation_languages", - "/api/v1/instance/domain_blocks", - "/api/v1/instance/privacy_policy", - "/api/v1/timelines/link?url=https%3A%2F%2Fexample.org%2F", - "/api/v1/accounts/000000000000000000000000/identity_proofs" - }; - - [Theory] - [MemberData(nameof(AnsweredRoutes))] - public async Task Mastodon_routes_we_have_nothing_behind_answer_instead_of_404(string path) - { - Assert.Equal(HttpStatusCode.OK, (await _client.Get(path)).Status); - } - - [Fact] - public async Task Likes_of_one_post_come_as_one_group_of_notifications() - { - var author = await _host.Mastodon("grouped"); - var first = await _host.Mastodon("fanone"); - var second = await _host.Mastodon("fantwo"); - var post = await author.Status("like me twice"); - var id = post.Text("id"); - await first.Client.Post($"/api/v1/statuses/{id}/favourite"); - await second.Client.Post($"/api/v1/statuses/{id}/favourite"); - - var list = (await author.Client.Get("/api/v2/notifications")).Ok().Body; - var group = Assert.Single(list["notification_groups"]!.AsArray(), g => g!.Text("type") == "favourite")!; - var key = group.Text("group_key"); - - Assert.Equal($"grouped-favourite-{id}", key); - // told alone (v1, a stream), each notification names the group it belongs to - Assert.All((await author.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array, n => Assert.Equal(key, n.Text("group_key"))); - Assert.Equal(2, group.Number("notifications_count")); - Assert.Equal(2, group["sample_account_ids"]!.AsArray().Count); - Assert.Equal(id, group.Text("status_id")); - Assert.Contains(list["statuses"]!.AsArray(), s => s!.Text("id") == id); - Assert.True((await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count") >= 1); - Assert.Equal(key, Assert.Single((await author.Client.Get($"/api/v2/notifications/{key}")).Ok().Body["notification_groups"]!.AsArray())!.Text("group_key")); - Assert.Equal(2, (await author.Client.Get($"/api/v2/notifications/{key}/accounts")).Ok().Array.Count); - // a notifications marker past them reads them - var newest = (await author.Client.Get("/api/v1/notifications")).Ok().Ids.First(); - (await author.Client.Post("/api/v1/markers", ("notifications[last_read_id]", newest))).Ok(); - Assert.Equal(0, (await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count")); - Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); - Assert.Equal(HttpStatusCode.OK, (await author.Client.Post($"/api/v2/notifications/{key}/dismiss")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await author.Client.Get($"/api/v2/notifications/{key}")).Status); - } - - [Fact] - public async Task A_hashtag_link_opens_a_page_of_public_posts_with_that_tag() - { - var author = await _host.Mastodon("tagger"); - var tag = $"tag{Guid.NewGuid():N}"[..16]; - await author.Status($"about #{tag}"); - await author.Status($"quietly about #{tag}", ("visibility", "private")); - - var page = await _client.Fetch($"/tags/{tag}", Browser); - - Assert.Equal(HttpStatusCode.OK, page.Status); - Assert.Contains("about", page.Text); - Assert.DoesNotContain("quietly", page.Text); - Assert.Contains("default-src 'none'", page.Response.Headers.GetValues("Content-Security-Policy").Single()); - } - - [Fact] - public async Task A_communitys_announces_resolve_while_the_post_is_shown() - { - var token = TestContext.Current.CancellationToken; - var owner = await _host.Persona(await _host.SignUp(), "announcer"); - var community = await _host.FederatedGroup(owner, community: true); - var post = await _host.Publish(owner, new PrivaPub.Domain.Statuses.StatusDraft { Text = "into the community", PlainText = true, GroupId = community.Id }); - var kept = await DB.Default.Find().Match(a => a.GroupId == community.Id && a.ObjectURI == post.ObjectURI).ExecuteAsync(token); - Assert.Equal(2, kept.Count);//the activity, and the object for Mastodon - - foreach (var announcement in kept) - { - var fetched = await _client.Fetch(PathOf(announcement.ActivityURI)); - Assert.Equal(HttpStatusCode.OK, fetched.Status); - Assert.Equal("Announce", fetched.Json["type"]!.GetValue()); - Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue()); - } - var listed = await _client.Fetch($"/peasants/{community.UserName}/grunts/announce-{post.ID}"); - Assert.Equal(HttpStatusCode.OK, listed.Status); - - await _host.Remove(owner, post); - Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(PathOf(kept[0].ActivityURI))).Status); - } - - [Fact] - public async Task A_boost_points_at_the_boosted_posts_page() - { - var author = await _host.Mastodon("boosted"); - var booster = await _host.Mastodon("booster"); - var post = await author.Status("boost me"); - - var boost = (await booster.Client.Post($"/api/v1/statuses/{post.Text("id")}/reblog")).Ok().Body; - - Assert.Equal(post.Text("url"), boost.Text("url")); - var browser = await _client.Fetch(PathOf(boost.Text("uri")), Browser); - Assert.Equal(HttpStatusCode.Redirect, browser.Status); - } - - [Fact] - public async Task A_remote_accounts_counts_are_what_its_server_publishes() - { - var token = TestContext.Current.CancellationToken; - var reader = await _host.Mastodon("counter"); - var popular = new RemoteActor(_peer, "popular"); - _peer.Serve(new Uri(popular.Id).AbsolutePath + "/followers", - $$"""{"@context":"https://www.w3.org/ns/activitystreams","id":"{{popular.Id}}/followers","type":"OrderedCollection","totalItems":42}"""); - var remote = _host.Get(); - var account = await remote.GetActor(popular.Id, refresh: true, token); - - await _host.Run(j => j.Kind == PrivaPub.Models.Jobs.JobKind.CountAccount && j.Payload == popular.Id, token); - - var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body; - Assert.Equal(42, shown.Number("followers_count")); - Assert.Equal(0, shown.Number("following_count")); - } - - [Fact] - public async Task Notification_policy_and_requests_answer_a_signed_in_reader() - { - var reader = await _host.Mastodon("policy"); - - Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v1/notifications/policy")).Status); - Assert.Equal("accept", (await reader.Client.Get("/api/v2/notifications/policy")).Ok().Body.Text("for_not_following")); - Assert.Empty((await reader.Client.Get("/api/v1/notifications/requests")).Ok().Array); - } - } -} diff --git a/PrivaPub.Tests/Http/PersonaArchiveTests.cs b/PrivaPub.Tests/Http/PersonaArchiveTests.cs deleted file mode 100644 index 5ff829a..0000000 --- a/PrivaPub.Tests/Http/PersonaArchiveTests.cs +++ /dev/null @@ -1,346 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Media; -using PrivaPub.Models.Social; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.IO.Compression; -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json.Nodes; - -using PostEntity = PrivaPub.Models.Post.Post; - -namespace PrivaPub.Tests.Http -{ - // A persona's archive (owner decision 2026-10-07): exported in Mastodon's layout with nothing of its root, its siblings - // or its keys; imported back home without bringing anything back, or into another persona as posts nobody is told of, - // once; Mastodon's own archives read too; and hostile ones refused. - [Trait("Category", "Integration")] - public sealed class PersonaArchiveTests : IAsyncLifetime - { - PrivaPubHost _host; - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static string Base(Persona persona) => $"/clientapi/persona/{persona.Id}/archive"; - - async Task Status(Persona persona) - { - using var client = _host.As(persona.Root.Jwt); - return (await client.GetFromJsonAsync(Base(persona), Token))!; - } - - async Task Exported(Persona persona) - { - using var client = _host.As(persona.Root.Jwt); - Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsync(Base(persona), null, Token)).StatusCode); - await _host.Run(j => j.Kind == JobKind.ExportArchive && j.Payload == persona.Id, Token); - Assert.Equal("ready", (await Status(persona))["exportState"]!.GetValue()); - var ticket = (await (await client.PostAsync(Base(persona) + "/ticket", null, Token)).Content.ReadFromJsonAsync(Token))!; - using var anonymous = _host.Client(); - var response = await anonymous.GetAsync(ticket["url"]!.GetValue(), Token); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadAsByteArrayAsync(Token); - } - - // uploaded in two pieces, then imported with the parts asked for, the job run: the import's state - async Task<(HttpStatusCode Status, JsonObject State)> Imported(Persona persona, byte[] archive, params string[] parts) - { - using var client = _host.As(persona.Root.Jwt); - var upload = (await (await client.PostAsync("/clientapi/persona/archive/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - var half = archive.Length / 2; - Assert.Equal(HttpStatusCode.OK, (await client.PutAsync($"/clientapi/persona/archive/uploads/{upload}?offset=0", new ByteArrayContent(archive[..half]), Token)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await client.PutAsync($"/clientapi/persona/archive/uploads/{upload}?offset={half}", new ByteArrayContent(archive[half..]), Token)).StatusCode); - var asked = await client.PostAsJsonAsync(Base(persona) + "/import", new { uploadId = upload, parts }, Token); - if (asked.StatusCode != HttpStatusCode.Accepted) - return (asked.StatusCode, default); - await _host.Run(j => j.Kind == JobKind.ImportArchive && j.Payload == persona.Id, Token); - return (asked.StatusCode, await Status(persona)); - } - - static int Count(JsonObject state, string what) => state["importCounts"]?[what]?.GetValue() ?? 0; - - static Dictionary Texts(byte[] zip) - { - using var archive = new ZipArchive(new MemoryStream(zip)); - return archive.Entries.Where(e => e.Name.EndsWith(".json") || e.Name.EndsWith(".csv")) - .ToDictionary(e => e.FullName, e => new StreamReader(e.Open()).ReadToEnd()); - } - - [Fact] - public async Task An_archive_holds_the_persona_and_nothing_of_its_root_its_siblings_or_its_keys() - { - var alice = await _host.Mastodon("alice"); - var sibling = await _host.Persona(alice.Persona.Root, "sibling"); - var bob = await _host.Mastodon("bob"); - var picture = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") - { - Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(64, 48), "image/jpeg", "dot.jpg")) - })).Ok().Body.Text("id"); - var first = await alice.Status("a public post with a picture #archives", ("media_ids[]", picture)); - await alice.Status("for my followers only", ("visibility", "private")); - await alice.Status("a reply to myself", ("in_reply_to_id", first.Text("id"))); - await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/bookmark"); - await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/pin"); - await alice.Client.Post($"/api/v1/accounts/{bob.Persona.Id}/block"); - await alice.Client.Post("/api/v1/domain_blocks", ("domain", "spam.example")); - await alice.Client.Post("/api/v1/tags/archives/follow"); - - var zip = await Exported(alice.Persona); - var texts = Texts(zip); - - foreach (var file in new[] { "actor.json", "outbox.json", "likes.json", "bookmarks.json", "following_accounts.csv", "blocked_accounts.csv", - "muted_accounts.csv", "blocked_domains.csv", "lists.csv", "bookmarks.csv", "privapub/archive.json", "privapub/pins.json", - "privapub/followed_tags.json", "privapub/filters.json", "privapub/notification_policy.json" }) - Assert.True(texts.ContainsKey(file), $"{file} is missing"); - using (var archive = new ZipArchive(new MemoryStream(zip))) - Assert.Contains(archive.Entries, e => e.FullName.StartsWith("media_attachments/files/", StringComparison.Ordinal)); - var outbox = JsonNode.Parse(texts["outbox.json"])!["orderedItems"]!.AsArray(); - Assert.Equal(3, outbox.Count); - Assert.Contains(outbox, i => i!["object"]!["attachment"]?[0]?["url"]?.GetValue().StartsWith("/media_attachments/files/") == true); - Assert.Contains(bob.Persona.UserName, texts["blocked_accounts.csv"]); - Assert.Contains("spam.example", texts["blocked_domains.csv"]); - Assert.Contains(first.Text("uri"), texts["privapub/pins.json"]); - - var avatar = await DB.Default.Find().MatchID(alice.Persona.Id).ExecuteFirstAsync(Token); - var root = await DB.Default.Find().MatchID(alice.Persona.Root.Id).ExecuteFirstAsync(Token); - var all = string.Concat(texts.Values); - Assert.DoesNotContain(avatar.PrivateKey.Split('\n')[1], all); - Assert.DoesNotContain(avatar.SigningKey, all); - Assert.DoesNotContain(root.UserName, all); - Assert.DoesNotContain(root.HashedPassword, all); - Assert.DoesNotContain(sibling.UserName, all); - Assert.DoesNotContain(alice.Token, all); - } - - [Fact] - public async Task Back_home_an_archive_brings_nothing_back() - { - var alice = await _host.Mastodon("alice"); - await alice.Status("kept"); - var regretted = await alice.Status("regretted"); - var zip = await Exported(alice.Persona); - await alice.Client.Delete($"/api/v1/statuses/{regretted.Text("id")}"); - var before = await DB.Default.CountAsync(p => p.GroupUserId == alice.Persona.Id, Token); - - var (status, state) = await Imported(alice.Persona, zip, "posts", "pins"); - - Assert.Equal(HttpStatusCode.Accepted, status); - Assert.Equal("done", state["importState"]!.GetValue()); - Assert.True(state["importSameActor"]!.GetValue()); - Assert.Equal((0, 1, 1), (Count(state, "posts"), Count(state, "posts already here"), Count(state, "posts deleted here"))); - Assert.Equal(before, await DB.Default.CountAsync(p => p.GroupUserId == alice.Persona.Id, Token)); - Assert.NotNull((await DB.Default.Find().MatchID(regretted.Text("id")).ExecuteFirstAsync(Token)).DeletedAt); - } - - [Fact] - public async Task Into_another_persona_posts_arrive_once_and_nobody_is_told() - { - var alice = await _host.Mastodon("alice"); - var follower = await _host.Mastodon("follower"); - var picture = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") - { - Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(64, 48), "image/jpeg", "dot.jpg")) - })).Ok().Body.Text("id"); - var first = await alice.Status("the first words #moving", ("media_ids[]", picture)); - await alice.Status("and a reply to them", ("in_reply_to_id", first.Text("id"))); - await _host.Located(alice, "here and nowhere else"); - var zip = await Exported(alice.Persona); - - var carol = await _host.Mastodon("carol"); - await follower.Client.Post($"/api/v1/accounts/{carol.Persona.Id}/follow"); - var actor = $"/peasants/{carol.Persona.UserName}"; - var jobs = await DB.Default.CountAsync(j => j.Kind == JobKind.Deliver && j.Payload.Contains(actor), Token); - var (_, state) = await Imported(carol.Persona, zip, "posts", "pins", "tags", "located"); - - Assert.Equal("done", state["importState"]!.GetValue()); - Assert.False(state["importSameActor"]!.GetValue()); - Assert.Equal(2, Count(state, "posts")); - Assert.Equal(1, Count(state, "media")); - Assert.Equal(1, Count(state, "located posts")); - var copies = await DB.Default.Find().Match(p => p.GroupUserId == carol.Persona.Id && p.Visibility != Models.Post.PostVisibility.LocalGeo).ExecuteAsync(Token); - Assert.Equal(2, copies.Count); - var copy = copies.Single(p => p.ImportedFromURI == first.Text("uri")); - Assert.StartsWith($"{PrivaPubHost.Base}/peasants/{carol.Persona.UserName}/scribbles/", copy.ObjectURI); - Assert.Equal(["moving"], copy.Tags); - Assert.Single(copy.Media); - Assert.Equal(carol.Persona.Id, (await DB.Default.Find().MatchID(copy.Media[0].AttachmentId).ExecuteFirstAsync(Token)).OwnerAvatarId); - var reply = copies.Single(p => p.ID != copy.ID); - Assert.Equal(copy.ID, reply.AnsweringToPostId); - Assert.Equal(copy.ObjectURI + "/context", reply.ContextURI); - - // nobody told: no delivery, no home, no notification; on the profile all the same - Assert.Equal(jobs, await DB.Default.CountAsync(j => j.Kind == JobKind.Deliver && j.Payload.Contains(actor), Token)); - var ids = copies.Select(p => p.ID).ToList(); - Assert.False(await DB.Default.Find().Match(e => ids.Contains(e.PostId)).ExecuteAnyAsync(Token)); - Assert.Contains((await carol.Client.Get($"/api/v1/accounts/{carol.Persona.Id}/statuses")).Ok().Body!.AsArray(), s => s!.Text("id") == copy.ID); - - // twice changes nothing - var (_, again) = await Imported(carol.Persona, zip, "posts", "located"); - Assert.Equal((0, 2, 1), (Count(again, "posts"), Count(again, "posts already here"), Count(again, "located posts already here"))); - Assert.Equal(3, await DB.Default.CountAsync(p => p.GroupUserId == carol.Persona.Id, Token)); - } - - // Mastodon's own archive: its posts, not its boosts nor its direct messages - [Fact] - public async Task A_mastodon_archive_is_read() - { - var dave = await _host.Mastodon("dave"); - const string actor = "https://mastodon.example/users/dave"; - const string Public = "https://www.w3.org/ns/activitystreams#Public"; - var outbox = new JsonObject - { - ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = "outbox.json", - ["type"] = "OrderedCollection", - ["orderedItems"] = new JsonArray( - Create(actor, "1", "

hello from Mastodon

", [Public], [actor + "/followers"], "2023-04-01T10:00:00Z"), - Create(actor, "2", "

only for one

", ["https://elsewhere.example/users/x"], [], "2023-04-02T10:00:00Z"), - new JsonObject { ["type"] = "Announce", ["actor"] = actor, ["object"] = "https://elsewhere.example/notes/9" }) - }; - var zip = Zip(("actor.json", new JsonObject { ["id"] = actor, ["type"] = "Person", ["followers"] = actor + "/followers" }.ToJsonString()), - ("outbox.json", outbox.ToJsonString())); - - var (_, state) = await Imported(dave.Persona, zip, "posts"); - - Assert.Equal("done", state["importState"]!.GetValue()); - Assert.Equal((1, 1, 1), (Count(state, "posts"), Count(state, "boosts skipped"), Count(state, "direct or circle posts skipped"))); - var post = await DB.Default.Find().Match(p => p.GroupUserId == dave.Persona.Id).ExecuteFirstAsync(Token); - Assert.Equal(actor + "/statuses/1", post.ImportedFromURI); - Assert.Equal(new DateTime(2023, 4, 1, 10, 0, 0, DateTimeKind.Utc), post.CreationDate); - Assert.DoesNotContain(" new() - { - ["type"] = "Create", - ["actor"] = actor, - ["object"] = new JsonObject - { - ["id"] = $"{actor}/statuses/{id}", - ["type"] = "Note", - ["attributedTo"] = actor, - ["content"] = content, - ["published"] = published, - ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()), - ["cc"] = new JsonArray(cc.Select(c => (JsonNode)c).ToArray()) - } - }; - - static byte[] Zip(params (string Name, string Text)[] files) - { - var stream = new MemoryStream(); - using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) - foreach (var (name, text) in files) - { - using var entry = archive.CreateEntry(name).Open(); - entry.Write(Encoding.UTF8.GetBytes(text)); - } - return stream.ToArray(); - } - - [Theory] - [InlineData("escape")] - [InlineData("twice")] - [InlineData("bomb")] - [InlineData("link")] - [InlineData("junk")] - public async Task A_hostile_archive_is_refused(string how) - { - var erin = await _host.Mastodon("erin"); - var actorJson = new JsonObject { ["id"] = "https://elsewhere.example/users/erin" }.ToJsonString(); - var zip = how switch - { - "escape" => Zip(("actor.json", actorJson), ("../escape.txt", "x")), - "twice" => Zip(("actor.json", actorJson), ("outbox.json", "{}"), ("outbox.json", "{}")), - "bomb" => Zip(("actor.json", actorJson), ("outbox.json", new string(' ', 30 * 1024 * 1024))), - "link" => Link(actorJson), - _ => "not a zip at all"u8.ToArray() - }; - - var (status, _) = await Imported(erin.Persona, zip, "posts"); - - Assert.Equal(HttpStatusCode.UnprocessableEntity, status); - Assert.False(await DB.Default.Find().Match(p => p.GroupUserId == erin.Persona.Id).ExecuteAnyAsync(Token)); - } - - // a symbolic link, as zip on Unix stores one - static byte[] Link(string actorJson) - { - var stream = new MemoryStream(); - using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) - { - using (var actor = archive.CreateEntry("actor.json").Open()) - actor.Write(Encoding.UTF8.GetBytes(actorJson)); - var link = archive.CreateEntry("media_attachments/files/passwd"); - link.ExternalAttributes = unchecked((int)(0xA1FFu << 16)); - using var target = link.Open(); - target.Write("/etc/passwd"u8); - } - return stream.ToArray(); - } - - [Fact] - public async Task An_outbox_item_too_large_stops_the_import() - { - var frank = await _host.Mastodon("frank"); - const string actor = "https://elsewhere.example/users/frank"; - // (random, so it is no compression bomb: that is refused before the import starts) - var words = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(1536 * 1024)); - var huge = Create(actor, "1", words, ["https://www.w3.org/ns/activitystreams#Public"], [], "2024-01-01T00:00:00Z"); - var zip = Zip(("actor.json", new JsonObject { ["id"] = actor }.ToJsonString()), - ("outbox.json", new JsonObject { ["orderedItems"] = new JsonArray(huge) }.ToJsonString())); - - var (_, state) = await Imported(frank.Persona, zip, "posts"); - - Assert.Equal("failed", state["importState"]!.GetValue()); - Assert.Contains("larger than", state["importError"]!.GetValue()); - } - - [Fact] - public async Task Only_the_personas_own_root_and_never_a_banned_one() - { - var grace = await _host.Mastodon("grace"); - var stranger = await _host.SignUp("stranger"); - using (var other = _host.As(stranger.Jwt)) - Assert.Equal(HttpStatusCode.NotFound, (await other.GetAsync(Base(grace.Persona), Token)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await _host.As(grace.Persona.Root.Jwt).GetAsync(Base(grace.Persona), Token)).StatusCode); - await DB.Default.Update().MatchID(grace.Persona.Root.Id).Modify(u => u.IsBanned, true).ExecuteAsync(Token); - Assert.NotEqual(HttpStatusCode.OK, (await _host.As(grace.Persona.Root.Jwt).GetAsync(Base(grace.Persona), Token)).StatusCode); - } - - [Fact] - public async Task An_import_asked_to_stop_stops() - { - var henry = await _host.Mastodon("henry"); - const string actor = "https://elsewhere.example/users/henry"; - var zip = Zip(("actor.json", new JsonObject { ["id"] = actor }.ToJsonString()), - ("outbox.json", new JsonObject { ["orderedItems"] = new JsonArray(Create(actor, "1", "one", ["https://www.w3.org/ns/activitystreams#Public"], [], "2024-01-01T00:00:00Z")) }.ToJsonString())); - using var client = _host.As(henry.Persona.Root.Jwt); - var upload = (await (await client.PostAsync("/clientapi/persona/archive/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); - await client.PutAsync($"/clientapi/persona/archive/uploads/{upload}?offset=0", new ByteArrayContent(zip), Token); - Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsJsonAsync(Base(henry.Persona) + "/import", new { uploadId = upload, parts = new[] { "posts" } }, Token)).StatusCode); - Assert.Equal(HttpStatusCode.Conflict, (await client.PostAsJsonAsync(Base(henry.Persona) + "/import", new { uploadId = upload, parts = new[] { "posts" } }, Token)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await client.PostAsync(Base(henry.Persona) + "/import/stop", null, Token)).StatusCode); - - await _host.Run(j => j.Kind == JobKind.ImportArchive && j.Payload == henry.Persona.Id, Token); - - Assert.Equal("stopped", (await Status(henry.Persona))["importState"]!.GetValue()); - Assert.False(await DB.Default.Find().Match(p => p.GroupUserId == henry.Persona.Id).ExecuteAnyAsync(Token)); - } - } -} diff --git a/PrivaPub.Tests/Http/PersonaExchangeTests.cs b/PrivaPub.Tests/Http/PersonaExchangeTests.cs deleted file mode 100644 index b2cfda0..0000000 --- a/PrivaPub.Tests/Http/PersonaExchangeTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; - -using PrivaPub.Api.Mastodon.Auth; -using PrivaPub.Services; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Security.Claims; -using System.Text; - -namespace PrivaPub.Tests.Http -{ - // The first-party client's one sign-in: the /clientapi JWT exchanged for one persona's Mastodon token (PersonaExchange). - [Trait("Category", "Integration")] - public sealed class PersonaExchangeTests : IAsyncLifetime - { - const string Grant = "urn:ietf:params:oauth:grant-type:token-exchange"; - const string FirstParty = "decepub"; - - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task Exchange(string jwt, string avatarId, string clientId = FirstParty, string clientSecret = default, - string subjectTokenType = PersonaExchange.SubjectTokenType) - { - var fields = new List<(string, string)> - { - ("grant_type", Grant), - ("client_id", clientId), - ("subject_token", jwt), - ("subject_token_type", subjectTokenType), - (PersonaExchange.AvatarParameter, avatarId), - ("scope", "read write follow") - }; - if (clientSecret != default) - fields.Add(("client_secret", clientSecret)); - using var client = _host.Client(); - return await client.Form("/oauth/token", fields.Where(f => f.Item2 != default).ToArray()); - } - - async Task Token(Persona persona) - { - var response = await Exchange(persona.Root.Jwt, persona.Id); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return (await response.JsonBody())["access_token"]!.GetValue(); - } - - static async Task AssertRefused(HttpResponseMessage response, string error = "invalid_grant") - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(error, (await response.JsonBody())["error"]?.GetValue()); - } - - async Task Me(string token) - { - using var api = _host.As(token); - return (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode; - } - - static string Jwt(string rootId, string key, DateTime expires) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor - { - Issuer = PrivaPubHost.Base, - Audience = PrivaPubHost.Base, - Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.UserData, rootId) }), - NotBefore = expires.AddHours(-2), - IssuedAt = expires.AddHours(-2), - Expires = expires, - SigningCredentials = new(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha512) - }); - - [Fact] - public async Task The_root_jwt_becomes_one_personas_token_that_never_names_the_root() - { - var persona = await _host.Persona(await _host.SignUp(), "exchange"); - var root = persona.Root; - - var response = await Exchange(root.Jwt, persona.Id); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.DoesNotContain(root.Id, body); - Assert.DoesNotContain(root.UserName, body); - var token = (await response.JsonBody())["access_token"]!.GetValue(); - using var api = _host.As(token); - var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); - Assert.Contains($"\"id\":\"{persona.Id}\"", account); - Assert.DoesNotContain(root.Id, account); - foreach (var entry in await ClientApi.StoredTokens(FirstParty)) - { - var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty; - Assert.DoesNotContain(root.Id, entry.ToString() + payload); - Assert.DoesNotContain(root.UserName, entry.ToString() + payload); - } - } - - [Fact] - public async Task Each_persona_of_the_root_gets_its_own_token() - { - var root = await _host.SignUp(); - var first = await _host.Persona(root, "first"); - var second = await _host.Persona(root, "second"); - - using var firstApi = _host.As(await Token(first)); - using var secondApi = _host.As(await Token(second)); - - Assert.Contains($"\"id\":\"{first.Id}\"", await firstApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)); - Assert.Contains($"\"id\":\"{second.Id}\"", await secondApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Another_roots_persona_is_refused() - { - var mine = await _host.SignUp(); - var theirs = await _host.Persona(await _host.SignUp(), "theirs"); - - await AssertRefused(await Exchange(mine.Jwt, theirs.Id)); - await AssertRefused(await Exchange(mine.Jwt, "000000000000000000000000")); - await AssertRefused(await Exchange(mine.Jwt, "not an id")); - } - - [Fact] - public async Task A_token_that_is_not_a_valid_root_jwt_is_refused() - { - var persona = await _host.Persona(await _host.SignUp(), "forged"); - var key = _host.Services.GetRequiredService()["AppConfiguration:Jwt:Key"]!; - - await AssertRefused(await Exchange("garbage", persona.Id)); - await AssertRefused(await Exchange(Jwt(persona.Root.Id, key, DateTime.UtcNow.AddMinutes(-10)), persona.Id)); - await AssertRefused(await Exchange(Jwt(persona.Root.Id, new string('k', 64), DateTime.UtcNow.AddHours(1)), persona.Id)); - } - - [Fact] - public async Task Ended_sessions_refuse_the_jwt_and_revoke_the_exchanged_tokens() - { - var persona = await _host.Persona(await _host.SignUp(), "ended"); - var token = await Token(persona); - Assert.Equal(HttpStatusCode.OK, await Me(token)); - - using (var scope = _host.Services.CreateScope()) - await scope.ServiceProvider.GetRequiredService().Revoke(persona.Root.Id, TestContext.Current.CancellationToken); - - await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id)); - Assert.Equal(HttpStatusCode.Unauthorized, await Me(token)); - } - - [Fact] - public async Task A_banned_root_is_refused() - { - var persona = await _host.Persona(await _host.SignUp(), "banned"); - await ClientApi.Ban(persona.Root.Id); - try - { - await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id)); - } - finally - { - await ClientApi.Ban(persona.Root.Id, banned: false); - } - } - - [Fact] - public async Task A_revoked_token_no_longer_works() - { - var persona = await _host.Persona(await _host.SignUp(), "revoked"); - var token = await Token(persona); - - using var client = _host.Client(); - var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", FirstParty)); - - Assert.Equal(HttpStatusCode.OK, revoked.StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, await Me(token)); - } - - [Fact] - public async Task Only_the_first_party_client_may_exchange() - { - var persona = await _host.Persona(await _host.SignUp(), "thirdparty"); - using var client = _host.Client(); - var app = await client.RegisterApp(); - - var response = await Exchange(persona.Root.Jwt, persona.Id, app.ClientId, app.ClientSecret); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("unauthorized_client", (await response.JsonBody())["error"]?.GetValue()); - } - - [Fact] - public async Task A_mastodon_token_is_not_a_subject_token() - { - var persona = await _host.Persona(await _host.SignUp(), "mastodon"); - var token = await Token(persona); - - await AssertRefused(await Exchange(token, persona.Id, subjectTokenType: "urn:ietf:params:oauth:token-type:access_token"), "invalid_request"); - await AssertRefused(await Exchange(token, persona.Id)); - } - - [Fact] - public async Task Missing_parameters_are_a_client_error() - { - var persona = await _host.Persona(await _host.SignUp(), "missing"); - - await AssertRefused(await Exchange(persona.Root.Jwt, default)); - await AssertRefused(await Exchange(default, persona.Id), "invalid_request"); - await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id, subjectTokenType: default), "invalid_request"); - } - } -} diff --git a/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs b/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs deleted file mode 100644 index d0739fd..0000000 --- a/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs +++ /dev/null @@ -1,261 +0,0 @@ -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.Extensions.DependencyInjection; - -using MongoDB.Entities; - -using PrivaPub.ClientModels.Social; -using PrivaPub.Domain.Social; -using PrivaPub.Domain.Statuses; -using PrivaPub.Models.Post; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -using static PrivaPub.Tests.Support.Host.FederationHelpers; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed partial class PersonaSeparationHttpTests : IAsyncLifetime - { - const string Base = PrivaPubHost.Base; - const int MaxDocuments = 300; - - PrivaPubHost _host; - Root _root; - Persona _alpha; - Persona _bravo; - Persona _charlie; - LocalGroup _community; - string _tag; - Post _shown; - Post _poll; - List _alphaPosts; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - var token = TestContext.Current.CancellationToken; - _host = await PrivaPubHost.Shared(); - _root = await _host.SignUp("sep"); - _alpha = await _host.Persona(_root, "sepalpha"); - _bravo = await _host.Persona(_root, "sepbravo"); - _charlie = await _host.Persona(await _host.SignUp(), "sepcharlie"); - _community = await _host.FederatedGroup(_bravo, community: true, "sepcommunity"); - _tag = $"septag{Guid.NewGuid():N}"[..14]; - - await _host.Publish(_bravo, "the sibling's own words", PostVisibility.FollowersOnly); - var follows = _host.Get(); - Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _bravo.Id, Target = _charlie.UserName }, token)).IsValid); - - _shown = await _host.Publish(_alpha, $"hello from alpha #{_tag}"); - _poll = await _host.Publish(_alpha, new StatusDraft - { - Text = "which one?", - PlainText = true, - Poll = new PollDraft(new[] { "this", "that" }, 3600, false, false) - }); - var charlieSays = await _host.Publish(_charlie, "worth sharing"); - _alphaPosts = new List - { - _shown, - _poll, - await _host.Publish(_alpha, "quietly", PostVisibility.Unlisted), - await _host.Publish(_alpha, "for my followers", PostVisibility.FollowersOnly), - await _host.Publish(_alpha, $"@{_charlie.UserName} between us", PostVisibility.Direct), - await _host.PublishLocated(_alpha, "around here"), - await _host.Publish(_alpha, new StatusDraft { Text = "quoting charlie", PlainText = true, QuotedStatusId = charlieSays.ID }), - await _host.Reblog(_alpha, charlieSays) - }; - await DB.Default.SaveAsync(new Pin { AvatarId = _alpha.Id, PostId = _shown.ID }, token); - Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _alpha.Id, Target = _charlie.UserName }, token)).IsValid); - Assert.True((await follows.Follow(_charlie.Root.Id, new FollowForm { AvatarId = _charlie.Id, Target = _alpha.UserName }, token)).IsValid); - Assert.True((await _host.Get().Favourite(await _host.Actor(_charlie), _shown.ID, true, token)).Ok); - await _host.Publish(_charlie, new StatusDraft { Text = $"@{_alpha.UserName} nice", PlainText = true, InReplyTo = _shown.ID }); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - string[] Forbidden() => new[] { _bravo.Id, _bravo.UserName, _root.Id, _root.UserName, _community.Id, _community.UserName }; - - [Fact] - public async Task Nothing_the_api_tells_a_persona_names_its_sibling_or_its_login() - { - var bearer = await _host.MastodonToken(_alpha); - using var client = _host.As(bearer); - var notifications = await client.Fetch("/api/v1/notifications", "application/json"); - Assert.Equal(HttpStatusCode.OK, notifications.Status); - var notificationIds = JsonNode.Parse(notifications.Text)!.AsArray().Select(n => n!["id"]!.GetValue()).ToList(); - Assert.NotEmpty(notificationIds); - - var paths = Routes().Select(Fill).Concat(new[] - { - $"/api/v2/search?q={_alpha.UserName}", - $"/api/v2/search?q={Uri.EscapeDataString($"@{_alpha.UserName}@{PrivaPubHost.Host}")}", - $"/api/v2/search?q={Uri.EscapeDataString(_alpha.ActorUri())}", - $"/api/v2/search?q={Uri.EscapeDataString(_shown.ObjectURI)}", - $"/api/v2/search?q=%23{_tag}", - $"/api/v1/accounts/search?q={_alpha.UserName}", - $"/api/v1/accounts/lookup?acct={_alpha.UserName}", - $"/api/v1/accounts/relationships?id[]={_alpha.Id}&id[]={_charlie.Id}", - $"/api/v1/accounts/familiar_followers?id[]={_charlie.Id}", - $"/api/v1/accounts/{_alpha.Id}/statuses?pinned=true", - $"/api/v1/accounts/{_alpha.Id}/statuses?exclude_replies=true&limit=40", - $"/api/v1/statuses?{string.Join("&", _alphaPosts.Select(p => "id[]=" + p.ID))}", - $"/api/v1/statuses/{_poll.ID}", - $"/api/v1/polls/{_poll.ID}", - $"/api/v1/notifications/{notificationIds[0]}", - "/api/v1/timelines/home?limit=40", - "/api/v1/timelines/public?local=true&limit=40" - }).Concat(_alphaPosts.Select(p => $"/api/v1/statuses/{p.ID}/context")) - .Distinct() - .ToList(); - - var answered = 0; - var failures = new List(); - foreach (var path in paths) - { - var fetched = await client.Fetch(path, "application/json"); - if ((int)fetched.Status >= 500) - failures.Add($"{path} answered {(int)fetched.Status}"); - if (fetched.Status == HttpStatusCode.OK) - answered++; - foreach (var secret in Forbidden()) - if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase) || fetched.Response.Headers.ToString().Contains(secret, StringComparison.OrdinalIgnoreCase)) - failures.Add($"{path} names {secret}: {Short(fetched.Text)}"); - } - - Assert.True(failures.Count == 0, string.Join("\n", failures)); - Assert.True(answered >= 40, $"only {answered} of {paths.Count} answers were 200"); - var me = await client.Fetch("/api/v1/accounts/verify_credentials", "application/json"); - Assert.Contains(_alpha.Id, me.Text); - Assert.Contains(_shown.ID, (await client.Fetch("/api/v1/timelines/home?limit=40", "application/json")).Text); - } - - [Fact] - public async Task Nothing_published_about_a_persona_names_its_sibling_or_its_login() - { - using var client = _host.Client(); - var seeds = new[] - { - $"/peasants/{_alpha.UserName}", - $"/users/{_alpha.UserName}", - $"/peasants/{_alpha.UserName}/anus?page=true", - $"/peasants/{_alpha.UserName}/scribbles/{_shown.ID}", - $"/peasants/{_alpha.UserName}/grunts/create-{_shown.ID}", - $"/.well-known/webfinger?resource=acct:{_alpha.UserName}@{PrivaPubHost.Host}", - "/.well-known/nodeinfo", - $"/@{_alpha.UserName}", - $"/@{_alpha.UserName}/{_shown.ID}" - }; - var queue = new Queue(seeds); - var seen = new HashSet(seeds); - var failures = new List(); - var fetchedOk = 0; - - while (queue.Count > 0 && seen.Count <= MaxDocuments) - { - var path = queue.Dequeue(); - var html = path.StartsWith("/@", StringComparison.Ordinal); - var fetched = await client.Fetch(path, html ? Browser : path.StartsWith("/peasants/", StringComparison.Ordinal) ? ActivityJson : "application/json"); - if ((int)fetched.Status >= 500) - failures.Add($"{path} answered {(int)fetched.Status}"); - if (fetched.Status == HttpStatusCode.OK) - fetchedOk++; - foreach (var secret in Forbidden()) - if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase)) - failures.Add($"{path} names {secret}: {Short(fetched.Text)}"); - - var links = Links(fetched.Text).ToList(); - if (fetched.Location != default) - links.Add(fetched.Location.StartsWith('/') ? Base + fetched.Location : fetched.Location); - foreach (var link in links) - { - if (!link.StartsWith(Base + "/", StringComparison.Ordinal)) - continue; - var next = PathOf(link.Split('#')[0]); - if (Followed(next) && seen.Add(next)) - queue.Enqueue(next); - } - } - - Assert.True(failures.Count == 0, string.Join("\n", failures)); - Assert.Contains($"/peasants/{_alpha.UserName}/groupies", seen); - Assert.Contains($"/peasants/{_alpha.UserName}/trophies", seen); - Assert.Contains("/nodeinfo/2.1", seen); - Assert.True(fetchedOk >= 15, $"only {fetchedOk} of {seen.Count} documents were served"); - } - - bool Followed(string path) => - path.StartsWith($"/peasants/{_alpha.UserName}", StringComparison.Ordinal) - || path.StartsWith($"/@{_alpha.UserName}", StringComparison.Ordinal) - || path.StartsWith("/.well-known/", StringComparison.Ordinal) - || path.StartsWith("/nodeinfo/", StringComparison.Ordinal); - - static IEnumerable Links(string text) - { - JsonNode json = default; - try - { - json = JsonNode.Parse(text); - } - catch (System.Text.Json.JsonException) - { - } - if (json != default) - return Strings(json); - return Href().Matches(text).Select(m => WebUtility.HtmlDecode(m.Groups[1].Value)); - } - - IEnumerable Routes() - { - var endpoints = _host.Services.GetRequiredService().Endpoints.OfType(); - foreach (var endpoint in endpoints) - { - var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; - var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/'); - if (template.StartsWith("/api/", StringComparison.Ordinal) && methods is { } verbs && verbs.Contains("GET")) - yield return template; - } - } - - string Fill(string template) - { - var builder = new StringBuilder(); - foreach (var segment in RoutePatternFactory.Parse(template).PathSegments) - { - builder.Append('/'); - foreach (var part in segment.Parts) - builder.Append(part switch - { - RoutePatternLiteralPart literal => literal.Content, - RoutePatternSeparatorPart separator => separator.Content, - RoutePatternParameterPart parameter => Value(template, parameter.Name), - _ => string.Empty - }); - } - return builder.ToString(); - } - - string Value(string template, string parameter) => parameter switch - { - "hashtag" => _tag, - "host" => PrivaPubHost.Host, - _ when template.StartsWith("/api/v1/statuses/", StringComparison.Ordinal) - || template.Contains("/statuses/{id}", StringComparison.Ordinal) => _shown.ID, - _ when template.StartsWith("/api/v1/polls/", StringComparison.Ordinal) => _poll.ID, - _ => _alpha.Id - }; - - static string Short(string text) => text.Length > 400 ? text[..400] + "…" : text; - - [GeneratedRegex("(?:href|src)=\"([^\"]+)\"")] - private static partial Regex Href(); - } -} diff --git a/PrivaPub.Tests/Http/PostPlaceTests.cs b/PrivaPub.Tests/Http/PostPlaceTests.cs deleted file mode 100644 index 0089cb5..0000000 --- a/PrivaPub.Tests/Http/PostPlaceTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // A Pixelfed post's own place: stored from `location`, shown as `privapub.place`, changed by an edit. - [Trait("Category", "Integration")] - public sealed class PostPlaceTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static JsonObject Location(string name, string latitude, string longitude) => new() - { - ["type"] = "Place", ["name"] = name, ["latitude"] = latitude, ["longitude"] = longitude, ["country"] = "Italy" - }; - - [Fact] - public async Task A_pixelfed_place_is_shown_and_follows_the_authors_edits() - { - var alice = await _host.Mastodon("alice"); - var ann = new RemoteActor(_peer, "ann"); - var post = await _host.PublicPostFrom(ann, alice, "

the duomo

", note => note["location"] = Location("Milan", "45.4642", "9.19")); - - var place = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]; - - Assert.Equal(("Milan", "Italy"), (place.Text("name"), place.Text("country"))); - Assert.Equal(45.4642, place["latitude"]!.GetValue()); - Assert.Equal(9.19, place["longitude"]!.GetValue()); - - await Edit(ann, alice, post, note => note["location"] = Location("Turin", "45.0703", "7.6869")); - Assert.Equal("Turin", (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"].Text("name")); - - await Edit(ann, alice, post, _ => { }); - Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]); - Assert.Null((await DB.Default.Find().MatchID(post.ID).ExecuteFirstAsync(Token)).Place); - } - - [Fact] - public async Task A_post_without_a_place_has_none() - { - var alice = await _host.Mastodon("alice"); - var post = await _host.PublicPostFrom(new RemoteActor(_peer, "plain"), alice); - - Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]); - } - - [Fact] - public async Task A_local_post_never_carries_a_location() - { - var alice = await _host.Mastodon("alice"); - var status = await alice.Status("made here"); - - using var client = _host.Client(); - client.DefaultRequestHeaders.Accept.ParseAdd("application/activity+json"); - var note = JsonNode.Parse(await client.GetStringAsync(status.Text("uri"), Token))!; - - Assert.Null(note["location"]); - } - - async Task Edit(RemoteActor author, Mastodon mentioned, Post post, Action shape) - { - var note = new JsonObject - { - ["id"] = post.ObjectURI, - ["type"] = "Note", - ["attributedTo"] = author.Id, - ["to"] = new JsonArray(MastodonHelpers.Public), - ["cc"] = new JsonArray(mentioned.Uri), - ["content"] = "

the duomo, again

", - ["published"] = post.CreationDate.ToString("O"), - ["updated"] = DateTime.UtcNow.ToString("O"), - ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName }) - }; - shape(note); - await _host.Deliver(author, mentioned.Mouth, new JsonObject - { - ["id"] = $"{post.ObjectURI}/updates/{Guid.NewGuid():N}", - ["type"] = "Update", - ["actor"] = author.Id, - ["to"] = new JsonArray(MastodonHelpers.Public), - ["object"] = note - }); - } - } -} diff --git a/PrivaPub.Tests/Http/ProvenanceAndReactionsTests.cs b/PrivaPub.Tests/Http/ProvenanceAndReactionsTests.cs deleted file mode 100644 index 1c8c58c..0000000 --- a/PrivaPub.Tests/Http/ProvenanceAndReactionsTests.cs +++ /dev/null @@ -1,224 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class ProvenanceAndReactionsTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static string Provenance(string id) => $"/api/privapub/v1/statuses/{id}/provenance"; - - [Fact] - public async Task A_local_post_has_a_local_provenance() - { - var alice = await _host.Mastodon("alice"); - var status = await alice.Status("made here"); - - var provenance = (await _host.Client().Get(Provenance(status.Text("id")))).Ok(); - - Assert.Equal("local", provenance.Body.Text("path")); - Assert.Equal(status.Text("uri"), provenance.Body.Text("object_uri")); - Assert.Equal(status.Text("url"), provenance.Body.Text("url")); - Assert.Null(provenance.Body["signature"]); - Assert.Null(provenance.Body["raw"]); - } - - [Fact] - public async Task A_delivered_post_shows_its_signature_and_raw_form_and_reading_it_asks_nobody() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var create = bob.Create("

signed and sealed

", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, - note => note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName })); - await _host.Deliver(bob, alice.Mouth, create); - var noteId = create["object"].Text("id"); - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token); - var asked = _peer.Requests.Count; - - var provenance = (await alice.Client.Get(Provenance(post.ID))).Ok().Body; - var anonymous = (await _host.Client().Get(Provenance(post.ID))).Ok().Body; - - Assert.Equal("delivered", provenance.Text("path")); - Assert.Equal(noteId, provenance.Text("object_uri")); - Assert.Equal("Note", provenance.Text("object_type")); - Assert.Null(provenance["fetched_by"]); - Assert.Equal((create.Text("id"), "Create", bob.Id), (provenance["activity"].Text("id"), provenance["activity"].Text("type"), provenance["activity"].Text("actor"))); - Assert.Equal(bob.KeyId, provenance["signature"].Text("key_id")); - Assert.Equal("rsa-sha256", provenance["signature"].Text("algorithm")); - Assert.Contains("digest", provenance["signature"]!["headers"]!.AsArray().Select(h => h!.GetValue())); - Assert.Equal(noteId, provenance["raw"].Text("id")); - Assert.False(string.IsNullOrEmpty(provenance.Text("raw_hash"))); - Assert.True(provenance.Number("raw_bytes") > 0); - Assert.NotNull(provenance.Text("received_at")); - Assert.Equal(provenance.ToJsonString(), anonymous.ToJsonString()); - Assert.Equal(asked, _peer.Requests.Count); - } - - [Fact] - public async Task A_fetched_parent_shows_no_signature_and_was_fetched_by_the_instance_actor() - { - var alice = await _host.Mastodon("alice"); - var bob = new RemoteActor(_peer, "bob"); - var parentPath = $"/notes/{Guid.NewGuid():N}"; - var parentId = _peer.A + parentPath; - _peer.Serve(parentPath, new JsonObject - { - ["id"] = parentId, - ["type"] = "Note", - ["attributedTo"] = bob.Id, - ["to"] = new JsonArray(MastodonHelpers.Public), - ["content"] = "

the start of it

", - ["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O") - }.ToJsonString()); - var reply = bob.Create("

and a reply

", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, note => - { - note["inReplyTo"] = parentId; - note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }); - }); - await _host.Deliver(bob, alice.Mouth, reply); - var parent = await DB.Default.Find().Match(p => p.ObjectURI == parentId).ExecuteFirstAsync(Token); - Assert.NotNull(parent); - var asked = _peer.Requests.Count; - - var provenance = (await alice.Client.Get(Provenance(parent.ID))).Ok().Body; - - Assert.Equal("fetched", provenance.Text("path")); - Assert.Equal("instance-actor", provenance.Text("fetched_by")); - Assert.Null(provenance["signature"]); - Assert.Equal((reply.Text("id"), "Create"), (provenance["activity"].Text("id"), provenance["activity"].Text("type"))); - Assert.Null(provenance["inbox"]); - Assert.Equal(parentId, provenance["raw"].Text("id")); - Assert.Contains(_peer.Requests, r => r.Path == parentPath && r.Signature.Contains("/peasants/privapub#main-key")); - Assert.Equal(asked, _peer.Requests.Count); - } - - [Fact] - public async Task Provenance_follows_who_may_see_the_post() - { - var alice = await _host.Mastodon("alice"); - var other = await _host.Mastodon("other"); - var bob = new RemoteActor(_peer, "bob"); - var direct = bob.Create("

between us

", new[] { alice.Uri }, shape: note => - note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName })); - await _host.Deliver(bob, alice.Mouth, direct); - var noteId = direct["object"].Text("id"); - var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token); - - Assert.Equal("delivered", (await alice.Client.Get(Provenance(post.ID))).Ok().Body.Text("path")); - Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get(Provenance(post.ID))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance(post.ID))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance("000000000000000000000000"))).Status); - } - - [Fact] - public async Task An_instance_is_described_from_what_was_stored_and_reading_it_asks_nobody() - { - var host = $"described{Guid.NewGuid():N}.example"; - await DB.Default.SaveAsync(new RemoteInstance - { - Host = host, - Software = "gotosocial", - SoftwareVersion = "0.20.0", - NodeName = "a test pasture", - Protocols = new List { "activitypub" }, - OpenRegistrations = false, - DescribedAt = DateTime.UtcNow.AddDays(-1), - NodeInfo = "{\"software\":{\"name\":\"gotosocial\",\"version\":\"0.20.0\"}}", - ConsecutiveFailures = 2, - LastFailureAt = DateTime.UtcNow.AddHours(-1) - }, Token); - var alice = await _host.Mastodon("alice"); - - var described = (await alice.Client.Get($"/api/privapub/v1/instances/{host.ToUpperInvariant()}")).Ok().Body; - - Assert.Equal(host, described.Text("host")); - Assert.Equal(("gotosocial", "0.20.0", "a test pasture"), (described.Text("software"), described.Text("version"), described.Text("node_name"))); - Assert.Equal("activitypub", Assert.Single(described["protocols"]!.AsArray())!.GetValue()); - Assert.False(described.Flag("open_registrations")); - Assert.Equal("gotosocial", described["node_info"]!["software"].Text("name")); - Assert.Equal(2, described["delivery"].Number("consecutive_failures")); - Assert.NotNull(described["delivery"].Text("last_failure_at")); - Assert.Null(described["delivery"]["last_success_at"]); - Assert.Equal(described.ToJsonString(), (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body.ToJsonString()); - Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example")).Status); - Assert.Empty(_peer.Requests); - } - - [Fact] - public async Task Reacting_to_a_remote_post_federates_an_emoji_react_and_its_undo() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var bob = new RemoteActor(_peer, "bob"); - var post = await _host.PublicPostFrom(bob, alice); - var path = $"/api/v1/pleroma/statuses/{post.ID}/reactions"; - - var reacted = (await alice.Client.Put(path + "/%F0%9F%91%8D")).Ok(); - - var reaction = Assert.Single(reacted.Body["emoji_reactions"]!.AsArray()); - Assert.Equal(("👍", 1, true), (reaction.Text("name"), reaction.Number("count"), reaction.Flag("me"))); - Assert.Equal("👍", Assert.Single(reacted.Body["pleroma"]!["emoji_reactions"]!.AsArray()).Text("name")); - (await carol.Client.Put(path + "/%F0%9F%91%8D")).Ok(); - var listed = Assert.Single((await _host.Client().Get(path)).Ok().Array); - Assert.Equal(("👍", 2, false), (listed.Text("name"), listed.Number("count"), listed.Flag("me"))); - Assert.Equal(new[] { alice.Id, carol.Id }.Order(), listed!["accounts"]!.AsArray().Select(a => a.Text("id")).Order()); - Assert.True(Assert.Single((await alice.Client.Get(path)).Ok().Array).Flag("me")); - var react = Assert.Single(await bob.Delivered(since), d => d.Type() == "EmojiReact" && d.Text("actor") == alice.Uri); - Assert.Equal(("👍", post.ObjectURI), (react.Text("content"), react.Text("object"))); - - var withdrawn = (await alice.Client.Delete(path + "/%F0%9F%91%8D")).Ok(); - - Assert.Equal(1, Assert.Single(withdrawn.Body["emoji_reactions"]!.AsArray()).Number("count")); - var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo" && d.Text("actor") == alice.Uri); - Assert.Equal(react.Text("id"), undo["object"].Text("id")); - Assert.Equal("EmojiReact", undo["object"].Text("type")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put(path + "/abc")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put("/api/v1/pleroma/statuses/000000000000000000000000/reactions/%F0%9F%91%8D")).Status); - } - - [Fact] - public async Task Reacting_to_a_local_post_notifies_its_author_and_federates_nothing() - { - var since = DateTime.UtcNow.AddSeconds(-1); - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var bob = new RemoteActor(_peer, "bob"); - await _host.FollowedBy(carol, bob); - var post = await carol.Status("react to me"); - - (await alice.Client.Put($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok(); - - var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=pleroma:emoji_reaction")).Ok().Array); - Assert.Equal(("🎉", alice.Id, post.Text("id")), (notification.Text("emoji"), notification["account"].Text("id"), notification["status"].Text("id"))); - Assert.DoesNotContain(await bob.Delivered(since), d => d.Type() is "EmojiReact" or "Like"); - (await alice.Client.Delete($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok(); - Assert.Empty((await carol.Client.Get($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions")).Ok().Array); - } - } -} diff --git a/PrivaPub.Tests/Http/RegistrationsTests.cs b/PrivaPub.Tests/Http/RegistrationsTests.cs deleted file mode 100644 index bc760df..0000000 --- a/PrivaPub.Tests/Http/RegistrationsTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; - -using PrivaPub.Infrastructure; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class RegistrationsTests : IAsyncLifetime - { - WebApplicationFactory _closed; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - var shared = await PrivaPubHost.Shared(); - _closed = shared.WithWebHostBuilder(builder => builder.UseSetting("Registrations:Mode", nameof(RegistrationMode.Invitations))); - } - - public async ValueTask DisposeAsync() - { - if (_closed != default) - await _closed.DisposeAsync(); - } - - [Fact] - public async Task Invitations_only_refuses_sign_up_and_every_document_says_closed() - { - using var client = _closed.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(PrivaPubHost.Base + "/"), AllowAutoRedirect = false }); - client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}"); - - var signUp = await client.PostJson("/clientapi/user/signup", new { userName = $"closed{Guid.NewGuid():N}"[..20], password = "Closed-Door-Pass-1" }); - var nodeInfo = (await client.Get("/nodeinfo/2.1")).Ok().Body; - var v1 = (await client.Get("/api/v1/instance")).Ok().Body; - var v2 = (await client.Get("/api/v2/instance")).Ok().Body; - - Assert.Equal(HttpStatusCode.Forbidden, signUp.StatusCode); - Assert.Contains("invitation", await signUp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.False(nodeInfo.Flag("openRegistrations")); - Assert.False(v1.Flag("registrations")); - Assert.True(v1.Flag("invites_enabled")); - Assert.False(v2["registrations"].Flag("enabled")); - Assert.Equal(RegistrationOptions.ClosedMessage, v2["registrations"].Text("message")); - } - } -} diff --git a/PrivaPub.Tests/Http/RootRemovalTests.cs b/PrivaPub.Tests/Http/RootRemovalTests.cs deleted file mode 100644 index 59162e2..0000000 --- a/PrivaPub.Tests/Http/RootRemovalTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Models.Federation; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Net.Http.Json; - -using GroupEntity = PrivaPub.Models.Group.Group; -using PostEntity = PrivaPub.Models.Post.Post; - -namespace PrivaPub.Tests.Http -{ - // Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04). - [Trait("Category", "Integration")] - public sealed class RootRemovalTests : IAsyncLifetime - { - PrivaPubHost _host; - HttpClient _client; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - _client?.Dispose(); - if (_peer != default) - await _peer.DisposeAsync(); - } - - async Task AdminRemoves(params string[] rootIds) - { - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) }; - return await client.SendAsync(request, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so() - { - var token = TestContext.Current.CancellationToken; - var root = await _host.SignUp("leaving"); - var persona = await _host.Persona(root, "leaving"); - var follower = new RemoteActor(_peer, "fan"); - var member = new RemoteActor(_peer, "member"); - await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token); - var group = await _host.Group(persona, community: true); - var groupId = group["id"]!.GetValue(); - await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token); - var post = await _host.Publish(persona, "goodbye soon"); - var apiToken = await _host.MastodonToken(persona); - var since = DateTime.UtcNow.AddSeconds(-1); - - Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode); - - var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Delete"); - Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue()); - Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue()); - var groupName = group["userName"]!.GetValue(); - Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), - d => d["type"]!.GetValue() == "Delete" && d["object"]!.GetValue() == $"{PrivaPubHost.Base}/peasants/{groupName}"); - var gone = await _client.Fetch($"/peasants/{persona.UserName}"); - Assert.Equal(HttpStatusCode.Gone, gone.Status); - Assert.Equal("Person", gone.Json["formerType"]!.GetValue()); - Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status); - Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode); - var stored = await DB.Default.Find().MatchID(post.ID).ExecuteFirstAsync(token); - Assert.NotNull(stored.DeletedAt); - Assert.Null(stored.ContentHtml); - Assert.NotNull((await DB.Default.Find().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt); - var removed = await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(token); - Assert.Equal($"deleted-{root.Id}", removed.UserName); - Assert.Null(removed.HashedPassword); - using var app = _host.Client(); - app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken); - Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode); - } - - [Fact] - public async Task A_second_removal_does_not_collide_with_the_first() - { - var first = await _host.SignUp("first"); - var second = await _host.SignUp("second"); - - Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode); - - Assert.NotNull((await DB.Default.Find().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt); - } - - [Fact] - public async Task A_root_deletes_itself_only_with_its_password() - { - var token = TestContext.Current.CancellationToken; - var root = await _host.SignUp("self"); - var persona = await _host.Persona(root, "self"); - using var client = _host.As(root.Jwt); - - var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" }); - Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); - Assert.Null((await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt); - - var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password }); - Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); - Assert.NotNull((await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode); - } - } -} diff --git a/PrivaPub.Tests/Http/ServerSetupTests.cs b/PrivaPub.Tests/Http/ServerSetupTests.cs deleted file mode 100644 index eb8badc..0000000 --- a/PrivaPub.Tests/Http/ServerSetupTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -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 _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.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 Pending() - { - using var client = Client(); - return (await client.Get("/clientapi/user/setup")).Ok().Body.Flag("isPending"); - } - - static async Task Message(HttpResponseMessage response) - { - var body = await response.JsonBody(); - return body["errorMessage"]?.GetValue() ?? body["title"]?.GetValue(); - } - - [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()).Order(); - Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), policies); - using var admin = Client(jwt["token"]!.GetValue()); - 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()); - Assert.Equal("The Founder", persona["name"]!.GetValue()); - var seal = await DB.Default.Find().OneAsync(ServerSetup.Name, token); - Assert.Equal(jwt["userId"]!.GetValue(), 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().OneAsync(ServerSetup.Name, TestContext.Current.CancellationToken)).AdminId); - } - } -} diff --git a/PrivaPub.Tests/Http/ThreadCollectionsTests.cs b/PrivaPub.Tests/Http/ThreadCollectionsTests.cs deleted file mode 100644 index 3ab9197..0000000 --- a/PrivaPub.Tests/Http/ThreadCollectionsTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Http -{ - // a public thread names its replies and its conversation (owner decision 2026-10-06), and they list only what is public - // or unlisted; a followers-only post names neither - [Trait("Category", "Integration")] - public sealed class ThreadCollectionsTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static string PathOf(JsonObject status) => new Uri(status.Text("uri")).AbsolutePath; - - static string[] Items(JsonNode collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToArray(); - - [Fact] - public async Task A_public_post_names_its_replies_and_context_which_list_only_public_answers() - { - var alice = await _host.Mastodon("alice"); - var carol = await _host.Mastodon("carol"); - var root = await alice.Status("a public question"); - var answer = await carol.Status("a public answer", ("in_reply_to_id", root.Text("id"))); - var hidden = await carol.Status("for my followers", ("in_reply_to_id", root.Text("id")), ("visibility", "private")); - var deeper = await alice.Status("and a deeper one", ("in_reply_to_id", answer.Text("id")), ("visibility", "unlisted")); - - var note = (await _host.ActivityPub(PathOf(root))).Ok().Object; - Assert.Equal(root.Text("uri") + "/replies", note.Text("replies")); - Assert.Equal(root.Text("uri") + "/context", note.Text("context")); - var answerNote = (await _host.ActivityPub(PathOf(deeper))).Ok().Object; - Assert.Equal(root.Text("uri") + "/context", answerNote.Text("context")); - - var replies = (await _host.ActivityPub(PathOf(root) + "/replies")).Ok().Object; - Assert.Equal(new[] { answer.Text("uri") }, Items(replies)); - - var context = (await _host.ActivityPub(PathOf(root) + "/context")).Ok().Object; - Assert.Equal(new[] { root.Text("uri"), answer.Text("uri"), deeper.Text("uri") }, Items(context)); - Assert.Equal(alice.Uri, context.Text("attributedTo")); - Assert.DoesNotContain(hidden.Text("uri"), Items(context)); - } - - [Fact] - public async Task A_followers_only_post_names_neither_and_serves_neither() - { - var alice = await _host.Mastodon("alice"); - var quiet = await alice.Status("for my followers", ("visibility", "private")); - - Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/replies")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/context")).Status); - } - } -} diff --git a/PrivaPub.Tests/Http/WellKnownTests.cs b/PrivaPub.Tests/Http/WellKnownTests.cs deleted file mode 100644 index 3c95ccd..0000000 --- a/PrivaPub.Tests/Http/WellKnownTests.cs +++ /dev/null @@ -1,237 +0,0 @@ -using PrivaPub.Domain.Statuses; -using PrivaPub.Models.Post; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -using static PrivaPub.Tests.Support.Host.FederationHelpers; - -namespace PrivaPub.Tests.Http -{ - [Trait("Category", "Integration")] - public sealed class WellKnownTests : IAsyncLifetime - { - const string Base = PrivaPubHost.Base; - const string Domain = PrivaPubHost.Host; - - PrivaPubHost _host; - HttpClient _client; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _client = _host.Client(); - } - - public ValueTask DisposeAsync() - { - _client?.Dispose(); - return ValueTask.CompletedTask; - } - - Task WebFinger(string resource) => - _client.Fetch("/.well-known/webfinger" + (resource == default ? string.Empty : "?resource=" + Uri.EscapeDataString(resource)), "application/jrd+json"); - - static string Link(JsonObject document, string rel) => - document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue() == rel)!["href"]!.GetValue(); - - // FEP-d556, FEP-2677 and FEP-844e: the server's own actor is found from its origin and from NodeInfo, and says what - // the server reads that its documents do not show - [Fact] - public async Task The_server_actor_is_found_from_the_origin_and_nodeinfo_and_tells_what_it_implements() - { - var instance = $"{Base}/peasants/privapub"; - foreach (var resource in new[] { Base, Base + "/" }) - { - var found = await WebFinger(resource); - Assert.Equal(HttpStatusCode.OK, found.Status); - Assert.Equal(resource, found.Json["subject"]!.GetValue()); - Assert.Equal(instance, Link(found.Json, "https://www.w3.org/ns/activitystreams#Service")); - } - var nodeinfo = await _client.Fetch("/.well-known/nodeinfo", "application/json"); - Assert.Equal(instance, Link(nodeinfo.Json, "https://www.w3.org/ns/activitystreams#Application")); - - var actor = (await _client.Fetch("/peasants/privapub")).Json; - Assert.Contains(actor["implements"]!.AsArray(), i => i!["href"]!.GetValue() == "https://datatracker.ietf.org/doc/html/rfc9421"); - var persona = await _host.Persona(await _host.SignUp(), "generated"); - var personaActor = (await _client.Fetch($"/peasants/{persona.UserName}")).Json; - Assert.Equal("Application", personaActor["generator"]!["type"]!.GetValue()); - Assert.Contains(personaActor["generator"]!["implements"]!.AsArray(), i => i!["href"]!.GetValue() == "https://datatracker.ietf.org/doc/html/rfc9421"); - Assert.StartsWith("z6Mk", personaActor["assertionMethod"]![0]!["publicKeyMultibase"]!.GetValue()); - } - - [Fact] - public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri() - { - var persona = await _host.Persona(await _host.SignUp(), "finger"); - var resources = new[] - { - $"acct:{persona.UserName}@{Domain}", - $"acct:@{persona.UserName}@{Domain}", - $"acct:{persona.UserName.ToUpperInvariant()}@{Domain.ToUpperInvariant()}", - $"{persona.UserName}@{Domain}", - $"@{persona.UserName}@{Domain}", - persona.ActorUri() - }; - - foreach (var resource in resources) - { - var found = await WebFinger(resource); - Assert.True(found.Status == HttpStatusCode.OK, $"{resource} answered {(int)found.Status}"); - Assert.Equal("application/jrd+json", found.MediaType); - var document = found.Json; - Assert.Equal($"acct:{persona.UserName}@{Domain}", document["subject"]!.GetValue()); - Assert.Equal(new[] { $"{Base}/@{persona.UserName}", persona.ActorUri() }, Strings(document["aliases"])); - Assert.Equal(persona.ActorUri(), Link(document, "self")); - Assert.Equal("application/activity+json", document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue() == "self")!["type"]!.GetValue()); - Assert.Equal($"{Base}/@{persona.UserName}", Link(document, "http://webfinger.net/rel/profile-page")); - Assert.DoesNotContain(persona.Root.Id, found.Text); - Assert.DoesNotContain(persona.Root.UserName, found.Text); - } - } - - [Fact] - public async Task WebFinger_refuses_other_domains_unknown_names_and_a_missing_resource() - { - var persona = await _host.Persona(await _host.SignUp(), "nofinger"); - - Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.UserName}@elsewhere.example")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.UserName}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:nobody{Guid.NewGuid():N}"[..20] + "@" + Domain)).Status); - Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"https://elsewhere.example/peasants/{persona.UserName}")).Status); - Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.Root.UserName}@{Domain}")).Status); - Assert.Equal(HttpStatusCode.BadRequest, (await WebFinger(default)).Status); - Assert.Equal(HttpStatusCode.BadRequest, (await WebFinger(string.Empty)).Status); - } - - [Fact] - public async Task WebFinger_finds_the_servers_own_actors_and_a_community() - { - var owner = await _host.Persona(await _host.SignUp(), "fingerowner"); - var community = await _host.FederatedGroup(owner, community: true); - - var instance = await WebFinger($"acct:privapub@{Domain}"); - var group = await WebFinger($"acct:{community.UserName}@{Domain}"); - - Assert.Equal(HttpStatusCode.OK, instance.Status); - Assert.Equal($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue()); - Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self")); - Assert.Single(instance.Json["links"]!.AsArray()); - var reporter = await WebFinger($"acct:privapub_reports@{Domain}"); - Assert.Equal(HttpStatusCode.OK, reporter.Status); - Assert.Equal($"{Base}/peasants/privapub_reports", Link(reporter.Json, "self")); - Assert.Single(reporter.Json["links"]!.AsArray()); - Assert.Equal(HttpStatusCode.OK, group.Status); - Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue()); - Assert.Equal(community.Uri, Link(group.Json, "self")); - Assert.DoesNotContain(owner.UserName, group.Text); - Assert.DoesNotContain(owner.Id, group.Text); - } - - // Current behaviour, for the owner to decide: WebFinger answers for a circle, because Mastodon resolves a handle - // through WebFinger before it can send the Follow a circle takes members through, while /api/v1/accounts/lookup, - // search and the profile page do not show circles at all. - [Fact] - public async Task WebFinger_answers_for_a_circle() - { - var owner = await _host.Persona(await _host.SignUp(), "circlefinger"); - var circle = await _host.FederatedGroup(owner, community: false); - - var found = await WebFinger($"acct:{circle.UserName}@{Domain}"); - - Assert.Equal(HttpStatusCode.OK, found.Status); - Assert.Equal(circle.Uri, Link(found.Json, "self")); - Assert.DoesNotContain(owner.UserName, found.Text); - } - - [Fact] - public async Task NodeInfo_is_linked_and_served_in_both_versions_without_naming_anyone() - { - var root = await _host.SignUp(); - var persona = await _host.Persona(root, "nodeinfo"); - await _host.Publish(persona, "counted"); - - var links = await _client.Fetch("/.well-known/nodeinfo", "application/json"); - - Assert.Equal(HttpStatusCode.OK, links.Status); - Assert.Equal("application/json", links.MediaType); - var hrefs = links.Json["links"]!.AsArray().ToDictionary(l => l!["rel"]!.GetValue(), l => l!["href"]!.GetValue()); - Assert.Equal($"{Base}/nodeinfo/2.1", hrefs["http://nodeinfo.diaspora.software/ns/schema/2.1"]); - Assert.Equal($"{Base}/nodeinfo/2.0", hrefs["http://nodeinfo.diaspora.software/ns/schema/2.0"]); - foreach (var version in new[] { "2.0", "2.1" }) - { - var info = await _client.Fetch(PathOf(hrefs[$"http://nodeinfo.diaspora.software/ns/schema/{version}"]), "application/json"); - Assert.Equal(HttpStatusCode.OK, info.Status); - Assert.Equal("application/json", info.MediaType); - Assert.Contains($"profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"", info.Response.Content.Headers.ContentType!.ToString()); - var document = info.Json; - Assert.Equal(version, document["version"]!.GetValue()); - Assert.Equal("privapub", document["software"]!["name"]!.GetValue()); - Assert.Equal(version == "2.1", document["software"]!["repository"] != default); - Assert.Equal(new[] { "activitypub" }, Strings(document["protocols"])); - Assert.True(document["usage"]!["users"]!["total"]!.GetValue() >= 1); - Assert.True(document["usage"]!["localPosts"]!.GetValue() >= 1); - Assert.DoesNotContain(root.Id, info.Text); - Assert.DoesNotContain(root.UserName, info.Text); - Assert.DoesNotContain(persona.UserName, info.Text); - } - } - - [Fact] - public async Task Unknown_nodeinfo_versions_are_404() - { - foreach (var version in new[] { "1.0", "2.2", "3.0", "2.0.json", "2" }) - Assert.True((await _client.Fetch($"/nodeinfo/{version}", "application/json")).Status == HttpStatusCode.NotFound, $"/nodeinfo/{version} was served"); - } - } - - [Trait("Category", "Integration")] - [Collection(nameof(Exclusive))] - public sealed class NodeInfoUsageTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task LocalPosts() - { - using var client = _host.Client(); - return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue(); - } - - // localPosts is Mastodon's: the sum of statuses_count, every local post but DMs and deleted ones, boosts included, - // and circle and located posts too (owner decision 2026-10-04) - [Fact] - public async Task Local_posts_count_as_mastodon_counts_statuses() - { - var persona = await _host.Persona(await _host.SignUp(), "usage"); - var friend = await _host.Persona(await _host.SignUp(), "usagefriend"); - var start = await LocalPosts(); - - var shown = await _host.Publish(persona, "counted"); - await _host.Publish(persona, "counted too", PostVisibility.Unlisted); - Assert.Equal(start + 2, await LocalPosts()); - - await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly); - await _host.Publish(persona, $"@{friend.UserName} between us", PostVisibility.Direct); - await _host.PublishLocated(persona, "around here"); - await _host.FederatedCopy(persona); - await _host.Reblog(friend, shown); - var circle = await _host.FederatedGroup(persona, community: false); - await _host.Publish(persona, new StatusDraft { Text = "circle only", PlainText = true, GroupId = circle.Id }); - Assert.Equal(start + 6, await LocalPosts());//followers-only, located, the boost and the circle post; not the DM nor the copy - - await _host.Remove(persona, shown); - Assert.Equal(start + 4, await LocalPosts());//the post and the boost of it - } - } -} diff --git a/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs b/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs deleted file mode 100644 index b2fc424..0000000 --- a/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -using MongoDB.Entities; - -using PrivaPub.ClientModels; -using PrivaPub.Infrastructure.Cli; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -namespace PrivaPub.Tests.Infrastructure -{ - [Trait("Category", "Integration")] - public sealed class AdminCommandsTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static async Task> StoredPolicies(Root root) => - (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Policies.Order().ToList(); - - async Task AdminRoute(Root root) - { - using var client = _host.As(root.Jwt); - return (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).StatusCode; - } - - [Fact] - public async Task Promote_makes_an_admin_and_a_moderator() - { - var root = await _host.SignUp("promote"); - - Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName.ToUpperInvariant() }, _host.Services)); - Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services)); - - Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), await StoredPolicies(root)); - } - - [Fact] - public async Task Demote_leaves_a_plain_user() - { - var root = await _host.SignUp("demote"); - Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services)); - - Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services)); - Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services)); - - Assert.Equal(new[] { Policies.IsUser }, await StoredPolicies(root)); - } - - [Fact] - public async Task An_unknown_root_exits_1() - { - Assert.Equal(1, await AdminCommands.Run(new[] { "promote", $"nobody{Guid.NewGuid():N}" }, _host.Services)); - Assert.Equal(1, await AdminCommands.Run(new[] { "demote", $"nobody{Guid.NewGuid():N}" }, _host.Services)); - } - - public static TheoryData Misuses() => new() - { - Array.Empty(), - new[] { "promote" }, - new[] { "promote", "a", "b" }, - new[] { "delete", "a" }, - new[] { "PROMOTE", "a" } - }; - - [Theory] - [MemberData(nameof(Misuses))] - public async Task Misuse_exits_2(string[] args) - { - Assert.Equal(2, await AdminCommands.Run(args, _host.Services)); - } - - [Fact] - public async Task A_token_follows_its_roots_policies_at_once() - { - var root = await _host.SignUp("policies"); - Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root)); - - Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services)); - Assert.Equal(HttpStatusCode.OK, await AdminRoute(root)); - using (var client = _host.As(root.Jwt)) - { - var refreshed = await (await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).JsonBody(); - Assert.Contains(Policies.IsAdmin, refreshed["policies"]!.AsArray().Select(p => p!.GetValue())); - } - - Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services)); - Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root)); - } - - async Task LogIn(string login, string password) - { - using var client = _host.Client(); - return (await client.PostJson("/clientapi/user/login", new { userName = login, password })).StatusCode; - } - - [Fact] - public async Task Create_root_makes_a_login_from_a_password_on_standard_input() - { - using var scope = _host.Services.CreateScope(); - var login = $"made{Guid.NewGuid():N}"[..20]; - var output = new StringWriter(); - - Assert.Equal(0, await AdminCommands.Run(new[] { "create-root", login, "--admin" }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-1\n"), output)); - Assert.Equal(1, await AdminCommands.Run(new[] { "create-root", login }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-2\n"), new StringWriter())); - Assert.Equal(1, await AdminCommands.Run(new[] { "create-root", $"weak{Guid.NewGuid():N}"[..20] }, scope.ServiceProvider, new StringReader("short\n"), new StringWriter())); - Assert.Equal(2, await AdminCommands.Run(new[] { "create-root", login, "--root" }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-3\n"), new StringWriter())); - - Assert.Equal(HttpStatusCode.OK, await LogIn(login, "Typed-On-Stdin-1")); - var stored = await DB.Default.Find().Match(u => u.UserName == login).ExecuteFirstAsync(TestContext.Current.CancellationToken); - Assert.Contains(Policies.IsAdmin, stored.Policies); - } - - async Task> PersonaIds(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()).ToList(); - } - - async Task VerifyCredentials(string mastodonToken) - { - using var client = _host.As(mastodonToken); - return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode; - } - - [Fact] - public async Task Move_persona_gives_a_persona_to_another_login_and_ends_its_tokens() - { - using var scope = _host.Services.CreateScope(); - var giver = await _host.SignUp("giver"); - var taker = await _host.SignUp("taker"); - var persona = await _host.Persona(giver, "moved"); - var token = await _host.MastodonToken(persona); - Assert.Equal(HttpStatusCode.OK, await VerifyCredentials(token)); - - Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", "@" + persona.UserName, taker.UserName.ToUpperInvariant() }, scope.ServiceProvider, output: new StringWriter())); - Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", persona.UserName, taker.UserName }, scope.ServiceProvider, output: new StringWriter())); - - Assert.Equal(HttpStatusCode.Unauthorized, await VerifyCredentials(token)); - Assert.DoesNotContain(persona.Id, await PersonaIds(giver)); - Assert.Contains(persona.Id, await PersonaIds(taker)); - Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", $"nobody{Guid.NewGuid():N}"[..20], taker.UserName }, scope.ServiceProvider)); - Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", persona.UserName, $"nobody{Guid.NewGuid():N}" }, scope.ServiceProvider)); - } - - [Fact] - public async Task Delete_root_deletes_a_login_with_the_personas_it_still_has() - { - var cancellation = TestContext.Current.CancellationToken; - using var scope = _host.Services.CreateScope(); - var root = await _host.SignUp("leaving"); - var persona = await _host.Persona(root, "leaving"); - var output = new StringWriter(); - - Assert.Equal(0, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: output)); - Assert.Equal(1, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: new StringWriter())); - - Assert.Contains("@" + persona.UserName, output.ToString()); - Assert.NotEqual(HttpStatusCode.OK, await LogIn(root.UserName, root.Password)); - Assert.NotNull((await DB.Default.Find().OneAsync(root.Id, cancellation)).DeletedAt); - Assert.NotNull((await DB.Default.Find().OneAsync(persona.Id, cancellation)).DeletionAt); - } - } -} diff --git a/PrivaPub.Tests/Infrastructure/BackupTests.cs b/PrivaPub.Tests/Infrastructure/BackupTests.cs deleted file mode 100644 index b859825..0000000 --- a/PrivaPub.Tests/Infrastructure/BackupTests.cs +++ /dev/null @@ -1,287 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Bson.IO; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Backup; -using PrivaPub.Infrastructure.Cli; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.IO.Compression; - -namespace PrivaPub.Tests.Infrastructure -{ - // The server's backup: every collection as it was, byte for byte, read at one instant on a replica set, without what - // belongs to the moment (the statistics salt above all), with the media rows' files linked, checkable and rotated. - // Each test backs up a database of its own; alone, since the maintenance lock is the server's one. - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class BackupTests : IAsyncLifetime - { - readonly string _scratch = Path.Combine(Path.GetTempPath(), $"privapub-backup-tests-{Guid.NewGuid():N}"); - IMongoDatabase _database; - - string Backups => Path.Combine(_scratch, "backups"); - string Media => Path.Combine(_scratch, "media"); - string Trash => Path.Combine(_scratch, "media-trash"); - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - await PrivaPubHost.Shared(); - _database = DB.Default.Database().Client.GetDatabase($"PrivaPubBackup_{Guid.NewGuid():N}"); - Directory.CreateDirectory(Media); - Directory.CreateDirectory(Trash); - } - - public async ValueTask DisposeAsync() - { - if (_database != default) - await _database.Client.DropDatabaseAsync(_database.DatabaseNamespace.DatabaseName); - if (Directory.Exists(_scratch)) - Directory.Delete(_scratch, recursive: true); - } - - BackupContext Context(BackupOptions options = default) => new(_database, Backups, Media, Trash, "https://privapub.test", options ?? new BackupOptions()); - - static CancellationToken Token => TestContext.Current.CancellationToken; - - static List Read(string file) - { - using var gzip = new GZipStream(File.OpenRead(file), CompressionMode.Decompress); - using var reader = new StreamReader(gzip); - var documents = new List(); - while (reader.ReadLine() is { } line) - documents.Add(BsonDocument.Parse(line)); - return documents; - } - - async Task> Raw(string collection) => - (await (await _database.GetCollection(collection).FindAsync(FilterDefinition.Empty, cancellationToken: Token)).ToListAsync(Token)) - .Select(d => - { - var bytes = new byte[d.Slice.Length]; - d.Slice.GetBytes(0, bytes, 0, bytes.Length); - return bytes; - }) - .ToList(); - - [Fact] - public async Task Every_document_comes_back_byte_for_byte_and_the_salt_never_leaves() - { - var odd = new BsonDocument - { - { "_id", ObjectId.GenerateNewId() }, - { "Guid", new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard) }, - { "OldGuid", new BsonBinaryData(new byte[16], BsonBinarySubType.UuidLegacy) }, - { "Money", new BsonDecimal128(Decimal128.Parse("12345.678900")) }, - { "Long", new BsonInt64(long.MaxValue) }, - { "Int", 7 }, - { "Double", -0.0 }, - { "NaN", double.NaN }, - { "At", new BsonDateTime(new DateTime(2026, 10, 7, 3, 30, 0, 123, DateTimeKind.Utc)) }, - { "Stamp", new BsonTimestamp(1, 2) }, - { "Null", BsonNull.Value }, - { "Regex", new BsonRegularExpression("^a.b$", "i") }, - { "Nested", new BsonDocument { { "z", 1 }, { "a", new BsonArray { 1, "two", new BsonDocument("three", 3) } } } }, - { "Unicode", "città 🌍 \u0000 end" } - }; - // an ObjectRecord as big as a remote's long post gets - var big = new BsonDocument { { "_id", ObjectId.GenerateNewId() }, { "Json", new string('x', 10 * 1024 * 1024) } }; - await _database.GetCollection("Odd").InsertOneAsync(odd, cancellationToken: Token); - await _database.GetCollection("ObjectRecord").InsertOneAsync(big, cancellationToken: Token); - await _database.GetCollection("Odd").Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending("At"), new CreateIndexOptions { Name = "at", Unique = true }), cancellationToken: Token); - await _database.GetCollection("InteractionSalt").InsertOneAsync(new BsonDocument("Salt", "never in a backup"), cancellationToken: Token); - await _database.GetCollection("Job").InsertOneAsync(new BsonDocument("Kind", "Deliver"), cancellationToken: Token); - await _database.GetCollection("_migration_history_").InsertManyAsync([ - new BsonDocument { { "Number", 15 }, { "Name", "older" } }, new BsonDocument { { "Number", 16 }, { "Name", "focal points are finite" } }], cancellationToken: Token); - - var (backup, error) = await ServerBackup.Create(Context(), "manual", dbOnly: false, Token); - - Assert.Null(error); - var directory = Path.Combine(Backups, backup.Id); - Assert.False(Directory.Exists(directory + ServerBackup.PartialSuffix)); - Assert.Equal(await Raw("Odd"), Read(Path.Combine(directory, "db", "Odd.jsonl.gz")).Select(d => d.ToBson()).ToList()); - Assert.Equal(await Raw("ObjectRecord"), Read(Path.Combine(directory, "db", "ObjectRecord.jsonl.gz")).Select(d => d.ToBson()).ToList()); - - Assert.False(File.Exists(Path.Combine(directory, "db", "InteractionSalt.jsonl.gz"))); - Assert.False(File.Exists(Path.Combine(directory, "db", "Job.jsonl.gz"))); - foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) - Assert.DoesNotContain("never in a backup", await ReadAll(file)); - Assert.Contains(backup.Manifest.Excluded, e => e.Name == "InteractionSalt"); - - var odds = backup.Manifest.Collections.Single(c => c.Name == "Odd"); - Assert.Equal(1, odds.Count); - Assert.Contains(odds.Indexes, i => BsonDocument.Parse(i)["name"] == "at" && BsonDocument.Parse(i)["unique"].ToBoolean()); - Assert.Equal((16, "focal points are finite"), (backup.Manifest.MigrationNumber, backup.Manifest.NewestMigration)); - Assert.Equal("https://privapub.test", backup.Manifest.Host); - Assert.Equal(MongoFixture.Connection.Contains("directConnection=true"), backup.Manifest.Consistent); - Assert.Empty(await ServerBackup.Verify(Backups, backup.Id, Token)); - if (!OperatingSystem.IsWindows()) - { - Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead, File.GetUnixFileMode(Path.Combine(directory, "db", "Odd.jsonl.gz"))); - Assert.False(File.GetUnixFileMode(directory).HasFlag(UnixFileMode.OtherRead)); - } - } - - static async Task ReadAll(string file) - { - if (!file.EndsWith(".gz", StringComparison.Ordinal)) - return await File.ReadAllTextAsync(file, Token); - await using var gzip = new GZipStream(File.OpenRead(file), CompressionMode.Decompress); - using var reader = new StreamReader(gzip); - return await reader.ReadToEndAsync(Token); - } - - [Fact] - public async Task Media_rows_files_are_linked_from_the_live_directory_or_the_trash() - { - Directory.CreateDirectory(Path.Combine(Media, "2026", "10")); - await File.WriteAllTextAsync(Path.Combine(Media, "2026", "10", "live.jpg"), "live", Token); - await File.WriteAllTextAsync(Path.Combine(Media, "2026", "10", "live-preview.jpg"), "preview", Token); - Directory.CreateDirectory(Path.Combine(Trash, "2026", "10")); - await File.WriteAllTextAsync(Path.Combine(Trash, "2026", "10", "moving.jpg"), "moving", Token); - await File.WriteAllTextAsync(Path.Combine(Media, "2026", "10", "trashed.jpg"), "trashed", Token); - await _database.GetCollection("MediaAttachment").InsertManyAsync([ - new BsonDocument { { "FilePath", "2026/10/live.jpg" }, { "PreviewPath", "2026/10/live-preview.jpg" }, { "TrashedAt", BsonNull.Value } }, - new BsonDocument { { "FilePath", "2026/10/moving.jpg" } },//a file the janitor moved while its row was being trashed - new BsonDocument { { "FilePath", "2026/10/gone.jpg" } }, - new BsonDocument { { "FilePath", "2026/10/trashed.jpg" }, { "TrashedAt", DateTime.UtcNow } }, - new BsonDocument { { "FilePath", "../../etc/passwd" } }], cancellationToken: Token); - - var (backup, _) = await ServerBackup.Create(Context(), "manual", dbOnly: false, Token); - - var media = Path.Combine(Backups, backup.Id, "media"); - Assert.Equal("live", await File.ReadAllTextAsync(Path.Combine(media, "2026", "10", "live.jpg"), Token)); - Assert.Equal("preview", await File.ReadAllTextAsync(Path.Combine(media, "2026", "10", "live-preview.jpg"), Token)); - Assert.Equal("moving", await File.ReadAllTextAsync(Path.Combine(media, "2026", "10", "moving.jpg"), Token)); - Assert.False(File.Exists(Path.Combine(media, "2026", "10", "trashed.jpg"))); - Assert.Equal(["2026/10/live-preview.jpg", "2026/10/live.jpg", "2026/10/moving.jpg"], backup.Manifest.Media.List); - Assert.Equal((3, 1), (backup.Manifest.Media.Files, backup.Manifest.Media.Missing)); - - // a link, not a copy: the live file deleted, the backup's stays - File.Delete(Path.Combine(Media, "2026", "10", "live.jpg")); - Assert.Equal("live", await File.ReadAllTextAsync(Path.Combine(media, "2026", "10", "live.jpg"), Token)); - - // db-only lists them and links none - var (listed, _) = await ServerBackup.Create(Context(), "pre-deploy", dbOnly: true, Token); - Assert.False(Directory.Exists(Path.Combine(Backups, listed.Id, "media"))); - Assert.Equal(["2026/10/live-preview.jpg", "2026/10/moving.jpg"], listed.Manifest.Media.List); - Assert.Empty(await ServerBackup.Verify(Backups, listed.Id, Token)); - Assert.Empty(await ServerBackup.Verify(Backups, backup.Id, Token)); - } - - [Fact] - public async Task Verify_finds_an_altered_or_missing_file() - { - await _database.GetCollection("Post").InsertOneAsync(new BsonDocument("Content", "hello"), cancellationToken: Token); - await _database.GetCollection("Avatar").InsertOneAsync(new BsonDocument("UserName", "someone"), cancellationToken: Token); - var (backup, _) = await ServerBackup.Create(Context(), "manual", dbOnly: false, Token); - var db = Path.Combine(Backups, backup.Id, "db"); - - await File.AppendAllTextAsync(Path.Combine(db, "Post.jsonl.gz"), "tampered", Token); - File.Delete(Path.Combine(db, "Avatar.jsonl.gz")); - - Assert.Equal(["Avatar: missing", "Post: altered"], (await ServerBackup.Verify(Backups, backup.Id, Token)).Order()); - Assert.Equal(["no such backup, or no manifest"], await ServerBackup.Verify(Backups, "../" + backup.Id, Token)); - } - - [Fact] - public async Task One_backup_at_a_time() - { - await using (var held = await MaintenanceLock.Take("restore", Token)) - { - Assert.NotNull(held); - var (backup, error) = await ServerBackup.Create(Context(), "manual", dbOnly: true, Token); - Assert.Null(backup); - Assert.Contains("already running", error); - Assert.Equal("restore", (await MaintenanceLock.Current(Token)).What); - } - Assert.Null(await MaintenanceLock.Current(Token)); - Assert.NotNull((await ServerBackup.Create(Context(), "manual", dbOnly: true, Token)).Backup); - } - - [Fact] - public async Task A_holder_that_died_is_taken_over() - { - await using var dead = await MaintenanceLock.Take("backup", Token); - await DB.Default.Update().Match(l => l.ID == MaintenanceLock.Name) - .Modify(l => l.Heartbeat, DateTime.UtcNow.AddMinutes(-3)).ExecuteAsync(Token); - - Assert.Null(await MaintenanceLock.Current(Token)); - await using var alive = await MaintenanceLock.Take("restore", Token); - Assert.NotNull(alive); - Assert.Equal("restore", (await MaintenanceLock.Current(Token)).What); - } - - // the newest 7 nightly, the newest of each of the 4 weeks before, the newest 3 pre-deploy; manual ones kept - [Fact] - public void Rotation_keeps_days_weeks_and_the_last_deploys() - { - var today = new DateTime(2026, 10, 7, 3, 30, 0, DateTimeKind.Utc); - for (var day = 0; day < 60; day++) - Fake("nightly", today.AddDays(-day)); - for (var deploy = 0; deploy < 5; deploy++) - Fake("pre-deploy", today.AddDays(-deploy).AddHours(5)); - Fake("manual", today.AddYears(-1)); - Directory.CreateDirectory(Path.Combine(Backups, "20260901-000000-nightly" + ServerBackup.PartialSuffix)); - Directory.SetLastWriteTimeUtc(Path.Combine(Backups, "20260901-000000-nightly" + ServerBackup.PartialSuffix), today.AddDays(-30)); - - ServerBackup.Retain(Backups, new BackupOptions()); - - var kept = ServerBackup.List(Backups); - var nightly = kept.Where(b => b.Kind == "nightly").Select(b => b.CreatedAt).ToList(); - Assert.Equal(11, nightly.Count); - Assert.Equal(Enumerable.Range(0, 7).Select(d => today.AddDays(-d)), nightly.Take(7)); - Assert.Equal(4, nightly.Skip(7).Select(d => System.Globalization.ISOWeek.GetWeekOfYear(d)).Distinct().Count()); - Assert.Equal(3, kept.Count(b => b.Kind == "pre-deploy")); - Assert.Single(kept, b => b.Kind == "manual"); - Assert.Empty(Directory.EnumerateDirectories(Backups, "*" + ServerBackup.PartialSuffix)); - } - - void Fake(string kind, DateTime at) - { - var directory = Path.Combine(Backups, $"{at:yyyyMMdd-HHmmss}-{kind}"); - Directory.CreateDirectory(Path.Combine(directory, "db")); - new ArchiveManifest { Kind = kind, CreatedAt = at }.Write(directory); - } - - [Theory] - [InlineData("2026-10-07T03:29:00", "2026-10-06T03:31:00", false)]//yesterday's is the last one due - [InlineData("2026-10-07T03:31:00", "2026-10-06T03:31:00", true)] - [InlineData("2026-10-07T03:31:00", "2026-10-07T03:30:30", false)] - [InlineData("2026-10-07T01:00:00", "2026-10-05T03:31:00", true)]//missed last night: at once - public void A_nightly_backup_is_due_once_a_night(string now, string last, bool due) => - Assert.Equal(due, BackupScheduler.IsDue(DateTime.Parse(now, null, System.Globalization.DateTimeStyles.AdjustToUniversal), - new TimeOnly(3, 30), [DateTime.Parse(last, null, System.Globalization.DateTimeStyles.AdjustToUniversal)])); - - // the deploy's: the server's own database, through the configuration, without media links - [Fact] - public async Task The_deploy_backs_up_from_the_command_line() - { - var host = await PrivaPubHost.Shared(); - var output = new StringWriter(); - - Assert.Equal(0, await AdminCommands.Run(["backup", "--kind", "pre-deploy", "--db-only"], host.Services, output: output)); - var id = output.ToString().Split(':')[0]; - Assert.EndsWith("-pre-deploy", id); - Assert.Equal(2, await AdminCommands.Run(["backup", "--kind", "nightly"], host.Services, output: TextWriter.Null)); - - output = new StringWriter(); - Assert.Equal(0, await AdminCommands.Run(["backups"], host.Services, output: output)); - Assert.StartsWith(id + "\tpre-deploy", output.ToString()); - output = new StringWriter(); - Assert.Equal(0, await AdminCommands.Run(["backup", "verify", id], host.Services, output: output)); - Assert.Contains("whole", output.ToString()); - - var backups = host.Get(); - var manifest = backups.Find(id).Manifest; - Assert.Contains(manifest.Collections, c => c.Name == "Avatar"); - Assert.DoesNotContain(manifest.Collections, c => c.Name is "InteractionSalt" or "Job" or "MaintenanceLock" or "openiddict.tokens" or "AppConfiguration"); - Assert.Equal(ServerBackup.CodeMigration(), manifest.MigrationNumber); - Assert.True(backups.Delete(id)); - } - } -} diff --git a/PrivaPub.Tests/Infrastructure/CdnCatalogTests.cs b/PrivaPub.Tests/Infrastructure/CdnCatalogTests.cs deleted file mode 100644 index 48a29df..0000000 --- a/PrivaPub.Tests/Infrastructure/CdnCatalogTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using PrivaPub.Infrastructure.Geo; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Models.Jobs; - -using System.Net; - -namespace PrivaPub.Tests.Infrastructure -{ - public class CdnCatalogTests - { - static HttpResponseMessage Response(params (string Name, string Value)[] headers) - { - var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; - foreach (var (name, value) in headers) - response.Headers.TryAddWithoutValidation(name, value); - return response; - } - - [Theory] - [InlineData("cf-ray", "8c1f0e2b4d5a1234-FRA", "cloudflare.com")] - [InlineData("server", "cloudflare", "cloudflare.com")] - [InlineData("x-amz-cf-id", "abc==", "cloudfront.net")] - [InlineData("via", "1.1 5a1b.cloudfront.net (CloudFront)", "cloudfront.net")] - [InlineData("x-served-by", "cache-fra-eddf8230045-FRA", "fastly.com")] - [InlineData("server", "BunnyCDN-DE1-1078", "bunny.net")] - [InlineData("akamai-grn", "0.1234", "akamai.com")] - [InlineData("x-azure-ref", "20261004T101010Z-abc", "azure.microsoft.com")] - [InlineData("x-vercel-id", "fra1::abc", "vercel.com")] - [InlineData("x-iinfo", "1-2-3", "imperva.com")] - public void A_cdn_is_recognised_by_its_fingerprint(string header, string value, string domain) - { - Assert.Equal(domain, CdnCatalog.FromResponse(Response((header, value)))?.Domain); - } - - [Fact] - public void A_plain_server_has_no_cdn_and_an_unknown_cdn_names_itself() - { - Assert.Null(CdnCatalog.FromResponse(Response(("server", "nginx"), ("via", "1.1 varnish")))); - var named = CdnCatalog.FromResponse(Response(("x-cdn", "EdgeOne"))); - Assert.Equal(("EdgeOne", null, "edgeone"), (named.Name, named.Domain, named.Key)); - Assert.Null(CdnCatalog.FromResponse(Response(("x-cdn", "

", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" }; - var local = new Post { Text = "**bold** raw", 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().OneAsync(remote.ID, token); - var migratedLocal = await DB.Default.Find().OneAsync(local.ID, token); - Assert.Equal("

hi

", migratedRemote.Text); - Assert.Equal("

hi

", migratedRemote.ContentHtml); - Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat); - Assert.Equal("**bold** raw", migratedLocal.Text); - Assert.Equal("

bold <b>raw</b>

", 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().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().OneAsync(fetched.ID, token); - var afterDelivered = await DB.Default.Find().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 { 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().Match(g => g.Kind != GroupKind.Circle && g.ID != group.ID).ExecuteAsync(token)).Select(g => g.ID).ToList(); - var federatedGroupPosts = (await DB.Default.Find().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().OneAsync(conversation.ID, token)).ParticipantsKey); - Assert.Equal(GroupKind.Circle, (await DB.Default.Find().OneAsync(group.ID, token)).Kind); - Assert.True((await DB.Default.Find().OneAsync(localInGroup.ID, token)).IsLocalOnly); - Assert.False((await DB.Default.Find().OneAsync(remoteInGroup.ID, token)).IsLocalOnly); - Assert.False((await DB.Default.Find().OneAsync(ungrouped.ID, token)).IsLocalOnly); - } - finally - { - if (communities.Count > 0) - await DB.Default.Update().Match(g => communities.Contains(g.ID)).Modify(g => g.Kind, GroupKind.Community).ExecuteAsync(token); - if (federatedGroupPosts.Count > 0) - await DB.Default.Update().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().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(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().OneAsync(moved.ID, token); - Assert.NotNull(after.AbandonedAt); - Assert.Equal("moved to the job queue", after.LastError); - } - Assert.Null((await DB.Default.Find().OneAsync(delivered.ID, token)).AbandonedAt); - Assert.Null((await DB.Default.Find().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 = "

first

", EditedAt = DateTime.MinValue }, new PostRevision { ContentHtml = "

second

", 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().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().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().MatchID(avatar.ID).Modify(b => b.Unset(a => a.PublishedOn)).ExecuteAsync(token); - await DB.Default.Update().MatchID(group.ID).Modify(b => b.Unset(g => g.PublishedOn)).ExecuteAsync(token); - Assert.False(await DB.Default.Find().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().OneAsync(avatar.ID, token)).PublishedOn, (await DB.Default.Find().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().OneAsync(dated.ID, token)).PublishedOn); - } - - [Fact] - public async Task A_server_with_an_administrator_is_set_up() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - var token = TestContext.Current.CancellationToken; - await DB.Default.DeleteAsync(PrivaPub.Models.ServerSetup.Name); - var admin = new RootUser { UserName = $"admin{Guid.NewGuid():N}"[..20] }; - admin.SetAdmin(true); - await DB.Default.SaveAsync(admin, token); - Assert.True(await PrivaPub.Models.ServerSetup.IsPending(token)); - - await new _018_a_server_with_an_administrator_is_set_up().UpgradeAsync(); - - Assert.False(await PrivaPub.Models.ServerSetup.IsPending(token)); - } - } -} diff --git a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs b/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs deleted file mode 100644 index ca833da..0000000 --- a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using MongoDB.Bson; - -using PrivaPub.Infrastructure.Ids; -using PrivaPub.Models.User; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Infrastructure -{ - public class PrivacyIdsTests - { - [Fact] - public void Persona_ids_carry_only_the_day_and_share_nothing_else() - { - var avatar = new Avatar(); - var first = (string)avatar.GenerateNewID(); - var second = (string)new Avatar { PublishedOn = avatar.PublishedOn }.GenerateNewID(); - var group = new GroupEntity(); - - Assert.True(ObjectId.TryParse(first, out var a)); - Assert.True(ObjectId.TryParse(second, out var b)); - Assert.Equal(avatar.PublishedOn, a.CreationTime); - Assert.InRange(a.CreationTime, DateTime.UtcNow.Date.AddDays(-13), DateTime.UtcNow.Date); - Assert.Equal(a.CreationTime, b.CreationTime); - Assert.NotEqual(first[8..14], second[8..14]); - Assert.Equal(group.PublishedOn, ObjectId.Parse((string)group.GenerateNewID()).CreationTime); - } - - [Fact] - public void A_remote_post_id_sorts_by_its_published_time() - { - var older = PrivacyIds.At(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - var newer = PrivacyIds.At(new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc)); - - Assert.True(string.CompareOrdinal(older, newer) < 0); - Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime); - } - - [Fact] - public void A_post_arriving_live_sorts_after_everything_already_stored() - { - var local = ObjectId.GenerateNewId().ToString(); - var reply = PrivacyIds.Arrived(DateTime.UtcNow.AddSeconds(-30)); - var skewed = PrivacyIds.Arrived(DateTime.UtcNow.AddYears(50)); - - Assert.True(string.CompareOrdinal(local, reply) < 0); - Assert.True(string.CompareOrdinal(reply, skewed) < 0); - Assert.True(ObjectId.Parse(skewed).CreationTime < DateTime.UtcNow.AddMinutes(1)); - } - - [Fact] - public void A_backfilled_post_keeps_its_published_time() => - Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime); - - [Fact] - public void A_persona_publishes_a_day_within_two_weeks_before_its_creation() - { - var created = new DateTime(2026, 10, 1, 15, 30, 0, DateTimeKind.Utc); - var days = Enumerable.Range(0, 200).Select(_ => PrivacyIds.PublishedDay(created)).ToList(); - - Assert.All(days, d => Assert.Equal(TimeSpan.Zero, d.TimeOfDay)); - Assert.All(days, d => Assert.InRange(d, created.Date.AddDays(-13), created.Date)); - Assert.True(days.Distinct().Count() > 1); - } - - [Fact] - public void A_published_time_in_the_future_is_clamped() => - Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2)); - } -} diff --git a/PrivaPub.Tests/Infrastructure/RestoreTests.cs b/PrivaPub.Tests/Infrastructure/RestoreTests.cs deleted file mode 100644 index 81df874..0000000 --- a/PrivaPub.Tests/Infrastructure/RestoreTests.cs +++ /dev/null @@ -1,316 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Backup; -using PrivaPub.Infrastructure.Cli; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -namespace PrivaPub.Tests.Infrastructure -{ - // A backup restored at boot: what was made since is lost, what protects is not (owner decision 2026-10-07). A server - // in a database of its own is backed up, changed, and restored; alone, since the maintenance lock is the server's one. - [Trait("Category", "Integration")] - [Xunit.Collection(nameof(Exclusive))] - public sealed class RestoreTests : IAsyncLifetime - { - const string Host = "https://privapub.test"; - - readonly string _scratch = Path.Combine(Path.GetTempPath(), $"privapub-restore-tests-{Guid.NewGuid():N}"); - IMongoDatabase _database; - - string Backups => Path.Combine(_scratch, "backups"); - string Media => Path.Combine(_scratch, "media"); - string Trash => Path.Combine(_scratch, "media-trash"); - - static CancellationToken Token => TestContext.Current.CancellationToken; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - await PrivaPubHost.Shared(); - _database = DB.Default.Database().Client.GetDatabase($"PrivaPubRestore_{Guid.NewGuid():N}"); - Directory.CreateDirectory(Media); - Directory.CreateDirectory(Trash); - } - - public async ValueTask DisposeAsync() - { - if (_database != default) - await _database.Client.DropDatabaseAsync(_database.DatabaseNamespace.DatabaseName); - if (Directory.Exists(_scratch)) - Directory.Delete(_scratch, recursive: true); - } - - BackupContext Context(string host = Host) => new(_database, Backups, Media, Trash, host, new BackupOptions()); - - IMongoCollection C(string name) => _database.GetCollection(name); - - async Task One(string collection, BsonDocument filter) => - await (await C(collection).FindAsync(filter, cancellationToken: Token)).FirstOrDefaultAsync(Token); - - async Task> All(string collection) => - await (await C(collection).FindAsync(FilterDefinition.Empty, cancellationToken: Token)).ToListAsync(Token); - - static BsonDocument Id(ObjectId id) => new("_id", id); - - static Task Set(IMongoCollection collection, ObjectId id, BsonDocument fields) => - collection.UpdateOneAsync(Id(id), new BsonDocument("$set", fields), cancellationToken: Token); - - // the ids of the town this test builds - readonly ObjectId _alice = ObjectId.GenerateNewId(), _alicePersona = ObjectId.GenerateNewId(); - readonly ObjectId _deletedPost = ObjectId.GenerateNewId(), _editedPost = ObjectId.GenerateNewId(), _laterPost = ObjectId.GenerateNewId(); - readonly ObjectId _deletedMedia = ObjectId.GenerateNewId(), _laterMedia = ObjectId.GenerateNewId(); - readonly ObjectId _bob = ObjectId.GenerateNewId(), _bobPersona = ObjectId.GenerateNewId(); - - static string Uri(ObjectId post) => $"{Host}/peasants/alice/posts/{post}"; - - async Task TownBackedUp() - { - await File.WriteAllTextAsync(Path.Combine(Media, "deleted.jpg"), "deleted", Token); - await C("RootUser").InsertOneAsync(new BsonDocument { { "_id", _alice }, { "UserName", "alice" }, { "HashedPassword", "old" }, { "SessionStamp", "s1" }, { "Policies", new BsonArray { "IsUser" } }, { "DeletedAt", BsonNull.Value } }, cancellationToken: Token); - await C("Avatar").InsertOneAsync(new BsonDocument { { "_id", _alicePersona }, { "UserName", "alice" }, { "SuspendedAt", BsonNull.Value }, { "DeletionAt", BsonNull.Value } }, cancellationToken: Token); - await C("RootToAvatar").InsertOneAsync(new BsonDocument { { "RootId", _alice.ToString() }, { "AvatarId", _alicePersona.ToString() } }, cancellationToken: Token); - await C("ReservedName").InsertOneAsync(new BsonDocument("Name", "alice"), cancellationToken: Token); - await C("Post").InsertManyAsync([ - new BsonDocument { { "_id", _deletedPost }, { "GroupUserId", _alicePersona.ToString() }, { "IsFederatedCopy", false }, { "ObjectURI", Uri(_deletedPost) }, { "Text", "regret" }, - { "Media", new BsonArray { new BsonDocument("AttachmentId", _deletedMedia.ToString()) } }, { "DeletedAt", BsonNull.Value } }, - new BsonDocument { { "_id", _editedPost }, { "GroupUserId", _alicePersona.ToString() }, { "IsFederatedCopy", false }, { "ObjectURI", Uri(_editedPost) }, { "Text", "first words" }, { "DeletedAt", BsonNull.Value } } - ], cancellationToken: Token); - await C("TimelineEntry").InsertOneAsync(new BsonDocument { { "PostId", _deletedPost.ToString() }, { "ReblogOfPostId", BsonNull.Value } }, cancellationToken: Token); - await C("MediaAttachment").InsertOneAsync(new BsonDocument { { "_id", _deletedMedia }, { "PostId", _deletedPost.ToString() }, { "FilePath", "deleted.jpg" }, { "TrashedAt", BsonNull.Value } }, cancellationToken: Token); - await C("Follower").InsertOneAsync(new BsonDocument { { "LocalActorId", _alicePersona.ToString() }, { "ActorURI", "https://elsewhere.example/users/left" } }, cancellationToken: Token); - await C("RemoteInstance").InsertOneAsync(new BsonDocument { { "Host", "down.example" }, { "ConsecutiveFailures", 9 }, { "UnavailableUntil", DateTime.UtcNow.AddDays(1) } }, cancellationToken: Token); - await C("_migration_history_").InsertOneAsync(new BsonDocument { { "Number", 16 }, { "Name", "focal points are finite" } }, cancellationToken: Token); - await C("InteractionSalt").InsertOneAsync(new BsonDocument("Salt", "the backup's day"), cancellationToken: Token); - - var (backup, error) = await ServerBackup.Create(Context(), "manual", dbOnly: false, Token); - Assert.Null(error); - return backup; - } - - // what happens after the backup: protective acts, and things merely made or changed - async Task LifeGoesOn() - { - // alice regrets a post: deleted, its media trashed and moved out of what is served - await Set(C("Post"), _deletedPost, new BsonDocument { { "DeletedAt", DateTime.UtcNow }, { "Text", BsonNull.Value }, { "Media", new BsonArray() } }); - await Set(C("MediaAttachment"), _deletedMedia, new BsonDocument { { "TrashedAt", DateTime.UtcNow }, { "TrashReason", "deleted" } }); - File.Move(Path.Combine(Media, "deleted.jpg"), Path.Combine(Trash, "deleted.jpg")); - await C("TimelineEntry").DeleteManyAsync(new BsonDocument("PostId", _deletedPost.ToString()), Token); - // an edit, a new post with a picture: not protective - await Set(C("Post"), _editedPost, new BsonDocument("Text", "second words")); - await File.WriteAllTextAsync(Path.Combine(Media, "later.jpg"), "later", Token); - await C("Post").InsertOneAsync(new BsonDocument { { "_id", _laterPost }, { "GroupUserId", _alicePersona.ToString() }, { "IsFederatedCopy", false }, { "ObjectURI", Uri(_laterPost) }, { "Text", "later" }, { "DeletedAt", BsonNull.Value } }, cancellationToken: Token); - await C("MediaAttachment").InsertOneAsync(new BsonDocument { { "_id", _laterMedia }, { "PostId", _laterPost.ToString() }, { "FilePath", "later.jpg" }, { "TrashedAt", BsonNull.Value } }, cancellationToken: Token); - // a follower leaves, another comes - await C("Follower").DeleteManyAsync(FilterDefinition.Empty, Token); - await C("Follower").InsertOneAsync(new BsonDocument { { "LocalActorId", _alicePersona.ToString() }, { "ActorURI", "https://elsewhere.example/users/came" } }, cancellationToken: Token); - // alice blocks someone, the admin blocks a server, someone is reported, alice's persona is suspended, she changes - // her password - await C("Block").InsertOneAsync(new BsonDocument { { "AvatarId", _alicePersona.ToString() }, { "TargetActorURI", "https://elsewhere.example/users/troll" } }, cancellationToken: Token); - await C("DomainBlock").InsertOneAsync(new BsonDocument { { "Domain", "evil.example" }, { "Severity", "Suspend" } }, cancellationToken: Token); - await C("Report").InsertOneAsync(new BsonDocument("Comment", "harassment"), cancellationToken: Token); - await Set(C("Avatar"), _alicePersona, new BsonDocument("SuspendedAt", DateTime.UtcNow)); - await Set(C("RootUser"), _alice, new BsonDocument("HashedPassword", "new")); - // bob arrives - await C("RootUser").InsertOneAsync(new BsonDocument { { "_id", _bob }, { "UserName", "bob" }, { "HashedPassword", "bob's" }, { "Policies", new BsonArray { "IsUser" } }, { "DeletedAt", BsonNull.Value } }, cancellationToken: Token); - await C("Avatar").InsertOneAsync(new BsonDocument { { "_id", _bobPersona }, { "UserName", "bob" }, { "DeletionAt", BsonNull.Value } }, cancellationToken: Token); - await C("RootToAvatar").InsertOneAsync(new BsonDocument { { "RootId", _bob.ToString() }, { "AvatarId", _bobPersona.ToString() } }, cancellationToken: Token); - await C("ReservedName").InsertOneAsync(new BsonDocument("Name", "bob"), cancellationToken: Token); - // sessions, a list made, today's salt, work queued - await C("openiddict.tokens").InsertOneAsync(new BsonDocument("subject", "alice"), cancellationToken: Token); - await C("PersonaList").InsertOneAsync(new BsonDocument { { "AvatarId", _alicePersona.ToString() }, { "Title", "friends" } }, cancellationToken: Token); - await C("InteractionSalt").DeleteManyAsync(FilterDefinition.Empty, Token); - await C("InteractionSalt").InsertOneAsync(new BsonDocument("Salt", "today's"), cancellationToken: Token); - await C("Job").InsertOneAsync(new BsonDocument("Kind", "Deliver"), cancellationToken: Token); - } - - async Task AssertRestoredAndProtected(BackupInfo backup) - { - // lost: what was merely made or changed since - Assert.Equal("first words", (await One("Post", Id(_editedPost)))["Text"].AsString); - var later = await One("Post", Id(_laterPost)); - Assert.False(later["DeletedAt"].IsBsonNull); - Assert.True(later["Text"].IsBsonNull); - Assert.Empty(await All("PersonaList")); - Assert.Equal(16, (await One("_migration_history_", new BsonDocument()))["Number"].ToInt32()); - - // kept: every protective act - var deleted = await One("Post", Id(_deletedPost)); - Assert.False(deleted["DeletedAt"].IsBsonNull); - Assert.True(deleted["Text"].IsBsonNull); - Assert.Null(await One("TimelineEntry", new BsonDocument("PostId", _deletedPost.ToString()))); - Assert.False((await One("MediaAttachment", Id(_deletedMedia)))["TrashedAt"].IsBsonNull); - Assert.Equal("restore: made after the backup", (await One("MediaAttachment", Id(_laterMedia)))["TrashReason"].AsString); - Assert.Equal(["https://elsewhere.example/users/came"], (await All("Follower")).Select(f => f["ActorURI"].AsString)); - Assert.NotNull(await One("Block", new BsonDocument("TargetActorURI", "https://elsewhere.example/users/troll"))); - Assert.NotNull(await One("DomainBlock", new BsonDocument("Domain", "evil.example"))); - Assert.NotNull(await One("Report", new BsonDocument("Comment", "harassment"))); - Assert.False((await One("Avatar", Id(_alicePersona)))["SuspendedAt"].IsBsonNull); - var alice = await One("RootUser", Id(_alice)); - Assert.Equal("new", alice["HashedPassword"].AsString); - Assert.NotEqual("s1", alice["SessionStamp"].AsString); - - // bob, made since: deleted, his name kept - var bob = await One("RootUser", Id(_bob)); - Assert.Equal($"deleted-{_bob}", bob["UserName"].AsString); - Assert.True(bob["HashedPassword"].IsBsonNull); - Assert.False(bob["DeletedAt"].IsBsonNull); - Assert.False((await One("Avatar", Id(_bobPersona)))["DeletionAt"].IsBsonNull); - Assert.NotNull(await One("ReservedName", new BsonDocument("Name", "bob"))); - - // what a backup never holds is as it was; sessions end; circuits close - Assert.Equal("today's", (await One("InteractionSalt", new BsonDocument()))["Salt"].AsString); - Assert.Single(await All("Job")); - Assert.Empty(await All("openiddict.tokens")); - Assert.Equal(0, (await One("RemoteInstance", new BsonDocument("Host", "down.example")))["ConsecutiveFailures"].ToInt32()); - - // the picture of the deleted post came back from the backup, to be trashed again by the janitor - Assert.True(File.Exists(Path.Combine(Media, "deleted.jpg"))); - Assert.Null(RestoreMarker.Read(Backups)); - var record = await (await _database.GetCollection(nameof(RestoreRecord)).FindAsync(r => r.Backup == backup.Id, cancellationToken: Token)).FirstAsync(Token); - Assert.Equal(["bob"], record.Report.RootsTombstoned); - Assert.Equal(["bob"], record.Report.PersonasTombstoned); - Assert.Equal(1, record.Report.PostsGone); - Assert.Equal(1, record.Report.PostsDeleted); - } - - [Fact] - public async Task A_restore_loses_what_was_made_since_but_never_a_protective_act() - { - var backup = await TownBackedUp(); - await LifeGoesOn(); - - Assert.Null(await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - Assert.Contains("already pending", await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - Assert.Equal(RestoreOutcome.Restored, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - - await AssertRestoredAndProtected(backup); - var before = ServerBackup.List(Backups).Single(b => b.Kind == "pre-restore"); - Assert.Equal(before.Id, (await (await _database.GetCollection(nameof(RestoreRecord)).FindAsync(FilterDefinition.Empty, cancellationToken: Token)).FirstAsync(Token)).PreRestore); - } - - // an attempt that died midway: the next one starts again from the backup, with the same pre-restore backup, never - // one of the half-restored database - [Fact] - public async Task A_restore_that_died_midway_converges_on_the_next_boot() - { - var backup = await TownBackedUp(); - await LifeGoesOn(); - Assert.Null(await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - Assert.Equal(RestoreOutcome.Restored, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - var before = ServerBackup.List(Backups).Single(b => b.Kind == "pre-restore"); - - // as if the boot died after importing a collection, with the marker saying so - await C("Post").DeleteManyAsync(FilterDefinition.Empty, Token); - await C("Block").DeleteManyAsync(FilterDefinition.Empty, Token); - new RestoreMarker { Backup = backup.Id, PreRestore = before.Id, State = "running", Attempts = 1, RequestedBy = "admin" }.Write(Backups); - - Assert.Equal(RestoreOutcome.Restored, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - Assert.Single(ServerBackup.List(Backups), b => b.Kind == "pre-restore"); - await AssertRestoredAndProtected(backup); - } - - [Fact] - public async Task A_backup_from_elsewhere_from_a_newer_build_or_altered_is_refused() - { - var backup = await TownBackedUp(); - - Assert.Contains("not https://other.test", await ServerRestore.Request(Context("https://other.test"), backup.Id, "admin", Token)); - Assert.Contains("no such backup", await ServerRestore.Request(Context(), "../" + backup.Id, "admin", Token)); - - var manifest = ArchiveManifest.Read(Path.Combine(Backups, backup.Id)); - manifest.MigrationNumber = ServerBackup.CodeMigration() + 1; - manifest.Write(Path.Combine(Backups, backup.Id)); - Assert.Contains("newer build", await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - - manifest.MigrationNumber = 16; - manifest.Write(Path.Combine(Backups, backup.Id)); - await File.AppendAllTextAsync(Path.Combine(Backups, backup.Id, "db", "Post.jsonl.gz"), "x", Token); - Assert.Contains("Post: altered", await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - Assert.Null(RestoreMarker.Read(Backups)); - } - - // refused at boot before anything changed (the backup went meanwhile): recorded, and the server boots as it was - [Fact] - public async Task A_restore_that_cannot_start_is_abandoned_and_changes_nothing() - { - var backup = await TownBackedUp(); - await LifeGoesOn(); - Assert.Null(await ServerRestore.Request(Context(), backup.Id, "admin", Token)); - ServerBackup.Delete(Backups, backup.Id); - - Assert.Equal(RestoreOutcome.Abandoned, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - - Assert.Equal("second words", (await One("Post", Id(_editedPost)))["Text"].AsString); - Assert.Null(RestoreMarker.Read(Backups)); - Assert.DoesNotContain(ServerBackup.List(Backups), b => b.Kind == "pre-restore"); - var record = await (await _database.GetCollection(nameof(RestoreRecord)).FindAsync(FilterDefinition.Empty, cancellationToken: Token)).FirstAsync(Token); - Assert.True(record.Abandoned); - Assert.Contains("no such backup", record.Error); - } - - [Fact] - public async Task After_three_failures_it_waits_for_someone_to_look() - { - var backup = await TownBackedUp(); - await LifeGoesOn(); - new RestoreMarker { Backup = backup.Id, PreRestore = "gone", State = "running", Attempts = RestoreMarker.MaxAttempts, Error = "disk full" }.Write(Backups); - - Assert.Equal(RestoreOutcome.GaveUp, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - Assert.Equal("second words", (await One("Post", Id(_editedPost)))["Text"].AsString); - Assert.NotNull(RestoreMarker.Read(Backups)); - - await File.WriteAllTextAsync(RestoreMarker.PathIn(Backups), "{ not json", Token); - Assert.Equal(RestoreOutcome.GaveUp, await ServerRestore.ApplyPending(Context(), NullLogger.Instance, Token)); - } - - // a command waits while a restore does; asking how it goes doesn't - [Fact] - public async Task Commands_wait_for_a_restore_asked_for() - { - var host = await PrivaPubHost.Shared(); - var backups = host.Get(); - new RestoreMarker { Backup = "20260101-000000-manual", RequestedBy = "cli" }.Write(backups.Root); - try - { - Assert.Equal(MaintenanceGate.TryLater, await MaintenanceGate.Enter(host.Services, ["promote", "someone"], Token)); - Assert.Null(await MaintenanceGate.Enter(host.Services, ["restore", "--status"], Token)); - var output = new StringWriter(); - Assert.Equal(0, await AdminCommands.Run(["restore", "--status"], host.Services, output: output)); - Assert.Contains("waiting: 20260101-000000-manual, pending", output.ToString()); - Assert.Equal(1, await AdminCommands.Run(["restore", "20260101-000000-manual"], host.Services, output: TextWriter.Null)); - } - finally - { - RestoreMarker.Clear(backups.Root); - } - } - - // FEP-8fcf: for two weeks after a restore, no digests, and no follow undone for them - [Fact] - public async Task Followers_digests_rest_for_two_weeks_after_a_restore() - { - RestoreRecord.Forget(); - Assert.False(await RestoreRecord.InFollowersGrace(Token)); - var record = new RestoreRecord { Backup = "test", RestoredAt = DateTime.UtcNow.AddDays(-13) }; - await DB.Default.SaveAsync(record, Token); - try - { - RestoreRecord.Forget(); - Assert.True(await RestoreRecord.InFollowersGrace(Token)); - await DB.Default.Update().MatchID(record.ID).Modify(r => r.RestoredAt, DateTime.UtcNow.AddDays(-15)).ExecuteAsync(Token); - RestoreRecord.Forget(); - Assert.False(await RestoreRecord.InFollowersGrace(Token)); - } - finally - { - await DB.Default.DeleteAsync(record.ID); - RestoreRecord.Forget(); - } - } - } -} diff --git a/PrivaPub.Tests/Infrastructure/TopologyTests.cs b/PrivaPub.Tests/Infrastructure/TopologyTests.cs deleted file mode 100644 index 1ad10ca..0000000 --- a/PrivaPub.Tests/Infrastructure/TopologyTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Data; -using PrivaPub.Tests.Support; - -namespace PrivaPub.Tests.Infrastructure -{ - [Trait("Category", "Integration")] - public sealed class TopologyTests - { - // tools/ci/with-test-mongod.sh starts a one-member replica set, as production's mongod is: the backup's snapshot reads - // are what the tests exercise - [Fact] - public async Task The_test_mongod_is_a_replica_set_like_productions() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - Assert.SkipUnless(MongoFixture.Connection.Contains("directConnection=true"), "a mongod not started by tools/ci/with-test-mongod.sh"); - Assert.True(await MongoTopology.IsReplicaSet(DB.Default.Database(), TestContext.Current.CancellationToken)); - } - } -} diff --git a/PrivaPub.Tests/PrivaPub.Tests.csproj b/PrivaPub.Tests/PrivaPub.Tests.csproj deleted file mode 100644 index 64004cf..0000000 --- a/PrivaPub.Tests/PrivaPub.Tests.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - disable - enable - Exe - false - - - - - - - - - - - - - - - - - - - - - - diff --git a/PrivaPub.Tests/Statistics/AdminStatisticsTests.cs b/PrivaPub.Tests/Statistics/AdminStatisticsTests.cs deleted file mode 100644 index 23c15c0..0000000 --- a/PrivaPub.Tests/Statistics/AdminStatisticsTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statistics; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Statistics -{ - public class StatisticsQueriesTests - { - [Fact] - public void Percentiles_come_from_the_latency_buckets() - { - var latency = new Dictionary { ["out:le50ms"] = 50, ["out:le250ms"] = 45, ["out:le5000ms"] = 4, ["out:inf"] = 1, ["in:le50ms"] = 9 }; - - Assert.Equal(50, StatisticsQueries.Percentile(latency, "out", 0.5)); - Assert.Equal(250, StatisticsQueries.Percentile(latency, "out", 0.95)); - Assert.Equal(5000, StatisticsQueries.Percentile(latency, "out", 0.99)); - Assert.Equal(30001, StatisticsQueries.Percentile(latency, "out", 1)); - Assert.Null(StatisticsQueries.Percentile(latency, "http:actor", 0.5)); - } - - [Fact] - public void Outcomes_add_up_per_channel() - { - var counters = new Dictionary - { - ["out:Create:Note:ok"] = 3, ["out:Create:Note:retry:503"] = 2, ["out:Like:-:ok"] = 1, ["in:Create:Note:accepted:stored"] = 4, ["recv:Create:202:queued"] = 9 - }; - - Assert.Equal(new Dictionary { ["ok"] = 4, ["retry"] = 2 }, StatisticsQueries.Outcomes(counters, "out")); - Assert.Equal(new Dictionary { ["accepted"] = 4 }, StatisticsQueries.Outcomes(counters, "in")); - } - } - - [Trait("Category", "Integration")] - public sealed class AdminStatisticsTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - [Fact] - public async Task Only_the_admin_reads_the_statistics() - { - var token = TestContext.Current.CancellationToken; - var plain = await _host.SignUp(); - using var anonymous = _host.Client(); - using var user = _host.As(plain.Jwt); - - Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/clientapi/admin/statistics/overview", token)).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await user.GetAsync("/clientapi/admin/statistics/overview", token)).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await user.PostAsync("/clientapi/admin/statistics/rollups/2001-01-01", default, token)).StatusCode); - } - - [Fact] - public async Task The_admin_sees_a_server_its_events_and_the_overview() - { - var token = TestContext.Current.CancellationToken; - var admin = await _host.Admin(); - var persona = await _host.Persona(await _host.SignUp(), "stats"); - var bob = new RemoteActor(_peer, "bob"); - var noteId = $"{_peer.A}/notes/{Guid.NewGuid():N}"; - var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"); - using (var client = _host.Client()) - Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", new JsonObject - { - ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = to.DeepClone(), - ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = to.DeepClone(), ["content"] = "hi" } - }), token)).StatusCode); - await _host.RunInbox(noteId + "/activity", token); - await _host.Get().Flush(token); - using var adminClient = _host.As(admin.Jwt); - - var host = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/hosts/127.0.0.1?days=1", token))!; - var overview = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/overview?days=1", token))!; - var hosts = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/hosts?days=1&sort=host", token))!; - var events = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/events?host=127.0.0.1&channel=in&limit=5", token))!; - - var today = host["days"]!.AsArray().Last()!; - Assert.True(today["live"]!.GetValue()); - Assert.True(today["counters"]!["in:Create:Note:accepted:stored"]!.GetValue() >= 1); - Assert.Contains("DB-IP", host["attribution"]!.GetValue()); - Assert.True(overview["channels"]!["in"]!.GetValue() >= 1); - Assert.Contains(hosts["hosts"]!.AsArray(), h => h!["host"]!.GetValue() == "127.0.0.1"); - Assert.NotEmpty(events.AsArray()); - Assert.DoesNotContain(persona.UserName, host.ToJsonString()); - Assert.DoesNotContain(bob.Id, host.ToJsonString()); - Assert.Equal(HttpStatusCode.NotFound, (await adminClient.GetAsync($"/clientapi/admin/statistics/hosts/never{Guid.NewGuid():N}.example", token)).StatusCode); - } - - [Fact] - public async Task The_admin_can_refold_a_past_day_and_describe_a_server_again() - { - var token = TestContext.Current.CancellationToken; - var admin = await _host.Admin(); - using var client = _host.As(admin.Jwt); - var describe = $"described{Guid.NewGuid():N}.example"; - - Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/clientapi/admin/statistics/rollups/{DateTime.UtcNow:yyyy-MM-dd}", default, token)).StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync("/clientapi/admin/statistics/rollups/yesterday", default, token)).StatusCode); - Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsync("/clientapi/admin/statistics/rollups/2001-01-05", default, token)).StatusCode); - Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsync($"/clientapi/admin/statistics/hosts/{describe}/describe", default, token)).StatusCode); - - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.RollupDay && j.Payload == "2001-01-05" && j.DedupeKey.StartsWith("rollup|2001-01-05|rerun|")).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == describe).ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Statistics/CrawlerTests.cs b/PrivaPub.Tests/Statistics/CrawlerTests.cs deleted file mode 100644 index 6f873fe..0000000 --- a/PrivaPub.Tests/Statistics/CrawlerTests.cs +++ /dev/null @@ -1,276 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Federation.Crawler; -using PrivaPub.Federation.Moderation; -using PrivaPub.Federation.Objects; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text; - -namespace PrivaPub.Tests.Statistics -{ - public class RobotsTxtTests - { - const string Robots = """ - # comments are ignored - User-agent: * - Disallow: /private - Allow: /private/ok - - User-agent: PrivaPub-Stargazer - User-agent: other - Disallow: /api/ - Allow: /api/v1/instance$ - - User-agent: greedy - Disallow: /*.json$ - """; - - [Fact] - public void The_crawlers_own_group_wins_and_the_longest_rule_decides() - { - var mine = RobotsTxt.Parse(Robots, "PrivaPub-Stargazer"); - var anyone = RobotsTxt.Parse(Robots, "SomethingElse"); - var greedy = RobotsTxt.Parse(Robots, "greedy"); - - Assert.False(mine.Allows("/api/v1/instance/peers")); - Assert.True(mine.Allows("/api/v1/instance")); - Assert.True(mine.Allows("/private")); - Assert.True(mine.Allows("/.well-known/nodeinfo")); - Assert.False(anyone.Allows("/private/nope")); - Assert.True(anyone.Allows("/private/ok/yes")); - Assert.True(anyone.Allows("/api/v1/instance/peers")); - Assert.False(greedy.Allows("/nodeinfo/2.1.json")); - Assert.True(greedy.Allows("/nodeinfo/2.1.json?x=1")); - } - - [Fact] - public void An_error_keeps_the_crawler_out_and_a_missing_file_lets_it_in() - { - Assert.True(RobotsTxt.From(404, default, Stargazer.Token).Allows("/anything")); - Assert.False(RobotsTxt.From(503, default, Stargazer.Token).Allows("/anything")); - Assert.False(RobotsTxt.From(0, default, Stargazer.Token).Allows("/anything")); - Assert.False(RobotsTxt.From(200, "User-agent: *\nDisallow: /", Stargazer.Token).Allows("/robots.txt")); - Assert.True(RobotsTxt.From(200, "User-agent: *\nDisallow:", Stargazer.Token).Allows("/x")); - } - - [Fact] - public void Peer_lists_yield_only_names_we_could_ever_reach() - { - var names = new[] { "Social.Example.org.", "10.0.0.1", "host.example.org:8443", "x.onion", "printer.local", "bücher.example.org", "a.test", "noDot", "social.example.org" }; - - Assert.Equal(new[] { "social.example.org", "xn--bcher-kva.example.org" }, PeerHosts.Filter(names, _ => true)); - Assert.Contains("a.test", PeerHosts.Filter(names, _ => true, testNetwork: true)); - Assert.Empty(PeerHosts.Filter(names, _ => false)); - } - - [Fact] - public void A_truncated_peers_list_still_gives_what_was_read() - { - var items = new List(); - FederationHttp.ReadStrings(Encoding.UTF8.GetBytes("[\"a.example\", 3, {\"x\":1}, \"b.example\", \"c.exa"), 10, items); - var capped = new List(); - FederationHttp.ReadStrings(Encoding.UTF8.GetBytes("[\"a\",\"b\",\"c\"]"), 2, capped); - - Assert.Equal(new[] { "a.example", "b.example" }, items); - Assert.Equal(new[] { "a", "b" }, capped); - } - } - - [Trait("Category", "Integration")] - public sealed class CrawlerTests : IAsyncLifetime - { - Peer _peer; - readonly string _run = Guid.NewGuid().ToString("N")[..10]; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - sealed class PeerDescriber : InstanceDescriber - { - readonly string _peer; - - public PeerDescriber(IFederationHttp http, string peer) : base(http) => _peer = peer; - - protected override string Address(string url) => $"{_peer}/{new Uri(url).Host}{new Uri(url).PathAndQuery}"; - } - - sealed class PeerCrawler : InstanceCrawler - { - readonly string _peer; - - public PeerCrawler(IFederationHttp http, InstanceDescriber describer, IDomainBlocks blocks, CrawlerOptions crawler, string peer, MemoryLedger ledger) - : base(http, describer, blocks, new StaticOptions(new StatisticsOptions { Crawler = crawler }), - new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }), - new StaticOptions(new FederationOptions { AllowPrivateNetworks = true }), ledger) => _peer = peer; - - protected override string Address(string url) => $"{_peer}/{new Uri(url).Host}{new Uri(url).PathAndQuery}"; - } - - (PeerCrawler Crawler, MemoryLedger Ledger) Crawler(CrawlerOptions options, IDomainBlocks blocks = default) - { - var http = Peer.Http(); - var ledger = new MemoryLedger(); - return (new PeerCrawler(http, new PeerDescriber(http, _peer.A), blocks ?? new NoBlocks(), options, _peer.A, ledger), ledger); - } - - string Host(string name) => $"{name}{_run}.example.org"; - - void Serve(string host, string robots = default, int robotsStatus = 200, params string[] peers) - { - if (robots != default) - _peer.ServeText($"/{host}/robots.txt", robots, "text/plain"); - else - _peer.Answer($"/{host}/robots.txt", robotsStatus); - _peer.Serve($"/{host}/.well-known/nodeinfo", $$"""{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"https://{{host}}/nodeinfo/2.0"}]}"""); - _peer.Serve($"/{host}/nodeinfo/2.0", """{"software":{"name":"lemmy","version":"0.19.9"},"usage":{"users":{"total":3}}}"""); - _peer.Serve($"/{host}/api/v1/instance/peers", "[" + string.Join(",", peers.Select(p => $"\"{p}\"")) + "]"); - } - - [Fact] - public async Task A_crawl_describes_a_crawled_server_and_learns_its_peers_politely() - { - var token = TestContext.Current.CancellationToken; - var host = Host("seed"); - var blocked = Host("blocked"); - Serve(host, "User-agent: *\nDisallow: /private", 200, Host("new1"), Host("new2"), blocked, "10.0.0.1", Host("new1")); - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] { new DomainBlock { Domain = blocked, Severity = DomainBlockSeverity.Silence } }); - await DB.Default.SaveAsync(new RemoteInstance { Host = host, Seen = "crawled" }, token); - var (crawler, ledger) = Crawler(new CrawlerOptions { Enabled = true }, blocks); - - await crawler.Handle(new Job { Kind = JobKind.CrawlInstance, Payload = host }, token); - - var seed = await DB.Default.Find().Match(i => i.Host == host).ExecuteSingleAsync(token); - Assert.Equal(("lemmy", 5, false), (seed.Software, seed.PeersCount!.Value, seed.RobotsDisallowed!.Value)); - Assert.NotNull(seed.LastCrawledAt); - var found = await DB.Default.Find().Match(i => i.Host == Host("new1") || i.Host == Host("new2") || i.Host == blocked).ExecuteAsync(token); - Assert.Equal(new[] { Host("new1"), Host("new2") }, found.Select(i => i.Host).OrderBy(h => h)); - Assert.All(found, i => Assert.Equal("crawled", i.Seen)); - var visited = _peer.Requests.Where(r => r.Path.StartsWith("/" + host + "/")).ToList(); - Assert.All(visited, r => Assert.Contains(r.Path[(host.Length + 1)..], new[] { "/robots.txt", "/.well-known/nodeinfo", "/nodeinfo/2.0", "/api/v1/instance/peers" })); - Assert.All(visited, r => Assert.StartsWith("PrivaPub-Stargazer/", r.Headers["User-Agent"])); - Assert.All(visited, r => Assert.Contains("/stargazing)", r.Headers["User-Agent"])); - Assert.Equal(("crawl", "ok"), (Assert.Single(ledger.Events).Event.Channel, ledger.Events.Single().Event.Outcome)); - } - - [Fact] - public async Task Robots_txt_and_its_absence_are_obeyed_and_a_touched_server_is_not_described_again() - { - var token = TestContext.Current.CancellationToken; - var shy = Host("shy"); - var broken = Host("broken"); - var touched = Host("touched"); - Serve(shy, "User-agent: PrivaPub-Stargazer\nDisallow: /api/", 200, Host("hidden")); - Serve(broken, default, 503, Host("never")); - Serve(touched, "", 200); - await DB.Default.SaveAsync(new[] { new RemoteInstance { Host = shy, Seen = "crawled" }, new RemoteInstance { Host = touched, Seen = "touched" } }, token); - var (crawler, ledger) = Crawler(new CrawlerOptions { Enabled = true }); - - await crawler.Handle(new Job { Payload = shy }, token); - await crawler.Handle(new Job { Payload = broken }, token); - await crawler.Handle(new Job { Payload = touched }, token); - - Assert.DoesNotContain(_peer.Requests, r => r.Path == $"/{shy}/api/v1/instance/peers"); - Assert.Contains(_peer.Requests, r => r.Path == $"/{shy}/nodeinfo/2.0"); - Assert.Equal(new[] { $"/{broken}/robots.txt" }, _peer.Requests.Where(r => r.Path.StartsWith($"/{broken}/")).Select(r => r.Path)); - Assert.True((await DB.Default.Find().Match(i => i.Host == broken).ExecuteSingleAsync(token)).RobotsDisallowed); - Assert.DoesNotContain(_peer.Requests, r => r.Path.StartsWith($"/{touched}/.well-known/")); - Assert.Contains(ledger.Events, e => e.Event.Host == broken && e.Event.Reason == "robots-disallowed"); - Assert.False(await DB.Default.Find().Match(i => i.Host == Host("hidden") || i.Host == Host("never")).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task A_disabled_crawler_does_nothing_and_the_caps_hold() - { - var token = TestContext.Current.CancellationToken; - var host = Host("capped"); - Serve(host, "", 200, Enumerable.Range(0, 20).Select(i => Host($"many{i}-")).ToArray()); - await DB.Default.SaveAsync(new RemoteInstance { Host = host, Seen = "touched" }, token); - - await Crawler(new CrawlerOptions { Enabled = false }).Crawler.Handle(new Job { Payload = host }, token); - Assert.DoesNotContain(_peer.Requests, r => r.Path.StartsWith($"/{host}/")); - - await Crawler(new CrawlerOptions { Enabled = true, MaxNewHostsPerCrawl = 3 }).Crawler.Handle(new Job { Payload = host }, token); - Assert.Equal(3, await DB.Default.CountAsync(i => i.Host.StartsWith("many") && i.Host.EndsWith($"{_run}.example.org"), cancellation: token)); - } - - [Fact] - public async Task The_planner_spreads_due_servers_across_the_hour_and_skips_fresh_and_blocked_ones() - { - var token = TestContext.Current.CancellationToken; - var due = Host("due"); - var fresh = Host("fresh"); - var blocked = Host("plannedblock"); - await DB.Default.SaveAsync(new[] - { - new RemoteInstance { Host = due, Seen = "crawled", LastCrawledAt = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, - new RemoteInstance { Host = fresh, Seen = "crawled", LastCrawledAt = DateTime.UtcNow }, - new RemoteInstance { Host = blocked, Seen = "crawled", LastCrawledAt = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc) } - }, token); - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] { new DomainBlock { Domain = blocked, Severity = DomainBlockSeverity.Suspend } }); - var options = new StatisticsOptions { Crawler = new CrawlerOptions { Enabled = true, HostsPerHour = 600, Seeds = new() { Host("seedling"), "10.1.1.1" } } }; - - await new CrawlPlanner(new JobQueue(j => j.DedupeKey == "never"), blocks, new StaticOptions(options)).Handle(new Job(), token); - await new CrawlPlanner(new JobQueue(j => j.DedupeKey == "never"), blocks, new StaticOptions(new StatisticsOptions())).Handle(new Job(), token); - - var planned = (await DB.Default.Find().Match(j => j.Kind == JobKind.CrawlInstance && j.Payload.EndsWith($"{_run}.example.org")).ExecuteAsync(token)).Select(j => j.Payload).ToList(); - Assert.Contains(due, planned); - Assert.Contains(Host("seedling"), planned); - Assert.DoesNotContain(fresh, planned); - Assert.DoesNotContain(blocked, planned); - Assert.Equal("crawled", (await DB.Default.Find().Match(i => i.Host == Host("seedling")).ExecuteSingleAsync(token)).Seen); - } - } - - [Trait("Category", "Integration")] - public sealed class StargazingPageTests : IAsyncLifetime - { - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task The_page_names_the_crawler_says_it_is_off_and_how_to_keep_it_out() - { - using var client = _host.Client(); - - using var response = await client.GetAsync("/stargazing", TestContext.Current.CancellationToken); - var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains("PrivaPub-Stargazer/", page); - Assert.Contains("https://privapub.test/stargazing", page); - Assert.Contains("off", page); - Assert.Contains("Disallow: /", page); - Assert.Contains("DB-IP", page); - Assert.StartsWith("default-src 'none'", response.Headers.GetValues("Content-Security-Policy").Single()); - } - } -} diff --git a/PrivaPub.Tests/Statistics/DeliveryAttemptTests.cs b/PrivaPub.Tests/Statistics/DeliveryAttemptTests.cs deleted file mode 100644 index e48458e..0000000 --- a/PrivaPub.Tests/Statistics/DeliveryAttemptTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Outbox; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Statistics -{ - [Xunit.Collection(nameof(Exclusive))] - [Trait("Category", "Integration")] - public sealed class DeliveryAttemptTests : IAsyncLifetime - { - static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; - - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - } - - public async ValueTask DisposeAsync() - { - if (_harness == default) - return; - await _harness.DisposeAsync(); - await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); - } - - async Task<(JobOutcome Outcome, InteractionEvent Event)> Attempt(string path, JsonObject activity, LocalActor signer = default) - { - var ledger = new MemoryLedger(); - var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), - NullLogger.Instance, ledger); - signer ??= (await _harness.Persona("sender")).Avatar; - var id = activity["id"]!.GetValue(); - await _harness.Delivery.Enqueue(signer, new[] { _harness.Peer.A + path }, activity, TestContext.Current.CancellationToken); - var job = await DB.Default.Find().Match(j => j.DedupeKey == $"{id}|{_harness.Peer.A + path}").ExecuteFirstAsync(TestContext.Current.CancellationToken); - job.Attempts = 1; - - var outcome = await handler.Handle(job, TestContext.Current.CancellationToken); - - return (outcome, Assert.Single(ledger.Of("out"))); - } - - static JsonObject Create(string audience) => new() - { - ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", - ["type"] = "Create", - ["to"] = new JsonArray(audience), - ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" } - }; - - [Fact] - public async Task A_delivered_public_post_is_ok_with_its_shape_size_and_time() - { - _harness.Peer.Answer("/ok/inbox", 202); - - var (outcome, e) = await Attempt("/ok/inbox", Create("https://www.w3.org/ns/activitystreams#Public")); - - Assert.Equal(JobResult.Done, outcome.Result); - Assert.Equal(("ok", "Create", "Note", "public", "person"), (e.Outcome, e.Activity, e.Object, e.Audience, e.LocalKind)); - Assert.Equal(202, e.Status); - Assert.Equal("127.0.0.1", e.Host); - Assert.Null(e.Reason); - Assert.True(e.Bytes > 0); - Assert.NotNull(e.LatencyMs); - Assert.Equal(1, e.Attempt); - } - - [Fact] - public async Task Refusals_and_failures_carry_their_status_and_private_traffic_has_no_kind() - { - _harness.Peer.Answer("/gone/inbox", 410); - _harness.Peer.Answer("/busy/inbox", 429); - _harness.Peer.Answer("/broken/inbox", 500); - - var (_, gone) = await Attempt("/gone/inbox", Create("https://elsewhere.example/users/x")); - var (_, busy) = await Attempt("/busy/inbox", Create("https://elsewhere.example/users/x")); - var (_, broken) = await Attempt("/broken/inbox", Create("https://elsewhere.example/users/x")); - - Assert.Equal(("dead", "410", 410), (gone.Outcome, gone.Reason, gone.Status!.Value)); - Assert.Equal(("retry", "429"), (busy.Outcome, busy.Reason)); - Assert.Equal(("retry", "500"), (broken.Outcome, broken.Reason)); - Assert.All(new[] { gone, busy, broken }, e => Assert.Equal(("private", (string)null), (e.Audience, e.LocalKind))); - } - - [Fact] - public async Task A_signer_that_no_longer_exists_is_dead() - { - var ghost = new LocalActor { Id = "000000000000000000000000", Kind = LocalActorKind.Person, UserName = "ghost", BaseAddress = Harness.Base }; - - var (outcome, e) = await Attempt("/never/inbox", Create("https://www.w3.org/ns/activitystreams#Public"), ghost); - - Assert.Equal(JobResult.Dead, outcome.Result); - Assert.Equal(("dead", "signer-gone"), (e.Outcome, e.Reason)); - Assert.Null(e.Status); - } - } -} diff --git a/PrivaPub.Tests/Statistics/DescribeTests.cs b/PrivaPub.Tests/Statistics/DescribeTests.cs deleted file mode 100644 index 0894a2f..0000000 --- a/PrivaPub.Tests/Statistics/DescribeTests.cs +++ /dev/null @@ -1,332 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Domain.Statistics; -using PrivaPub.Federation.Moderation; -using PrivaPub.Federation.Objects; -using PrivaPub.Infrastructure.Data.Migrations; -using PrivaPub.Infrastructure.Geo; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -using System.Net; -using System.Text.Json; - -namespace PrivaPub.Tests.Statistics -{ - public class InstanceDocumentsTests - { - static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement; - - [Fact] - public void NodeInfo_gives_software_usage_and_languages_whatever_their_shape() - { - var facts = InstanceDocuments.NodeInfo(Json(""" - {"software":{"name":"mastodon","version":"4.7.3"},"protocols":["activitypub"],"openRegistrations":false, - "usage":{"users":{"total":"1234","activeMonth":56.0,"activeHalfyear":-3},"localPosts":99999999999,"localComments":7}, - "metadata":{"nodeName":"Example","nodeDescription":"

A small\n place

","languages":["en","DE","klingon!"]}} - """), "2.1"); - - Assert.Equal(("mastodon", "4.7.3", "Example", "2.1"), (facts.Software, facts.SoftwareVersion, facts.NodeName, facts.Version)); - Assert.Equal("A small place", facts.NodeDescription); - Assert.Equal(new[] { "activitypub" }, facts.Protocols); - Assert.False(facts.OpenRegistrations); - Assert.Equal(1234, facts.UsersTotal); - Assert.Equal(56, facts.UsersActiveMonth); - Assert.Equal(0, facts.UsersActiveHalfyear); - Assert.Equal(1_000_000_000, facts.LocalPosts); - Assert.Equal(7, facts.LocalComments); - Assert.Equal(new[] { "en", "de" }, facts.Languages); - Assert.Null(InstanceDocuments.NodeInfo(Json("{}"), "2.0").UsersTotal); - // NodeInfo wants the name in lower case, which Mobilizon does not keep - Assert.Equal("mobilizon", InstanceDocuments.NodeInfo(Json("""{"software":{"name":"Mobilizon"}}"""), "2.1").Software); - } - - // Castopod publishes NodeInfo2 only - [Fact] - public void NodeInfo2_gives_the_server_and_its_usage() - { - var facts = InstanceDocuments.NodeInfo2(Json(""" - {"version":"1.0","server":{"baseUrl":"https://castopod.test/","name":"Castopod","software":"Castopod","version":"1.15.5"}, - "protocols":["activitypub"],"openRegistrations":false,"usage":{"users":{"total":1,"activeMonth":1,"activeHalfyear":1},"localPosts":3}} - """)); - - Assert.Equal(("castopod", "1.15.5", "Castopod", "nodeinfo2-1.0"), (facts.Software, facts.SoftwareVersion, facts.NodeName, facts.Version)); - Assert.Equal(new[] { "activitypub" }, facts.Protocols); - Assert.Equal((1L, 3L), (facts.UsersTotal.Value, facts.LocalPosts.Value)); - Assert.Null(InstanceDocuments.NodeInfo2(Json("""{"version":"1.0","server":{}}"""))); - } - - [Fact] - public void The_newest_https_nodeinfo_link_is_chosen() - { - var links = Json(""" - {"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"https://a.example/2.0"}, - {"rel":"http://nodeinfo.diaspora.software/ns/schema/2.1","href":"http://a.example/2.1"}, - {"rel":"http://nodeinfo.diaspora.software/ns/schema/2.2","href":"https://a.example/2.2"}]} - """); - - Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links)); - Assert.Equal(default, InstanceDocuments.NodeInfoLink(Json("[]"))); - // Funkwhale 2.0 links its NodeInfo under its swagger schema's URL - Assert.Equal(("https://f.example/api/v2/instance/nodeinfo/2.1", "2.1"), InstanceDocuments.NodeInfoLink(Json(""" - {"links":[{"rel":"https://docs.funkwhale.audio/swagger/schema.yml","href":"https://f.example/api/v2/instance/nodeinfo/2.1"}]} - """))); - } - - [Fact] - public void The_instance_api_gives_registration_mode_limits_and_never_a_contact() - { - var v2 = InstanceDocuments.InstanceApi(Json(""" - {"title":"Example","languages":["en"],"registrations":{"enabled":true,"approval_required":true}, - "configuration":{"statuses":{"max_characters":500}},"api_versions":{"mastodon":7},"source_url":"https://github.com/mastodon/mastodon", - "contact":{"email":"admin@a.example","account":{"username":"admin"}}} - """)); - var v1 = InstanceDocuments.InstanceApi(Json("""{"title":"Old","registrations":false,"max_toot_chars":"5000"}""")); - - Assert.Equal(("Example", "approval", 500, 7, "https://github.com/mastodon/mastodon"), (v2.Title, v2.RegistrationMode, v2.MaxPostChars!.Value, v2.ApiVersion!.Value, v2.SourceUrl)); - Assert.Equal(new[] { "en" }, v2.Languages); - Assert.DoesNotContain("admin", System.Text.Json.JsonSerializer.Serialize(v2)); - Assert.Equal(("closed", 5000), (v1.RegistrationMode, v1.MaxPostChars!.Value)); - Assert.True(InstanceDocuments.SpeaksMastodonApi("GoToSocial")); - Assert.False(InstanceDocuments.SpeaksMastodonApi("lemmy")); - } - - [Fact] - public void The_public_location_is_where_the_server_was_located_unless_a_cdn_hides_it() - { - var geo = new InstanceGeo { Country = "DE", City = "Berlin", Latitude = 52.5, Longitude = 13.4, AsnOrg = "Hetzner", Asn = 24940 }; - - Assert.Equal(new PublicLocation("DE", "Berlin", 52.5, 13.4, "Hetzner", null), PublicGeo.Project(geo)); - Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new InstanceGeo { Country = "DE" })); - Assert.Equal(new PublicLocation(null, null, null, null, null, "Cloudflare"), - PublicGeo.Project(new InstanceGeo { Country = "US", City = "SF", Latitude = 37.8, Longitude = -122.4, Cdn = "Cloudflare" })); - Assert.Null(PublicGeo.Project(new InstanceGeo())); - Assert.Null(PublicGeo.Project(null)); - } - } - - [Trait("Category", "Integration")] - public sealed class DescribeTests : IAsyncLifetime - { - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - sealed class PeerDescriber : InstanceDescriber - { - readonly string _peer; - - public PeerDescriber(IFederationHttp http, IGeoLocator geo, IConnectedAddresses addresses, string peer, ICdnRanges ranges = default, - IEdgeHints edges = default) : base(http, geo, addresses, ranges, edges) => _peer = peer; - - protected override string Address(string url) => _peer + new Uri(url).PathAndQuery; - } - - sealed class FixedGeo : IGeoLocator - { - public GeoFix Locate(IPAddress address) => new("NL", "Amsterdam", 52.4, 4.9, 60781, "LeaseWeb"); - public string Source => "test"; - } - - void ServeServer(string prefix, string software, bool v2 = true) - { - _peer.Serve($"{prefix}/.well-known/nodeinfo", $$"""{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.1","href":"https://ignored.example/nodeinfo/2.1"}]}"""); - _peer.Serve($"{prefix}/nodeinfo/2.1", $$$"""{"software":{"name":"{{{software}}}","version":"1.0"},"usage":{"users":{"total":42,"activeMonth":7}},"openRegistrations":true}"""); - if (v2) - _peer.Serve($"{prefix}/api/v2/instance", """{"title":"A test server","languages":["it"],"registrations":{"enabled":true,"approval_required":false}}"""); - _peer.Serve($"{prefix}/api/v1/instance", """{"title":"An old test server","registrations":true}"""); - } - - [Fact] - public async Task A_server_is_described_located_and_snapshotted_weekly() - { - var token = TestContext.Current.CancellationToken; - var host = $"described{Guid.NewGuid():N}.example"; - ServeServer("/" + host, "mastodon"); - var addresses = new ConnectedAddresses(); - var describer = new PeerDescriber(Peer.Http(connected: addresses), new FixedGeo(), addresses, _peer.A + "/" + host); - - Assert.True(await describer.Describe(host, crawled: false, default, token)); - - var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteSingleAsync(token); - Assert.Equal(("mastodon", 42L, 7L, "2.1"), (instance.Software, instance.UsersTotal!.Value, instance.UsersActiveMonth!.Value, instance.NodeInfoVersion)); - Assert.Equal(("A test server", "open", "mastodon-v2"), (instance.Title, instance.RegistrationMode, instance.InstanceApi)); - Assert.Equal(new[] { "it" }, instance.Languages); - Assert.Equal(("NL", "Amsterdam", "LeaseWeb", "test"), (instance.Geo.Country, instance.Geo.City, instance.Geo.AsnOrg, instance.Geo.Source)); - Assert.False(instance.Geo.Ipv6); - var snapshot = await DB.Default.Find().Match(s => s.Host == host).ExecuteSingleAsync(token); - Assert.True(snapshot.Reachable); - Assert.Equal((42L, "NL"), (snapshot.UsersTotal!.Value, snapshot.Country)); - Assert.Equal(InstanceDescriber.Week(DateTime.UtcNow), snapshot.Week); - } - - [Fact] - public async Task A_cdn_is_found_by_its_published_ranges_first_then_by_its_headers() - { - var token = TestContext.Current.CancellationToken; - var listed = $"listed{Guid.NewGuid():N}.example"; - var hinted = $"hinted{Guid.NewGuid():N}.example"; - ServeServer("/" + listed, "mastodon"); - ServeServer("/" + hinted, "mastodon"); - var addresses = new ConnectedAddresses(); - var ranges = new CdnRanges(); - ranges.Replace(new CdnRangeSet { Key = "cloudflare.com", Name = "Cloudflare", Ranges = new() { "127.0.0.0/8" }, FetchedAt = DateTime.UtcNow }); - var edges = new EdgeHints(); - edges.Remember("127.0.0.1", CdnCatalog.ByKey("fastly.com")); - - await new PeerDescriber(Peer.Http(connected: addresses), new FixedGeo(), addresses, _peer.A + "/" + listed, ranges, edges).Describe(listed, crawled: false, default, token); - await new PeerDescriber(Peer.Http(connected: addresses), new FixedGeo(), addresses, _peer.A + "/" + hinted, new CdnRanges(), edges).Describe(hinted, crawled: false, default, token); - - var listedOne = await DB.Default.Find().Match(i => i.Host == listed).ExecuteSingleAsync(token); - Assert.Equal(("Cloudflare", "cloudflare.com", "ranges"), (listedOne.Geo.Cdn, listedOne.Geo.CdnDomain, listedOne.Geo.CdnSource)); - var hintedOne = await DB.Default.Find().Match(i => i.Host == hinted).ExecuteSingleAsync(token); - Assert.Equal(("Fastly", "fastly.com", "headers"), (hintedOne.Geo.Cdn, hintedOne.Geo.CdnDomain, hintedOne.Geo.CdnSource)); - var snapshot = await DB.Default.Find().Match(s => s.Host == listed).ExecuteSingleAsync(token); - Assert.Equal(("Cloudflare", "cloudflare.com", "ranges", "Amsterdam", 52.4), (snapshot.Cdn, snapshot.CdnDomain, snapshot.CdnSource, snapshot.City, snapshot.Latitude!.Value)); - } - - [Fact] - public async Task Without_api_v2_the_v1_api_is_read_and_an_unreachable_week_is_recorded_too() - { - var token = TestContext.Current.CancellationToken; - var old = $"old{Guid.NewGuid():N}.example"; - var gone = $"gone{Guid.NewGuid():N}.example"; - ServeServer("/" + old, "pleroma", v2: false); - - await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + old).Describe(old, crawled: false, default, token); - Assert.False(await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + gone).Describe(gone, crawled: false, default, token)); - - var oldOne = await DB.Default.Find().Match(i => i.Host == old).ExecuteSingleAsync(token); - Assert.Equal(("mastodon-v1", "An old test server", "open"), (oldOne.InstanceApi, oldOne.Title, oldOne.RegistrationMode)); - Assert.Null(oldOne.Geo); - var goneOne = await DB.Default.Find().Match(i => i.Host == gone).ExecuteSingleAsync(token); - Assert.Equal("no NodeInfo", goneOne.DescriptionError); - Assert.False((await DB.Default.Find().Match(s => s.Host == gone).ExecuteSingleAsync(token)).Reachable); - } - - // a server whose NodeInfo failed is asked again on its next arrival, not a week later - [Fact] - public async Task A_failed_description_frees_its_weeks_key_and_a_good_one_keeps_it() - { - var token = TestContext.Current.CancellationToken; - var broken = $"broken{Guid.NewGuid():N}.example"; - var fine = $"fine{Guid.NewGuid():N}.example"; - ServeServer("/" + fine, "smithereen", v2: false); - var queue = new JobQueue(); - async Task Run(string host) - { - var key = InstanceDescriber.DedupeKey(host, DateTime.UtcNow); - Assert.True(await queue.Enqueue(JobKind.DescribeInstance, host, host, key, token)); - var job = await DB.Default.Find().Match(j => j.DedupeKey == key).ExecuteSingleAsync(token); - await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + host).Handle(job, token); - } - - await Run(broken); - await Run(fine); - - Assert.True(await queue.Enqueue(JobKind.DescribeInstance, broken, broken, InstanceDescriber.DedupeKey(broken, DateTime.UtcNow), token)); - Assert.False(await queue.Enqueue(JobKind.DescribeInstance, fine, fine, InstanceDescriber.DedupeKey(fine, DateTime.UtcNow), token)); - } - - [Fact] - public async Task A_crawled_server_never_downgrades_a_touched_one_and_robots_are_obeyed() - { - var token = TestContext.Current.CancellationToken; - var touched = $"touched{Guid.NewGuid():N}.example"; - var shy = $"shy{Guid.NewGuid():N}.example"; - ServeServer("/" + touched, "gotosocial"); - ServeServer("/" + shy, "mastodon"); - await DB.Default.SaveAsync(new RemoteInstance { Host = touched, Seen = "touched" }, token); - - await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + touched).Describe(touched, crawled: true, default, token); - await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + shy).Describe(shy, crawled: true, path => !path.StartsWith("/api/"), token); - - Assert.Equal("touched", (await DB.Default.Find().Match(i => i.Host == touched).ExecuteSingleAsync(token)).Seen); - var shyOne = await DB.Default.Find().Match(i => i.Host == shy).ExecuteSingleAsync(token); - Assert.Equal(("crawled", "mastodon"), (shyOne.Seen, shyOne.Software)); - Assert.Null(shyOne.InstanceApi); - Assert.DoesNotContain(_peer.Requests, r => r.Path.StartsWith("/" + shy + "/api/", StringComparison.Ordinal)); - Assert.Contains(_peer.Requests, r => r.Path == "/" + shy + "/nodeinfo/2.1"); - } - } - - [Trait("Category", "Integration")] - public sealed class TouchTests : IAsyncLifetime - { - public ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - return ValueTask.CompletedTask; - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task A_server_we_exchange_with_is_seen_and_described_once_a_week_unless_suspended() - { - var token = TestContext.Current.CancellationToken; - var friend = $"friend{Guid.NewGuid():N}.example"; - var spammer = $"spam{Guid.NewGuid():N}.example"; - var linked = $"linked{Guid.NewGuid():N}.example"; - var looked = $"looked{Guid.NewGuid():N}.example"; - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] { new DomainBlock { Domain = spammer, Severity = DomainBlockSeverity.Suspend } }); - var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions(new StatisticsOptions()), NullLogger.Instance, - new JobQueue(j => j.DedupeKey == "never"), blocks); - - ledger.Record(new InteractionEvent { Host = friend, Channel = "in", Activity = "Create", Outcome = "accepted" }, "https://" + friend + "/users/a"); - ledger.Record(new InteractionEvent { Host = friend, Channel = "out", Activity = "Like", Outcome = "ok" }); - ledger.Record(new InteractionEvent { Host = spammer, Channel = "out", Activity = "Reject", Outcome = "ok" }); - ledger.Record(new InteractionEvent { Host = linked, Channel = "preview", Outcome = "ok" }); - ledger.Count(looked, "http:actor:ok"); - await ledger.Flush(token); - - var seen = await DB.Default.Find().Match(i => i.Host == friend).ExecuteSingleAsync(token); - Assert.Equal("touched", seen.Seen); - Assert.NotNull(seen.FirstSeenAt); - Assert.Single(await DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == friend).ExecuteAsync(token)); - Assert.True(await DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == looked).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == spammer).ExecuteAnyAsync(token)); - Assert.False(await DB.Default.Find().Match(i => i.Host == linked).ExecuteAnyAsync(token)); - } - } - - [Xunit.Collection(nameof(Exclusive))] - [Trait("Category", "Integration")] - public sealed class TouchedMigrationTests - { - [Fact] - public async Task Known_servers_become_touched_with_their_dates() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - var token = TestContext.Current.CancellationToken; - var host = $"legacy{Guid.NewGuid():N}.example"; - var success = DateTime.UtcNow.AddDays(-2); - await DB.Default.SaveAsync(new RemoteInstance { Host = host, LastSuccessAt = success, DescribedAt = success.AddDays(-1) }, token); - - await new _010_remote_instances_were_touched().UpgradeAsync(); - - var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteSingleAsync(token); - Assert.Equal("touched", instance.Seen); - Assert.NotNull(instance.FirstSeenAt); - Assert.InRange(instance.LastSeenAt!.Value, success.AddSeconds(-1), success.AddSeconds(1)); - } - } -} diff --git a/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs b/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs deleted file mode 100644 index 9366dfe..0000000 --- a/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs +++ /dev/null @@ -1,211 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Statistics -{ - public class ArrivalVerdictTests - { - [Fact] - public void The_first_decision_wins_and_a_circle_has_no_kind() - { - var verdict = new ArrivalVerdict(); - - verdict.Drop("not-addressed"); - verdict.Accept("stored"); - verdict.About("Note", PostVisibility.Circle, DateTime.UtcNow.AddMinutes(-2), new LocalActor { Kind = LocalActorKind.Group, IsCircle = true }); - - Assert.Equal(("dropped", "not-addressed"), (verdict.Outcome, verdict.Reason)); - Assert.Equal("private", verdict.Audience); - Assert.Null(verdict.LocalKind); - Assert.InRange(verdict.AgeSeconds!.Value, 110, 130); - } - - [Theory] - [InlineData(PostVisibility.Public, "public")] - [InlineData(PostVisibility.Unlisted, "unlisted")] - [InlineData(PostVisibility.FollowersOnly, "private")] - [InlineData(PostVisibility.Direct, "private")] - [InlineData(PostVisibility.Circle, "private")] - [InlineData(PostVisibility.LocalGeo, "private")] - public void Every_visibility_but_public_and_unlisted_is_private(PostVisibility visibility, string audience) => - Assert.Equal(audience, ArrivalVerdict.AudienceOf(visibility)); - - [Fact] - public void Outside_an_arrival_nothing_happens() - { - Arrival.Current = default; - Arrival.Drop("x"); - Arrival.About("Note"); - } - } - - [Trait("Category", "Integration")] - public sealed class HandlerVerdictTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - InteractionEvent Processed(string activityType) => - _harness.Ledger.Of("in").Last(e => e.Activity == activityType); - - JsonObject Create(RemoteActor author, IEnumerable to, JsonObject extra = default) - { - var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority); - var note = new JsonObject - { - ["id"] = $"{origin}/notes/{Guid.NewGuid():N}", - ["type"] = "Note", - ["attributedTo"] = author.Id, - ["content"] = "

hello

", - ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) - }; - foreach (var (key, value) in extra ?? new JsonObject()) - note[key] = value?.DeepClone(); - return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; - } - - [Fact] - public async Task A_direct_message_is_stored_private_and_without_a_local_kind() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - - await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { alice.Uri })); - - var e = Processed("Create"); - Assert.Equal(("accepted", "stored", "private"), (e.Outcome, e.Reason, e.Audience)); - Assert.Equal("Note", e.Object); - Assert.Null(e.LocalKind); - Assert.Equal("127.0.0.1", e.Host); - Assert.NotNull(e.Attempt); - Assert.NotNull(e.WaitMs); - Assert.Equal(bob.Id, _harness.Ledger.Events.Last(x => x.Event == e).ActorUri); - } - - [Fact] - public async Task A_public_reply_to_a_persona_names_its_kind_and_its_features() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var parent = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "first" }, TestContext.Current.CancellationToken); - - await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { Addressing.Public, alice.Uri }, - new JsonObject { ["inReplyTo"] = parent.Post.ObjectURI, ["interactionPolicy"] = new JsonObject(), ["quoteUrl"] = "https://elsewhere.example/1" })); - - var e = Processed("Create"); - Assert.Equal(("accepted", "public", "person"), (e.Outcome, e.Audience, e.LocalKind)); - Assert.Contains("interaction-policy", e.Features); - Assert.Contains("legacy-quote", e.Features); - } - - [Fact] - public async Task Unwanted_things_are_dropped_with_a_reason() - { - var stranger = new RemoteActor(_harness.Peer, "stranger"); - - await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { Addressing.Public })); - var unaddressed = Processed("Create"); - await _harness.Deliver(stranger, "/human-centipede", new JsonObject - { - ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/likes/" + Guid.NewGuid().ToString("N"), - ["type"] = "Like", - ["actor"] = stranger.Id, - ["object"] = $"{Harness.Base}/peasants/x/scribbles/{Guid.NewGuid():N}" - }); - var like = Processed("Like"); - await _harness.Deliver(stranger, "/human-centipede", new JsonObject - { - ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/x/" + Guid.NewGuid().ToString("N"), - ["type"] = "Bite", - ["actor"] = stranger.Id, - ["object"] = stranger.Id - }); - var bite = Processed("Bite"); - await _harness.Deliver(stranger, "/human-centipede", new JsonObject - { - ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/undo/" + Guid.NewGuid().ToString("N"), - ["type"] = "Undo", - ["actor"] = stranger.Id, - ["object"] = new JsonObject { ["id"] = stranger.Id + "/likes/never", ["type"] = "Like", ["actor"] = stranger.Id, ["object"] = "https://nowhere.example/1" } - }); - var undo = Processed("Undo"); - - Assert.Equal(("dropped", "not-addressed", "public"), (unaddressed.Outcome, unaddressed.Reason, unaddressed.Audience)); - Assert.Equal(("dropped", "unknown-object"), (like.Outcome, like.Reason)); - Assert.Equal(("dropped", "unknown-type"), (bite.Outcome, bite.Reason)); - Assert.Equal(("dropped", "unknown-object", "Like"), (undo.Outcome, undo.Reason, undo.Object)); - } - - [Fact] - public async Task A_stranger_posting_into_a_circle_never_reveals_the_circle() - { - var (_, owner) = await _harness.Persona("owner"); - var (privateKey, publicKey) = Keys.NewKeyPair(); - var entity = new GroupEntity - { - UserName = $"circle{Guid.NewGuid():N}"[..20], - Kind = GroupKind.Circle, - PostingPolicy = PostingPolicy.Followers, - PrivateKey = privateKey, - PublicKey = publicKey, - Members = new() { new GroupMember { AvatarId = owner.Id, Role = GroupRole.Owner } } - }; - await DB.Default.SaveAsync(entity); - var circle = _harness.Local.FromGroup(entity); - var outsider = new RemoteActor(_harness.Peer, "outsider"); - - await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock })); - - var e = Processed("Create"); - Assert.Equal(("dropped", "not-addressed", "private"), (e.Outcome, e.Reason, e.Audience)); - Assert.Null(e.LocalKind); - Assert.DoesNotContain(_harness.Ledger.Events, x => System.Text.Json.JsonSerializer.Serialize(x.Event).Contains("circle", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task A_follow_is_accepted_and_its_undo_is_recorded() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var follow = new JsonObject { ["id"] = bob.Id + "/follows/" + Guid.NewGuid().ToString("N"), ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri }; - - await _harness.Deliver(bob, "/human-centipede", follow); - var followed = Processed("Follow"); - await _harness.Deliver(bob, "/human-centipede", new JsonObject - { - ["id"] = bob.Id + "/undo/" + Guid.NewGuid().ToString("N"), - ["type"] = "Undo", - ["actor"] = bob.Id, - ["object"] = follow.DeepClone() - }); - var undone = Processed("Undo"); - - Assert.Equal(("accepted", "auto-accepted", "none"), (followed.Outcome, followed.Reason, followed.Audience)); - Assert.Equal(("accepted", "undone", "Follow"), (undone.Outcome, undone.Reason, undone.Object)); - } - } -} diff --git a/PrivaPub.Tests/Statistics/InboxAnswerTests.cs b/PrivaPub.Tests/Statistics/InboxAnswerTests.cs deleted file mode 100644 index 49de003..0000000 --- a/PrivaPub.Tests/Statistics/InboxAnswerTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Moderation; -using PrivaPub.Federation.Signing; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Statistics -{ - public class SignatureProblemCodeTests - { - [Theory] - [InlineData("unsupported signature algorithm 'ed25519'", "algorithm-unsupported")] - [InlineData("(request-target) is not signed", "headers-unsigned")] - [InlineData("host is not signed", "headers-unsigned")] - [InlineData("the digest is not signed", "headers-unsigned")] - [InlineData("neither date nor (created) is signed", "headers-unsigned")] - [InlineData("the digest does not match the body", "digest-mismatch")] - [InlineData("unreadable Date header", "header-unreadable")] - [InlineData("unreadable (created)", "header-unreadable")] - [InlineData("unreadable (expires)", "header-unreadable")] - [InlineData("the Date header is outside the allowed window", "date-skew")] - [InlineData("(created) is outside the allowed window", "date-skew")] - [InlineData("the signature has expired", "expired")] - [InlineData("something new", "signature-problem")] - public void Every_signature_problem_has_a_reason_code(string problem, string code) => - Assert.Equal(code, HttpSignatures.ProblemCode(problem)); - } - - [Trait("Category", "Integration")] - public sealed class InboxAnswerTests : IAsyncLifetime - { - Harness _harness; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _harness = await Harness.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_harness != default) - await _harness.DisposeAsync(); - } - - JsonObject Create(RemoteActor sender, string to) - { - var origin = new Uri(sender.Id).GetLeftPart(UriPartial.Authority); - var id = $"{origin}/notes/{Guid.NewGuid():N}"; - return new JsonObject - { - ["id"] = id + "/activity", - ["type"] = "Create", - ["actor"] = sender.Id, - ["to"] = new JsonArray(to), - ["object"] = new JsonObject { ["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(to), ["content"] = "hi" } - }; - } - - (InboxResult Result, InteractionEvent Event, string ActorUri, bool Claimed) Last(InboxResult result) - { - var recorded = _harness.Ledger.Events.Last(e => e.Event.Channel == "recv"); - return (result, recorded.Event, recorded.ActorUri, recorded.HostClaimed); - } - - [Fact] - public async Task A_verified_delivery_is_queued_once_and_then_a_duplicate() - { - var (_, alice) = await _harness.Persona("alice"); - var bob = new RemoteActor(_harness.Peer, "bob"); - var activity = Create(bob, alice.Uri); - - var first = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None)); - var second = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None)); - - Assert.Equal(202, first.Event.Status); - Assert.Equal("queued", first.Event.Reason); - Assert.Equal("queued", first.Event.Outcome); - Assert.Equal("127.0.0.1", first.Event.Host); - Assert.Equal("Create", first.Event.Activity); - Assert.Equal("Note", first.Event.Object); - Assert.Equal("shared", first.Event.Inbox); - Assert.Equal("cavage:rsa-sha256", first.Event.Signature); - Assert.True(first.Event.Bytes > 0); - Assert.Equal(bob.Id, first.ActorUri); - Assert.False(first.Claimed); - Assert.Equal("duplicate", second.Event.Reason); - } - - [Fact] - public async Task Refusals_carry_their_reason_and_an_unverified_host_is_only_claimed() - { - var (_, alice) = await _harness.Persona("alice"); - var mallory = new RemoteActor(_harness.Peer, "mallory"); - - var unsigned = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); - unsigned.Headers.Remove("Signature"); - var noSignature = Last(await _harness.Receiver.Receive(unsigned, default, CancellationToken.None)); - - var tampered = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); - tampered.Headers["Digest"] = "SHA-256=AAAA"; - var badDigest = Last(await _harness.Receiver.Receive(tampered, default, CancellationToken.None)); - - var forged = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); - forged.Headers["Signature"] = forged.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA"); - var badSignature = Last(await _harness.Receiver.Receive(forged, default, CancellationToken.None)); - - var junk = Last(await _harness.Deliver(mallory, "/human-centipede", new JsonArray(1, 2))); - - Assert.Equal((401, "no-signature", "none"), (noSignature.Event.Status!.Value, noSignature.Event.Reason, noSignature.Event.Signature)); - Assert.Equal((401, "digest-mismatch"), (badDigest.Event.Status!.Value, badDigest.Event.Reason)); - Assert.Equal((401, "signature-invalid"), (badSignature.Event.Status!.Value, badSignature.Event.Reason)); - Assert.Equal((400, "not-activity"), (junk.Event.Status!.Value, junk.Event.Reason)); - foreach (var refused in new[] { noSignature, badDigest, badSignature }) - { - Assert.Equal("refused", refused.Event.Outcome); - Assert.True(refused.Claimed); - Assert.Null(refused.ActorUri); - } - } - - [Fact] - public async Task A_follow_of_nobody_is_a_404_and_a_self_delete_with_an_unknown_key_is_a_silent_202() - { - var stranger = new RemoteActor(_harness.Peer, "stranger"); - var follow = new JsonObject - { - ["id"] = stranger.Id + "/follows/" + Guid.NewGuid().ToString("N"), - ["type"] = "Follow", - ["actor"] = stranger.Id, - ["object"] = $"{Harness.Base}/peasants/nobody{Guid.NewGuid():N}" - }; - var notFound = Last(await _harness.Receiver.Receive(stranger.Post(Harness.Host, "/human-centipede", follow), default, CancellationToken.None)); - - var gone = new RemoteActor(_harness.Peer, "gone"); - _harness.Peer.Answer(new Uri(gone.Id).AbsolutePath, 410); - var delete = new JsonObject { ["id"] = gone.Id + "#delete", ["type"] = "Delete", ["actor"] = gone.Id, ["object"] = gone.Id }; - var deleted = Last(await _harness.Receiver.Receive(gone.Post(Harness.Host, "/human-centipede", delete), default, CancellationToken.None)); - - Assert.Equal((404, "unknown-recipient"), (notFound.Event.Status!.Value, notFound.Event.Reason)); - Assert.Equal((202, "self-delete-unknown-key"), (deleted.Event.Status!.Value, deleted.Event.Reason)); - Assert.True(deleted.Claimed); - } - - [Fact] - public async Task A_suspended_server_is_recorded_under_its_own_name() - { - var blocks = new DomainBlocks(NullLogger.Instance); - blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } }); - var ledger = new MemoryLedger(); - var receiver = new InboxReceiver(_harness.Local, _harness.Remote, _harness.Queue, blocks, NullLogger.Instance, ledger); - var (_, alice) = await _harness.Persona("alice"); - var spammer = new RemoteActor(_harness.Peer, "spammer", _harness.Peer.B); - - var result = await receiver.Receive(spammer.Post(Harness.Host, "/human-centipede", Create(spammer, alice.Uri)), default, CancellationToken.None); - - Assert.Equal(202, result.StatusCode); - var (recorded, actorUri, claimed) = Assert.Single(ledger.Events); - Assert.Equal(("localhost", "suspended", "queued"), (recorded.Host, recorded.Reason, recorded.Outcome)); - Assert.False(claimed); - Assert.Null(actorUri); - } - } -} diff --git a/PrivaPub.Tests/Statistics/InteractionsTests.cs b/PrivaPub.Tests/Statistics/InteractionsTests.cs deleted file mode 100644 index d75f6f5..0000000 --- a/PrivaPub.Tests/Statistics/InteractionsTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -namespace PrivaPub.Tests.Statistics -{ - public class InteractionsTests - { - [Fact] - public void The_local_kind_is_kept_only_on_public_and_unlisted_traffic() - { - Assert.Equal("person", Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "person" }).LocalKind); - Assert.Equal("group", Interactions.Sanitize(new InteractionEvent { Audience = "unlisted", LocalKind = "group" }).LocalKind); - Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "private", LocalKind = "group" }).LocalKind); - Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "none", LocalKind = "person" }).LocalKind); - Assert.Null(Interactions.Sanitize(new InteractionEvent { LocalKind = "person" }).LocalKind); - Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "circle" }).LocalKind); - } - - [Fact] - public void Junk_becomes_other_and_hosts_are_normalised() - { - var e = Interactions.Sanitize(new InteractionEvent - { - Host = "Mastodon.Social.", - Channel = "sideways", - Activity = "https://www.w3.org/ns/activitystreams#Create", - Object = "Note", - Outcome = "Dropped!", - Reason = "not-addressed", - Audience = "everyone", - Inbox = "front-door", - Signature = "cavage:rsa-sha256", - Features = new() { "fep-044f", "Bad Feature", "fep-044f", "source:text/markdown" } - }); - - Assert.Equal("mastodon.social", e.Host); - Assert.Equal("other", e.Channel); - Assert.Equal("other", e.Activity); - Assert.Equal("Note", e.Object); - Assert.Equal("other", e.Outcome); - Assert.Equal("not-addressed", e.Reason); - Assert.Null(e.Audience); - Assert.Null(e.Inbox); - Assert.Equal("cavage:rsa-sha256", e.Signature); - Assert.Equal(new[] { "fep-044f", "other", "source:text/markdown" }, e.Features); - Assert.Equal("-", Interactions.Host("evil host/with path")); - Assert.Equal("-", Interactions.Host(default)); - Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x")); - // RFC 9421's algorithm names carry an underscore - Assert.Equal("rfc9421:rsa-v1_5-sha256", Interactions.Sanitize(new InteractionEvent { Signature = "rfc9421:rsa-v1_5-sha256" }).Signature); - } - - [Fact] - public void Latencies_and_waits_fall_into_buckets() - { - Assert.Equal("le50ms", Interactions.Latency(0)); - Assert.Equal("le250ms", Interactions.Latency(101)); - Assert.Equal("inf", Interactions.Latency(31_000)); - Assert.Equal("le1s", Interactions.Wait(900)); - Assert.Equal("le60s", Interactions.Wait(59_000)); - Assert.Equal("inf", Interactions.Wait(100_000_000)); - } - - [Fact] - public void An_actor_hash_is_stable_for_a_salt_and_unlinkable_across_salts() - { - var monday = new byte[32]; - var tuesday = Enumerable.Repeat((byte)1, 32).ToArray(); - const string actor = "https://social.example/users/alice"; - - var first = InteractionSalts.Hash(monday, actor); - - Assert.Equal(first, InteractionSalts.Hash(monday, actor)); - Assert.NotEqual(first, InteractionSalts.Hash(tuesday, actor)); - Assert.NotEqual(first, InteractionSalts.Hash(monday, actor + "2")); - Assert.Equal(16, first.Length); - Assert.DoesNotContain("alice", first); - Assert.Null(InteractionSalts.Hash(default, actor)); - } - - [Fact] - public void A_full_ledger_drops_and_counts_but_never_blocks_or_throws() - { - var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions(new StatisticsOptions()), NullLogger.Instance); - - for (var i = 0; i < 10_005; i++) - ledger.Record(new InteractionEvent { Channel = Interactions.In }); - ledger.Record(default); - - Assert.Equal(5, ledger.Dropped); - } - - [Fact] - public void A_disabled_ledger_records_nothing() - { - var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions(new StatisticsOptions { Enabled = false }), NullLogger.Instance); - - for (var i = 0; i < 10_005; i++) - ledger.Record(new InteractionEvent { Channel = Interactions.In }); - - Assert.Equal(0, ledger.Dropped); - } - } -} diff --git a/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs b/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs deleted file mode 100644 index dd10e30..0000000 --- a/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Statistics -{ - [Trait("Category", "Integration")] - public sealed class LedgerOverHttpTests : IAsyncLifetime - { - PrivaPubHost _host; - Peer _peer; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - _peer = await Peer.Start(); - } - - public async ValueTask DisposeAsync() - { - if (_peer != default) - await _peer.DisposeAsync(); - } - - JsonObject DirectNote(RemoteActor sender, Persona persona, string text) - { - var noteId = $"{_peer.A}/notes/{Guid.NewGuid():N}"; - var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"); - return new JsonObject - { - ["id"] = noteId + "/activity", - ["type"] = "Create", - ["actor"] = sender.Id, - ["to"] = to.DeepClone(), - ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = text } - }; - } - - async Task> StoredSince(DateTime since, CancellationToken token) - { - await _host.Get().Flush(token); - return await DB.Default.Database().GetCollection(nameof(InteractionEvent)) - .Find(Builders.Filter.Gte(nameof(InteractionEvent.At), since) & Builders.Filter.In(nameof(InteractionEvent.Host), new[] { "127.0.0.1", Interactions.Unknown })) - .ToListAsync(token); - } - - [Fact] - public async Task A_delivery_and_its_refusals_are_recorded_naming_only_the_server() - { - var token = TestContext.Current.CancellationToken; - var since = DateTime.UtcNow.AddSeconds(-1); - var persona = await _host.Persona(await _host.SignUp(), "ledger"); - var bob = new RemoteActor(_peer, "bob"); - using var client = _host.Client(); - - Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", DirectNote(bob, persona, "psst")), token)).StatusCode); - var forged = bob.SignedPost("/human-centipede", DirectNote(bob, persona, "forged")); - forged.Headers.Remove("Signature"); - forged.Headers.TryAddWithoutValidation("Signature", bob.SignedPost("/elsewhere", new JsonObject()).Headers.GetValues("Signature").Single()); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(forged, token)).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await client.SendAsync(bob.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..20] + "/mouth", DirectNote(bob, persona, "lost")), token)).StatusCode); - - var stored = await StoredSince(since, token); - var reasons = stored.Select(e => e.GetValue(nameof(InteractionEvent.Reason), BsonNull.Value).ToString()).ToList(); - Assert.Contains("queued", reasons); - Assert.Contains("signature-invalid", reasons); - Assert.Contains("unknown-recipient", reasons); - var everything = string.Join("\n", stored.Select(e => e.ToJson())); - Assert.DoesNotContain(persona.UserName, everything); - Assert.DoesNotContain(persona.Id, everything); - Assert.DoesNotContain(persona.Root.Id, everything); - Assert.DoesNotContain(bob.Name, everything); - Assert.DoesNotContain("/notes/", everything); - } - - [Fact] - public async Task Client_and_served_traffic_is_counted_per_day_never_per_server() - { - var token = TestContext.Current.CancellationToken; - var persona = await _host.Persona(await _host.SignUp(), "served"); - var reader = new RemoteActor(_peer, "reader"); - var day = DateTime.UtcNow.Date; - await _host.Get().Flush(token); - var before = await DB.Default.Find().Match(d => d.Day == day).ExecuteFirstAsync(token) ?? new ServerDay(); - using var client = _host.Client(); - - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/instance", token)).StatusCode); - using (var actorRequest = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{persona.UserName}")) - { - actorRequest.Headers.Accept.ParseAdd("application/activity+json"); - Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(actorRequest, token)).StatusCode); - } - Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(reader.SignedGet($"/peasants/{persona.UserName}/anus"), token)).StatusCode); - await _host.Get().Flush(token); - - var after = await DB.Default.Find().Match(d => d.Day == day).ExecuteFirstAsync(token); - long Grew(Dictionary now, Dictionary then, string key) => now.GetValueOrDefault(key) - then.GetValueOrDefault(key); - Assert.True(Grew(after.Client, before.Client, "api/v1/instance:GET:2xx") >= 1); - Assert.True(Grew(after.Served, before.Served, "actor:200:unsigned") >= 1); - Assert.True(Grew(after.Served, before.Served, "outbox:200:signed") >= 1); - Assert.DoesNotContain(after.Served.Keys, k => k.Contains(persona.UserName)); - } - } -} diff --git a/PrivaPub.Tests/Statistics/LedgerStoreTests.cs b/PrivaPub.Tests/Statistics/LedgerStoreTests.cs deleted file mode 100644 index 216b7b8..0000000 --- a/PrivaPub.Tests/Statistics/LedgerStoreTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -namespace PrivaPub.Tests.Statistics -{ - [Trait("Category", "Integration")] - public sealed class LedgerStoreTests : IAsyncLifetime - { - readonly string _known = $"known{Guid.NewGuid():N}.example"; - readonly string _stranger = $"stranger{Guid.NewGuid():N}.example"; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - await DB.Default.SaveAsync(new RemoteInstance { Host = _known }); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - static InteractionLedger Ledger() => - new(new InteractionSalts(), new StaticOptions(new StatisticsOptions()), NullLogger.Instance); - - [Fact] - public async Task Events_are_stored_with_a_hash_and_never_the_actor() - { - var token = TestContext.Current.CancellationToken; - var ledger = Ledger(); - var actor = $"https://{_known}/users/someone{Guid.NewGuid():N}"; - - ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.In, Activity = "Create", Object = "Note", Outcome = Interactions.Accepted }, actor); - ledger.Record(new InteractionEvent { Host = _stranger, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "no-signature" }, hostClaimed: true); - ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "signature-invalid" }, hostClaimed: true); - await ledger.Flush(token); - - var stored = await DB.Default.Find().Match(e => e.Host == _known).ExecuteAsync(token); - Assert.Equal(2, stored.Count); - var accepted = Assert.Single(stored, e => e.Channel == Interactions.In); - Assert.NotNull(accepted.ActorHash); - Assert.Equal("signature-invalid", Assert.Single(stored, e => e.Channel == Interactions.Receive).Reason); - Assert.False(await DB.Default.Find().Match(e => e.Host == _stranger).ExecuteAnyAsync(token)); - var raw = await DB.Default.Database().GetCollection(nameof(InteractionEvent)).Find(new BsonDocument("_id", ObjectId.Parse(accepted.ID))).FirstAsync(token); - Assert.DoesNotContain("someone", raw.ToJson()); - Assert.DoesNotContain("/users/", raw.ToJson()); - } - - [Fact] - public async Task Two_processes_agree_on_a_days_salt_and_no_salt_is_made_for_the_past() - { - var token = TestContext.Current.CancellationToken; - - var one = await new InteractionSalts().For(DateTime.UtcNow, token); - var two = await new InteractionSalts().For(DateTime.UtcNow, token); - - Assert.Equal(Convert.ToBase64String(one), Convert.ToBase64String(two)); - Assert.Null(await new InteractionSalts().For(new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc), token)); - } - - [Fact] - public async Task Counters_add_up_per_day() - { - var token = TestContext.Current.CancellationToken; - var ledger = Ledger(); - - ledger.Count(_known, "media:hit", 1000); - ledger.Count(_known, "media:hit", 500); - ledger.Count(_known, "media.miss"); - ledger.CountServer(ServerSections.Client, $"test{_known}:GET:2xx", 120); - ledger.CountServer(ServerSections.Served, $"test{_known}:200:signed"); - await ledger.Flush(token); - - var day = await DB.Default.Find().Match(d => d.Host == _known && d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token); - Assert.Equal(2, day.Reads["media:hit"]); - Assert.Equal(1500, day.Reads["media:hit:bytes"]); - Assert.Equal(1, day.Reads["media_miss"]); - var server = await DB.Default.Find().Match(d => d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token); - Assert.Equal(1, server.Client[$"test{_known.Replace('.', '_')}:GET:2xx"]); - Assert.Equal(1, server.Served[$"test{_known.Replace('.', '_')}:200:signed"]); - Assert.True(server.ClientLatency["le250ms"] >= 1); - } - } -} diff --git a/PrivaPub.Tests/Statistics/OutboundRequestTests.cs b/PrivaPub.Tests/Statistics/OutboundRequestTests.cs deleted file mode 100644 index 9b16360..0000000 --- a/PrivaPub.Tests/Statistics/OutboundRequestTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Microsoft.AspNetCore.Http; - -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Tests.Support; - -namespace PrivaPub.Tests.Statistics -{ - public class HttpScopeTests - { - [Fact] - public async Task Scopes_nest_and_restore_across_awaits() - { - Assert.Null(HttpScope.Purpose); - Assert.Equal("request", HttpScope.Trigger); - - using (HttpScope.Triggered("deliver")) - { - using (HttpScope.Default("object")) - { - Assert.Equal("object", HttpScope.Purpose); - using (HttpScope.For("actor")) - { - await Task.Yield(); - Assert.Equal("actor", HttpScope.Purpose); - using (HttpScope.Default("object")) - Assert.Equal("actor", HttpScope.Purpose); - } - Assert.Equal("object", HttpScope.Purpose); - } - Assert.Null(HttpScope.Purpose); - Assert.Equal("deliver", HttpScope.Trigger); - Assert.False(HttpScope.Crawling); - using (HttpScope.Crawl()) - Assert.True(HttpScope.Crawling); - } - Assert.Equal("request", HttpScope.Trigger); - } - } - - public class TrafficMeterTests - { - [Theory] - [InlineData("api/v1/accounts/{id}/follow", "api/v1/accounts")] - [InlineData("/api/v1/statuses", "api/v1/statuses")] - [InlineData("/oauth/token", "oauth/token")] - [InlineData("/clientapi/avatar/private/insert", "clientapi/avatar/private")] - [InlineData("api/{x}", "api")] - [InlineData(null, "unmatched")] - public void Client_routes_group_by_their_first_literal_segments(string template, string group) => - Assert.Equal(group, TrafficMeter.Group(template)); - - [Theory] - [InlineData("peasants/{actor}", "/peasants/alice", "actor")] - [InlineData("peasants/{actor}/anus", "/peasants/alice/anus", "outbox")] - [InlineData("peasants/{actor}/flock", "/peasants/c/flock", "collection")] - [InlineData("peasants/{actor}/groupies", "/peasants/alice/groupies", "collection")] - [InlineData("peasants/{actor}/scribbles/{postId}", "/peasants/alice/scribbles/1", "object")] - [InlineData("peasants/{actor}/grunts/{activityId}", "/peasants/alice/grunts/1", "activity")] - [InlineData("peasants/{actor}/parrot-licences/{licenceId}", "/peasants/alice/parrot-licences/1", "licence")] - [InlineData("users/{actor}", "/users/alice", "actor")] - [InlineData(".well-known/webfinger", "/.well-known/webfinger", "webfinger")] - [InlineData("nodeinfo/2.1", "/nodeinfo/2.1", "nodeinfo")] - [InlineData("@{user}", "/@alice", null)] - public void Served_documents_are_counted_by_kind_never_by_name(string template, string path, string kind) => - Assert.Equal(kind, TrafficMeter.Served(template, new PathString(path))); - } - - public sealed class OutboundRequestTests : IAsyncLifetime - { - Peer _peer; - - public async ValueTask InitializeAsync() => _peer = await Peer.Start(); - - public async ValueTask DisposeAsync() => await _peer.DisposeAsync(); - - [Fact] - public async Task A_federation_fetch_is_recorded_with_its_purpose_trigger_and_size() - { - var ledger = new MemoryLedger(); - var http = Peer.Http(ledger: ledger); - _peer.Serve("/users/a", "{\"id\":\"{A}/users/a\",\"type\":\"Person\"}"); - - using (HttpScope.Triggered("processinbox")) - using (HttpScope.For("actor")) - Assert.NotNull(await http.GetJson($"{_peer.A}/users/a", "application/activity+json", default, TestContext.Current.CancellationToken)); - - var e = Assert.Single(ledger.Of("http")); - Assert.Equal(("127.0.0.1", "actor", "processinbox", "ok", 200), (e.Host, e.Purpose, e.Trigger, e.Outcome, e.Status!.Value)); - Assert.True(e.Bytes > 10); - Assert.Equal(0, e.Redirects); - } - - [Fact] - public async Task Refusals_are_recorded_and_a_second_try_is_remembered() - { - var ledger = new MemoryLedger(); - var http = Peer.Http(ledger: ledger); - _peer.Answer("/gone", 410); - _peer.ServeText("/page", "", "text/html"); - - using (HttpScope.Triggered("fetchancestors")) - { - await http.GetJson($"{_peer.A}/gone", "application/activity+json", default, TestContext.Current.CancellationToken); - await http.GetJson($"{_peer.A}/gone", "application/activity+json", default, TestContext.Current.CancellationToken); - await http.GetJson($"{_peer.A}/page", "application/activity+json", default, TestContext.Current.CancellationToken); - await http.GetJson("ftp://example.org/x", "application/activity+json", default, TestContext.Current.CancellationToken); - } - - var events = ledger.Of("http"); - Assert.Equal(new[] { ("refused", "410"), ("refused", "remembered"), ("refused", "content-type"), ("refused", "disallowed") }, - events.Select(e => (e.Outcome, e.Reason))); - Assert.Equal("object", events[0].Purpose); - } - - [Fact] - public async Task A_fetch_a_reader_caused_is_only_counted() - { - var ledger = new MemoryLedger(); - var http = Peer.Http(ledger: ledger); - _peer.Serve("/users/b", "{\"id\":\"{A}/users/b\",\"type\":\"Person\"}"); - - using (HttpScope.For("webfinger")) - await http.GetJson($"{_peer.A}/users/b", "application/activity+json", default, TestContext.Current.CancellationToken); - - Assert.Empty(ledger.Events); - Assert.Equal(1, ledger.Counts[("127.0.0.1", "http:webfinger:ok")]); - } - - [Fact] - public async Task A_connection_remembers_the_address_it_reached_for_locating_the_server() - { - var connected = new ConnectedAddresses(); - var http = Peer.Http(connected: connected); - _peer.Serve("/users/c", "{\"id\":\"{A}/users/c\",\"type\":\"Person\"}"); - - await http.GetJson($"{_peer.A}/users/c", "application/activity+json", default, TestContext.Current.CancellationToken); - - Assert.Equal(System.Net.IPAddress.Loopback, connected.Of("127.0.0.1")); - } - } -} diff --git a/PrivaPub.Tests/Statistics/RollupTests.cs b/PrivaPub.Tests/Statistics/RollupTests.cs deleted file mode 100644 index a35ff3b..0000000 --- a/PrivaPub.Tests/Statistics/RollupTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.Statistics; -using PrivaPub.Tests.Support; - -namespace PrivaPub.Tests.Statistics -{ - public class RollupFoldTests - { - static IEnumerable Day(string host) => new[] - { - new InteractionEvent { Host = host, Channel = "recv", Activity = "Create", Status = 202, Reason = "queued", Signature = "cavage:rsa-sha256", LatencyMs = 40, Bytes = 900 }, - new InteractionEvent { Host = host, Channel = "recv", Activity = "Create", Status = 401, Reason = "no-signature" }, - new InteractionEvent { Host = host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Reason = "stored", Audience = "public", LocalKind = "person", ActorHash = "a", Features = new() { "fep-044f-quote", "source:text/markdown" }, WaitMs = 1500 }, - new InteractionEvent { Host = host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Reason = "stored", Audience = "private", ActorHash = "b", Features = new() { "interaction-policy" } }, - new InteractionEvent { Host = host, Channel = "in", Activity = "Flag", Outcome = "accepted", Reason = "reported", Audience = "none", ActorHash = "a" }, - new InteractionEvent { Host = host, Channel = "in", Activity = "Like", Object = "Note", Outcome = "dropped", Reason = "unknown-object", Audience = "public" }, - new InteractionEvent { Host = host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "ok", Status = 202, Audience = "public", LocalKind = "person", LatencyMs = 120, Bytes = 2000 }, - new InteractionEvent { Host = host, Channel = "out", Activity = "Block", Outcome = "ok", Status = 202, Audience = "none" }, - new InteractionEvent { Host = host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "retry", Reason = "503", Audience = "private" }, - new InteractionEvent { Host = host, Channel = "http", Purpose = "actor", Trigger = "verify", Outcome = "ok", LatencyMs = 300, Bytes = 1400 }, - new InteractionEvent { Host = host, Channel = "http", Purpose = "object", Trigger = "fetchancestors", Outcome = "refused", Reason = "410" } - }; - - [Fact] - public void Every_event_counts_for_the_admin_with_its_reason() - { - var fold = Rollup.Fold(Day("social.example")).Hosts["social.example"]; - - Assert.Equal(1, fold.Counters["recv:Create:202:queued"]); - Assert.Equal(1, fold.Counters["recv:Create:401:no-signature"]); - Assert.Equal(1, fold.Counters["sig:cavage:rsa-sha256"]); - Assert.Equal(2, fold.Counters["in:Create:Note:accepted:stored"]); - Assert.Equal(1, fold.Counters["in:Like:Note:dropped:unknown-object"]); - Assert.Equal(1, fold.Counters["out:Create:Note:retry:503"]); - Assert.Equal(1, fold.Counters["out:Block:-:ok"]); - Assert.Equal(1, fold.Counters["http:object:refused:410"]); - Assert.Equal(1, fold.Counters["aud:in:private"]); - Assert.Equal(1, fold.Counters["kind:out:person"]); - Assert.Equal(1, fold.Counters["feat:source:text/markdown"]); - Assert.Equal(1, fold.Latency["out:le250ms"]); - Assert.Equal(1, fold.Latency["http:actor:le500ms"]); - Assert.Equal(1, fold.Waits["in:le10s"]); - Assert.Equal(2000, fold.Bytes["out"]); - Assert.Equal(1400, fold.Bytes["http:actor"]); - Assert.Equal(2, fold.Accounts.Count); - } - - [Fact] - public void The_public_counters_keep_only_public_kinds_of_traffic_without_reasons() - { - var fold = Rollup.Fold(Day("social.example")).Hosts["social.example"]; - - Assert.Equal(new Dictionary - { - ["in:Create:Note:accepted"] = 1, - ["in:Like:Note:dropped"] = 1, - ["out:Create:Note:delivered"] = 1, - ["feat:fep-044f-quote"] = 1, - ["feat:source:text/markdown"] = 1, - ["health:ok"] = 4, - ["health:failed"] = 1 - }, fold.PublicCounters); - Assert.DoesNotContain(fold.PublicCounters.Keys, k => k.Contains("Flag") || k.Contains("Block") || k.Contains("recv") || k.Contains("interaction-policy")); - } - - [Fact] - public void Keys_are_safe_field_names_and_folding_is_repeatable() - { - Assert.Equal("in:-:Note:accepted", Rollup.Key("in", null, "Note", "accepted", null)); - Assert.Equal("feat:content:text/x_mfm", Rollup.Key("feat", "content:text/x.mfm")); - var first = Rollup.Fold(Day("a.example").Concat(Day("b.example"))); - var second = Rollup.Fold(Day("a.example").Concat(Day("b.example"))); - - Assert.Equal(first.Hosts["a.example"].Counters, second.Hosts["a.example"].Counters); - Assert.Equal(2, first.Accounts.Count); - Assert.Equal(2, first.Hosts.Count); - } - } - - [Trait("Category", "Integration")] - public sealed class RollupJobTests : IAsyncLifetime - { - static readonly DateTime Day = new(2001, 2, 3, 0, 0, 0, DateTimeKind.Utc); - readonly string _host = $"rollup{Guid.NewGuid():N}.example"; - - public ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - return ValueTask.CompletedTask; - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - [Fact] - public async Task A_past_day_folds_into_server_rows_replaces_itself_keeps_reads_and_forgets_its_salt() - { - var token = TestContext.Current.CancellationToken; - await DB.Default.SaveAsync(new[] - { - new InteractionEvent { At = Day.AddHours(3), Host = _host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Audience = "public", ActorHash = "x" }, - new InteractionEvent { At = Day.AddHours(5), Host = _host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "ok", Audience = "public" }, - new InteractionEvent { At = Day.AddDays(1).AddMinutes(1), Host = _host, Channel = "in", Activity = "Like", Outcome = "accepted" } - }, token); - await DB.Default.SaveAsync(new InstanceDay { Day = Day, Host = _host, Reads = new() { ["media:hit"] = 7 } }, token); - await DB.Default.SaveAsync(new InteractionSalt { Day = Day, Key = "AAAA", ExpiresAt = DateTime.UtcNow.AddHours(1) }, token); - var job = new RollupJob(new JobQueue(j => j.DedupeKey == "never")); - - Assert.Equal(JobResult.Done, (await job.Handle(RollupJob.For(Day, $"test|{Guid.NewGuid():N}"), token)).Result); - var first = await DB.Default.Find().Match(d => d.Day == Day && d.Host == _host).ExecuteSingleAsync(token); - Assert.Equal(JobResult.Done, (await job.Handle(RollupJob.For(Day, $"test|{Guid.NewGuid():N}"), token)).Result); - var second = await DB.Default.Find().Match(d => d.Day == Day && d.Host == _host).ExecuteSingleAsync(token); - - Assert.Equal(1, first.Counters["in:Create:Note:accepted"]); - Assert.False(first.Counters.ContainsKey("in:Like:-:accepted")); - Assert.Equal(1, first.PublicCounters["out:Create:Note:delivered"]); - Assert.Equal(1, first.Accounts); - Assert.Equal(7, first.Reads["media:hit"]); - Assert.Equal(first.Counters, second.Counters); - Assert.Equal(7, second.Reads["media:hit"]); - Assert.NotNull(second.RolledUpAt); - Assert.False(await DB.Default.Find().Match(s => s.Day == Day).ExecuteAnyAsync(token)); - Assert.True(await DB.Default.Find().Match(j => j.DedupeKey == "rollup|2001-02-04" && j.Kind == JobKind.RollupDay).ExecuteAnyAsync(token)); - } - - [Fact] - public async Task Today_is_not_folded_before_it_ends() - { - var outcome = await new RollupJob(new JobQueue(j => j.DedupeKey == "never")).Handle(RollupJob.For(DateTime.UtcNow.Date, "x"), TestContext.Current.CancellationToken); - - Assert.Equal(JobResult.Defer, outcome.Result); - Assert.Equal(DateTime.UtcNow.Date.AddDays(1) + RollupJob.After, outcome.RetryAt); - } - - [Fact] - public async Task The_schedule_plans_today_and_catches_up_a_missed_day() - { - var token = TestContext.Current.CancellationToken; - var missed = DateTime.UtcNow.Date.AddDays(-3); - await DB.Default.SaveAsync(new InteractionEvent { At = missed.AddHours(12), Host = _host, Channel = "in", Activity = "Create", Outcome = "accepted" }, token); - - await new StatisticsSchedule(new JobQueue(j => j.DedupeKey == "never"), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance).Plan(token); - - var today = await DB.Default.Find().Match(j => j.DedupeKey == $"rollup|{RollupJob.DayKey(DateTime.UtcNow.Date)}").ExecuteFirstAsync(token); - Assert.Equal(DateTime.UtcNow.Date.AddDays(1) + RollupJob.After, today.RunAt); - Assert.True(await DB.Default.Find().Match(j => j.DedupeKey == $"rollup|{RollupJob.DayKey(missed)}").ExecuteAnyAsync(token)); - } - } -} diff --git a/PrivaPub.Tests/Support/Exclusive.cs b/PrivaPub.Tests/Support/Exclusive.cs deleted file mode 100644 index 6881df9..0000000 --- a/PrivaPub.Tests/Support/Exclusive.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PrivaPub.Tests.Support -{ - [CollectionDefinition(nameof(Exclusive), DisableParallelization = true)] - public sealed class Exclusive - { - } -} diff --git a/PrivaPub.Tests/Support/FederatedSeeds.cs b/PrivaPub.Tests/Support/FederatedSeeds.cs deleted file mode 100644 index 025bd5c..0000000 --- a/PrivaPub.Tests/Support/FederatedSeeds.cs +++ /dev/null @@ -1,55 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Social; -using PrivaPub.Tests.Support.Host; - -using System.Globalization; -using System.Net; -using System.Numerics; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Support -{ - public static class FederatedSeeds - { - public static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - - public static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}"; - - public static JsonObject PublicNote(RemoteActor author, string content = "

hello

", params string[] cc) => new() - { - ["id"] = NewId(author, "notes"), - ["type"] = "Note", - ["attributedTo"] = author.Id, - ["content"] = content, - ["published"] = DateTime.UtcNow.ToString("O"), - ["to"] = new JsonArray(Addressing.Public), - ["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray()) - }; - - public static JsonObject Create(RemoteActor author, JsonObject note) => - new() { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note.DeepClone() }; - - public static JsonObject Activity(RemoteActor actor, string type, JsonNode inner) => - new() { ["id"] = NewId(actor, "activities"), ["type"] = type, ["actor"] = actor.Id, ["object"] = inner.DeepClone() }; - - public static string IdOf(JsonNode node) => node["id"]!.GetValue(); - - //an accepted follow made here, without the round trip - public static Task Follows(string avatarId, RemoteActor target) => - DB.Default.SaveAsync(new Following { AvatarId = avatarId, TargetActorURI = target.Id, TargetInboxURL = target.Id + "/inbox", State = FollowState.Accepted }); - - public static async Task DeliverSigned(PrivaPubHost host, RemoteActor sender, JsonObject activity, string path = "/human-centipede") - { - using var client = host.Client(); - var response = await client.SendAsync(sender.SignedPost(path, activity), TestContext.Current.CancellationToken); - await host.RunInbox(IdOf(activity), TestContext.Current.CancellationToken); - return response.StatusCode; - } - - //the smallest id above this one, for a max_id that starts a page exactly at it - public static string IdAbove(string id) => - (BigInteger.Parse("0" + id, NumberStyles.HexNumber) + 1).ToString("x24")[^24..]; - } -} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs deleted file mode 100644 index 1b5916b..0000000 --- a/PrivaPub.Tests/Support/Harness.cs +++ /dev/null @@ -1,192 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging.Abstractions; - -using MongoDB.Entities; - -using PrivaPub.Domain.Content; -using PrivaPub.Domain.Media; -using PrivaPub.Domain.Relationships; -using PrivaPub.Domain.Social; -using PrivaPub.Domain.Statuses; -using PrivaPub.Domain.Timelines; -using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Inbox; -using PrivaPub.Federation.Inbox.Handlers; -using PrivaPub.Federation.Outbox; -using PrivaPub.Federation.Relays; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models; -using PrivaPub.Federation.Objects; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Jobs; -using PrivaPub.Models.User; -using PrivaPub.Resources; -using PrivaPub.Services; -using PrivaPub.StaticServices; - -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Support -{ - public sealed class Harness : IAsyncDisposable - { - public const string Host = "privapub.test"; - public const string Base = "https://" + Host; - - readonly DateTime _started = Millisecond(DateTime.UtcNow); - - Harness(Peer peer) - { - Peer = peer; - var cache = new MemoryCache(new MemoryCacheOptions()); - Local = new LocalActorService(Db, new StaticOptions(new AppConfiguration { BackendBaseAddress = Base })); - Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); - Delivery = new DeliveryService(Db, Queue); - Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery); - Fanout = new Fanout(Db); - Groups = new GroupDistributor(Delivery); - Records = new ObjectRecords(Queue); - Polls = new PollService(Db, Local, Delivery, Queue); - Reactions = new Reactions(Db, Delivery); - RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); - Outbox = new OutboxPublisher(Db, Local, Delivery, Relays); - Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); - Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox); - Participations = new Participations(Db, Local, Delivery); - Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance, Ledger); - Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); - Relationships = new RelationshipService(Db, Follows, Delivery); - Featured = new FeaturedPosts(Db, Remote, RemotePosts); - Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout); - Handlers = new IActivityHandler[] - { - new FollowHandler(Db, Local, Remote, Delivery), - new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue), - new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays), - new ReplyApprovalHandler(Approvals), - new ReplyRejectionHandler(Approvals), - new UndoHandler(Db, Local, Reactions), - new LikeHandler(Db, Reactions), - new EmojiReactHandler(Db, Reactions), - new QuoteRequestHandler(Quotes), - new WarnHandler(Db, Local, Remote), - new ResolveHandler(Db, Local), - new DislikeHandler(Db), - new JoinHandler(Db, Local, Delivery), - new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays), - new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays, Walls), - new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), - new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery), - new FlagHandler(Db, Local), - new BlockHandler(Db, Local), - new LockHandler(Db), - new MoveHandler(Remote, Db, Local, Follows, Relationships), - new FeaturedHandler(Featured, add: true, Walls, Remote, Queue), - new FeaturedHandler(Featured, add: false, Walls, Remote, Queue) - }; - ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; - Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger, Local); - Content = new ContentRenderer(Local, Remote); - Media = new MediaService(new StaticOptions(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), - Local, default, NullLogger.Instance); - Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes, Approvals); - Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer(), NullLogger.Instance); - Timelines = new TimelineService(Db, new KeyLocalizer()); - Reports = new ReportService(Db, Local, Delivery); - } - - public Peer Peer { get; } - public DbEntities Db { get; } = new(); - public MemoryLedger Ledger { get; } = new(); - public JobQueue Queue { get; } = new(); - public LocalActorService Local { get; } - public RemoteActorService Remote { get; } - public DeliveryService Delivery { get; } - public InboxReceiver Receiver { get; } - public InboxProcessor Processor { get; } - public IReadOnlyList Handlers { get; } - public FollowService Follows { get; } - public ContentRenderer Content { get; } - public OutboxPublisher Outbox { get; } - public PostsService Posts { get; } - public StatusService Statuses { get; } - public MediaService Media { get; } - public GroupDistributor Groups { get; } - public Fanout Fanout { get; } - public RemotePosts RemotePosts { get; } - public ObjectRecords Records { get; } - public PollService Polls { get; } - public Reactions Reactions { get; } - public QuoteService Quotes { get; } - public InteractionApprovals Approvals { get; } - public Participations Participations { get; } - public TimelineService Timelines { get; } - public RelationshipService Relationships { get; } - public FeaturedPosts Featured { get; } - public Walls Walls { get; } - public Relays Relays { get; } - // the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile - public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new(); - public ReportService Reports { get; } - - public async Task FollowedBy(LocalActor local, RemoteActor follower) => - await DB.Default.SaveAsync(new Follower - { - LocalActorId = local.Id, - LocalActorKind = local.Kind, - ActorURI = follower.Id, - InboxURL = follower.Id + "/inbox", - SharedInboxURL = follower.SharedInbox - }); - - public static async Task Start() => new(await Peer.Start()); - - public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default) - { - rootId ??= Guid.NewGuid().ToString("N")[..24]; - var (privateKey, publicKey) = Keys.NewKeyPair(); - var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey }; - await DB.Default.SaveAsync(avatar); - await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID }); - return (rootId, Local.FromAvatar(avatar)); - } - - public async Task Deliver(RemoteActor sender, string path, JsonNode activity) - { - var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None); - // the job this delivery queued: under its id, or apart (a forwarded copy; an id its sender reused) - var id = activity is JsonObject ? activity["id"]?.GetValue() : default; - var dedupe = (result.Reason == "forwarded" ? "inbox|forwarded|" : "inbox|") + id; - var job = await DB.Default.Find().Match(j => (j.DedupeKey == dedupe || j.DedupeKey.StartsWith(dedupe + "|")) && j.State == JobState.Pending) - .Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(); - if (job != default && result.Reason != "duplicate") - Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result); - return result; - } - - public async Task> Outgoing(string inbox) => - (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync()) - .Select(j => JsonSerializer.Deserialize(j.Payload)) - .Where(p => p.Inbox == inbox) - .Select(p => JsonNode.Parse(p.Body)!.AsObject()) - .ToList(); - - public async ValueTask DisposeAsync() => await Peer.DisposeAsync(); - - static DateTime Millisecond(DateTime time) => new(time.Ticks - time.Ticks % TimeSpan.TicksPerMillisecond, DateTimeKind.Utc); - } - - public sealed class KeyLocalizer : IStringLocalizer - { - public LocalizedString this[string name] => new(name, name); - public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments)); - public IEnumerable GetAllStrings(bool includeParentCultures) => Enumerable.Empty(); - } - - public sealed class NoPreviews : ILinkPreviews - { - public Task Wanted(PrivaPub.Models.Post.Post post, CancellationToken token) => Task.CompletedTask; - } -} diff --git a/PrivaPub.Tests/Support/Host/Accounts.cs b/PrivaPub.Tests/Support/Host/Accounts.cs deleted file mode 100644 index 0352807..0000000 --- a/PrivaPub.Tests/Support/Host/Accounts.cs +++ /dev/null @@ -1,117 +0,0 @@ -using PrivaPub.Infrastructure.Cli; - -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace PrivaPub.Tests.Support.Host -{ - public sealed record Root(string Id, string UserName, string Password, string Jwt); - - public sealed record Persona(string Id, string UserName, Root Root); - - public static partial class Accounts - { - public const string Password = "Test-Pass-1!"; - const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob"; - - public static async Task SignUp(this PrivaPubHost host, string name = "root") - { - var userName = $"{name}{Guid.NewGuid():N}"[..24]; - using var client = host.Client(); - var response = await client.PostAsJsonAsync("/clientapi/user/signup", new { userName, password = Password }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jwt = (await response.Content.ReadFromJsonAsync())!; - return new Root(jwt["userId"]!.GetValue(), userName, Password, jwt["token"]!.GetValue()); - } - - public static async Task LogIn(this PrivaPubHost host, Root root) - { - using var client = host.Client(); - var response = await client.PostAsJsonAsync("/clientapi/user/login", new { userName = root.UserName, password = root.Password }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jwt = (await response.Content.ReadFromJsonAsync())!; - return root with { Jwt = jwt["token"]!.GetValue() }; - } - - public static async Task Admin(this PrivaPubHost host) - { - var root = await host.SignUp("admin"); - Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, host.Services)); - return await host.LogIn(root); - } - - public static async Task Persona(this PrivaPubHost host, Root root, string name = "persona") - { - var userName = $"{name}{Guid.NewGuid():N}"[..20]; - using var client = host.As(root.Jwt); - var response = await client.PostAsJsonAsync("/clientapi/avatar/private/insert", new { userName, name, biography = "testing" }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var avatar = (await response.Content.ReadFromJsonAsync())!; - return new Persona(avatar["id"]!.GetValue(), userName, root); - } - - public static async Task MastodonToken(this PrivaPubHost host, Persona persona, string scopes = "read write follow") - { - using var client = host.Client(cookies: true); - var app = await Form(client, "/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes)); - var clientId = app["client_id"]!.GetValue(); - var clientSecret = app["client_secret"]!.GetValue(); - var query = $"client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(scopes)}"; - - var code = await Authorize(client, persona, query); - var token = await Form(client, "/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", clientId), - ("client_secret", clientSecret), ("redirect_uri", OutOfBand)); - return token["access_token"]!.GetValue(); - } - - public static async Task Authorize(HttpClient client, Persona persona, string query, string decision = "allow") - { - var returnUrl = "/oauth/authorize?" + query; - var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl)); - var antiforgery = AntiforgeryToken().Match(login).Groups[1].Value; - var signedIn = await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary - { - ["returnUrl"] = returnUrl, - ["__RequestVerificationToken"] = antiforgery, - ["userName"] = persona.Root.UserName, - ["password"] = persona.Root.Password - })); - Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); - - var choose = await client.GetStringAsync(returnUrl + "&signed_in=1"); - var fields = HiddenInput().Matches(choose).Select(m => new KeyValuePair(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList(); - fields.Add(new("avatarId", persona.Id)); - fields.Add(new("decision", decision)); - var answer = await client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(fields)); - var page = await answer.Content.ReadAsStringAsync(); - return Code().Match(page) is { Success: true } match ? match.Groups[1].Value : default; - } - - public static HttpClient As(this PrivaPubHost host, string bearer) - { - var client = host.Client(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearer); - return client; - } - - static async Task Form(HttpClient client, string path, params (string Key, string Value)[] fields) - { - var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); - var body = await response.Content.ReadAsStringAsync(); - Assert.True(response.IsSuccessStatusCode, $"{path} answered {(int)response.StatusCode}: {body}"); - return JsonNode.Parse(body)!.AsObject(); - } - - [GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")] - private static partial Regex AntiforgeryToken(); - - [GeneratedRegex("([^<]*)")] - private static partial Regex Code(); - } -} diff --git a/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs b/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs deleted file mode 100644 index 467f7e3..0000000 --- a/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs +++ /dev/null @@ -1,166 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Models.User; - -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace PrivaPub.Tests.Support.Host -{ - public sealed record OAuthApp(string ClientId, string ClientSecret, string Scopes) - { - public string Query(string extra = default) => - $"client_id={Uri.EscapeDataString(ClientId)}&redirect_uri={Uri.EscapeDataString(ClientApi.OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(Scopes)}" - + (extra == default ? string.Empty : "&" + extra); - - public string ReturnUrl(string extra = default) => "/oauth/authorize?" + Query(extra); - } - - public static partial class ClientApi - { - public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob"; - - public static HttpClient ClientAt(this PrivaPubHost host, string address, bool cookies = false) - { - var client = host.Client(cookies); - client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader); - client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, address); - return client; - } - - public static async Task JsonBody(this HttpResponseMessage response) => - JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject(); - - public static async Task JsonItems(this HttpResponseMessage response) => - JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); - - public static Task PostJson(this HttpClient client, string path, object body) => - client.PostAsJsonAsync(path, body); - - public static Task Form(this HttpClient client, string path, params (string Key, string Value)[] fields) => - client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); - - public static async Task RegisterApp(this HttpClient client, string scopes = "read write follow") - { - var response = await client.Form("/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var app = await response.JsonBody(); - return new OAuthApp(app["client_id"]!.GetValue(), app["client_secret"]!.GetValue(), scopes); - } - - public static async Task SignIn(this HttpClient client, Root root, string returnUrl, string password = default) - { - var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl)); - return await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary - { - ["returnUrl"] = returnUrl, - ["__RequestVerificationToken"] = Antiforgery(login), - ["userName"] = root.UserName, - ["password"] = password ?? root.Password - })); - } - - public static string Antiforgery(string page) => AntiforgeryToken().Match(page).Groups[1].Value; - - public static async Task>> Choice(this HttpClient client, OAuthApp app) - { - var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1"); - return HiddenInput().Matches(page).Select(m => new KeyValuePair(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList(); - } - - public static Task Decide(this HttpClient client, IEnumerable> choice, string avatarId, string decision = "allow") => - client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(choice.Append(new("avatarId", avatarId)).Append(new("decision", decision)))); - - public static async Task Choose(this HttpClient client, OAuthApp app, string avatarId, string decision = "allow") => - await client.Decide(await client.Choice(app), avatarId, decision); - - public static string CodeIn(string page) => CodeElement().Match(page) is { Success: true } match ? match.Groups[1].Value : default; - - public static async Task Code(this HttpClient client, OAuthApp app, Persona persona) - { - Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); - var code = CodeIn(await (await client.Choose(app, persona.Id)).Content.ReadAsStringAsync()); - Assert.False(string.IsNullOrEmpty(code)); - return code; - } - - public static Task Exchange(this HttpClient client, OAuthApp app, string code) => - client.Form("/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", app.ClientId), - ("client_secret", app.ClientSecret), ("redirect_uri", OutOfBand)); - - public static async Task Token(this HttpClient client, OAuthApp app, Persona persona) - { - var response = await client.Exchange(app, await client.Code(app, persona)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return (await response.JsonBody())["access_token"]!.GetValue(); - } - - public static async Task Group(this PrivaPubHost host, Persona owner, bool community, string password = default, string name = "group") - { - using var client = host.As(owner.Root.Jwt); - var response = await client.PostJson("/clientapi/group/insert", new - { - avatarId = owner.Id, - userName = $"{name}{Guid.NewGuid():N}"[..20], - name, - isCommunity = community, - invitationPassword = password - }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.JsonBody(); - } - - public static async Task Ban(string rootId, bool banned = true) => - await DB.Default.Update().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync(); - - public static async Task Follow(this PrivaPubHost host, RemoteActor follower, string origin, string userName) - { - var follow = new JsonObject - { - ["id"] = $"{origin}/follows/{Guid.NewGuid():N}", - ["type"] = "Follow", - ["actor"] = follower.Id, - ["object"] = $"{PrivaPubHost.Base}/peasants/{userName}" - }; - using var client = host.Client(); - Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(follower.SignedPost($"/peasants/{userName}/mouth", follow))).StatusCode); - Assert.Equal(1, await host.RunInbox(follow["id"]!.GetValue())); - return follow; - } - - public static async Task> StoredTokens(string clientId) - { - var database = DB.Default.Database(); - var application = await database.GetCollection("openiddict.applications") - .Find(Builders.Filter.Eq("client_id", clientId)).FirstAsync(); - return await database.GetCollection("openiddict.tokens") - .Find(Builders.Filter.Eq("application_id", application["_id"])).ToListAsync(); - } - - public static bool IsAccessToken(BsonDocument token) => - token.GetValue("type", BsonNull.Value) is { IsString: true } type && type.AsString.EndsWith("access_token", StringComparison.Ordinal); - - public static string JwtPayload(string jwt) - { - var parts = jwt.Split('.'); - if (parts.Length != 3) - return string.Empty; - var segment = parts[1].Replace('-', '+').Replace('_', '/'); - return Encoding.UTF8.GetString(Convert.FromBase64String(segment.PadRight(segment.Length + (4 - segment.Length % 4) % 4, '='))); - } - - [GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")] - private static partial Regex AntiforgeryToken(); - - [GeneratedRegex("([^<]*)")] - private static partial Regex CodeElement(); - } -} diff --git a/PrivaPub.Tests/Support/Host/FederationHelpers.cs b/PrivaPub.Tests/Support/Host/FederationHelpers.cs deleted file mode 100644 index bc71a35..0000000 --- a/PrivaPub.Tests/Support/Host/FederationHelpers.cs +++ /dev/null @@ -1,141 +0,0 @@ -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Actors; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; - -using System.Net; -using System.Net.Http.Json; -using System.Text.Json.Nodes; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Support.Host -{ - public sealed record Fetched(HttpStatusCode Status, string MediaType, string Text, HttpResponseMessage Response) - { - public JsonObject Json => JsonNode.Parse(Text)!.AsObject(); - public string Location => Response.Headers.Location?.OriginalString; - } - - public sealed record LocalGroup(string Id, string UserName) - { - public string Uri => $"{PrivaPubHost.Base}/peasants/{UserName}"; - } - - public static class FederationHelpers - { - public const string ActivityJson = "application/activity+json"; - public const string LdJson = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; - public const string Browser = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; - - public static string ActorUri(this Persona persona) => $"{PrivaPubHost.Base}/peasants/{persona.UserName}"; - - public static async Task Fetch(this HttpClient client, string path, string accept = ActivityJson) - { - using var request = new HttpRequestMessage(HttpMethod.Get, path); - if (accept != default) - request.Headers.TryAddWithoutValidation("Accept", accept); - return await client.Fetch(request); - } - - public static async Task Fetch(this HttpClient client, HttpRequestMessage request) - { - var response = await client.SendAsync(request, TestContext.Current.CancellationToken); - var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return new Fetched(response.StatusCode, response.Content.Headers.ContentType?.MediaType, text, response); - } - - public static async Task Actor(this PrivaPubHost host, Persona persona) => - await host.Get().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken); - - public static async Task Publish(this PrivaPubHost host, Persona persona, StatusDraft draft) - { - var outcome = await host.Get().Publish(await host.Actor(persona), draft, TestContext.Current.CancellationToken); - Assert.True(outcome.Ok, outcome.Error); - return outcome.Post; - } - - public static Task Publish(this PrivaPubHost host, Persona persona, string text, PostVisibility visibility = PostVisibility.Public) => - host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Visibility = visibility }); - - public static Task PublishLocated(this PrivaPubHost host, Persona persona, string text) => - host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Latitude = 45.46, Longitude = 9.19, RangeKm = 5 }); - - public static async Task Remove(this PrivaPubHost host, Persona persona, Post post) - { - var outcome = await host.Get().Remove(await host.Actor(persona), post.ID, TestContext.Current.CancellationToken); - Assert.True(outcome.Ok, outcome.Error); - } - - public static async Task Reblog(this PrivaPubHost host, Persona persona, Post original) - { - var outcome = await host.Get().Reblog(await host.Actor(persona), original.ID, true, PostVisibility.Public, TestContext.Current.CancellationToken); - Assert.True(outcome.Ok, outcome.Error); - return outcome.Post; - } - - public static async Task FederatedCopy(this PrivaPubHost host, Persona persona) - { - var post = new Post - { - GroupUserId = persona.Id, - AuthorAccountId = persona.Id, - IsFederatedCopy = true, - Visibility = PostVisibility.Public, - ObjectURI = $"https://remote.example/notes/{Guid.NewGuid():N}", - ActorURI = "https://remote.example/users/someone", - ContentHtml = "

a copy that is not ours to serve

" - }; - await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); - return post; - } - - public static async Task FederatedGroup(this PrivaPubHost host, Persona owner, bool community, string name = default) - { - var userName = $"{name ?? (community ? "community" : "circle")}{Guid.NewGuid():N}"[..20]; - using var client = host.As(owner.Root.Jwt); - var response = await client.PostAsJsonAsync("/clientapi/group/insert", - new { avatarId = owner.Id, userName, name = userName, description = "testing", isCommunity = community, postingPolicy = community ? "anyone" : default }, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var group = (await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken))!; - return new LocalGroup(group["id"]!.GetValue(), userName); - } - - public static async Task AddForeignMember(this LocalGroup group, string actorUri) => - await DB.Default.Update().MatchID(group.Id) - .Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = actorUri, IsForeign = true })) - .ExecuteAsync(TestContext.Current.CancellationToken); - - public static IEnumerable Nodes(JsonNode node) - { - if (node == default) - yield break; - yield return node; - switch (node) - { - case JsonObject obj: - foreach (var (_, value) in obj) - foreach (var inner in Nodes(value)) - yield return inner; - break; - case JsonArray array: - foreach (var item in array) - foreach (var inner in Nodes(item)) - yield return inner; - break; - } - } - - public static IEnumerable Keys(JsonNode node) => - Nodes(node).OfType().SelectMany(o => o.Select(p => p.Key)); - - public static IEnumerable Strings(JsonNode node) => - Nodes(node).OfType().Where(v => v.TryGetValue(out _)).Select(v => v.GetValue()); - - public static string PathOf(string url) => new Uri(url).PathAndQuery; - } -} diff --git a/PrivaPub.Tests/Support/Host/Jobs.cs b/PrivaPub.Tests/Support/Host/Jobs.cs deleted file mode 100644 index 68c8649..0000000 --- a/PrivaPub.Tests/Support/Host/Jobs.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -using MongoDB.Entities; - -using PrivaPub.Federation.Outbox; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Models.Jobs; - -using System.Linq.Expressions; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Support.Host -{ - public static class Jobs - { - public static async Task Run(this PrivaPubHost host, Expression> which, CancellationToken token = default) - { - var handlers = host.Services.GetServices().ToDictionary(h => h.Kind); - var queue = host.Get(); - var ran = 0; - for (var round = 0; round < 50; round++) - { - var pending = await DB.Default.Find().Match(which).Match(j => j.State == JobState.Pending).ExecuteAsync(token); - if (pending.Count == 0) - return ran; - foreach (var candidate in pending) - { - var job = await DB.Default.UpdateAndGet() - .Match(j => j.ID == candidate.ID && j.State == JobState.Pending) - .Modify(j => j.State, JobState.Running) - .Modify(j => j.LeasedUntil, DateTime.UtcNow + JobQueue.LeaseTime) - .Modify(b => b.Inc(j => j.Attempts, 1)) - .ExecuteAsync(token); - if (job == default) - continue; - var handler = handlers[job.Kind]; - JobOutcome outcome; - try - { - outcome = await handler.Handle(job, token); - } - catch (Exception ex) - { - outcome = JobOutcome.Dead(ex.GetType().Name + ": " + ex.Message); - } - await queue.Finish(job, outcome, handler.MaxAttempts, token); - ran++; - } - } - return ran; - } - - public static Task RunInbox(this PrivaPubHost host, string activityId, CancellationToken token = default) => - host.Run(j => j.Kind == JobKind.ProcessInbox && j.DedupeKey == "inbox|" + activityId, token); - - public static async Task> Deliveries(string inbox, DateTime since, CancellationToken token = default) => - (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token)) - .Select(j => JsonSerializer.Deserialize(j.Payload)) - .Where(p => p.Inbox == inbox) - .Select(p => JsonNode.Parse(p.Body)!.AsObject()) - .ToList(); - } -} diff --git a/PrivaPub.Tests/Support/Host/MastodonHelpers.cs b/PrivaPub.Tests/Support/Host/MastodonHelpers.cs deleted file mode 100644 index fbdb0f4..0000000 --- a/PrivaPub.Tests/Support/Host/MastodonHelpers.cs +++ /dev/null @@ -1,232 +0,0 @@ -using MongoDB.Entities; - -using NetVips; - -using PrivaPub.Federation.Actors; -using PrivaPub.Models.Post; -using PrivaPub.Models.User; - -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace PrivaPub.Tests.Support.Host -{ - public sealed record Mastodon(Persona Persona, string Token, HttpClient Client) - { - public string Id => Persona.Id; - public string UserName => Persona.UserName; - public string Uri => $"{PrivaPubHost.Base}/peasants/{Persona.UserName}"; - public string Mouth => $"/peasants/{Persona.UserName}/mouth"; - } - - public sealed record ApiAnswer(HttpStatusCode Status, JsonNode Body, string Text, HttpResponseHeaders Headers) - { - public JsonObject Object => Body.AsObject(); - public JsonArray Array => Body.AsArray(); - public IEnumerable Ids => Array.Select(item => item!["id"]!.GetValue()); - - public ApiAnswer Ok() - { - Assert.True(Status == HttpStatusCode.OK, $"expected 200, got {(int)Status}: {Text}"); - return this; - } - - public string Link(string rel) => - Headers.TryGetValues("Link", out var values) - ? Regex.Matches(string.Join(", ", values), "<([^>]+)>; rel=\"([a-z]+)\"").FirstOrDefault(m => m.Groups[2].Value == rel)?.Groups[1].Value - : default; - } - - public static class MastodonHelpers - { - public const string Public = "https://www.w3.org/ns/activitystreams#Public"; - - public static async Task Mastodon(this PrivaPubHost host, string name = "persona", Root root = default, string scopes = "read write follow") - { - root ??= await host.SignUp(); - var persona = await host.Persona(root, name); - var token = await host.MastodonToken(persona, scopes); - return new Mastodon(persona, token, host.As(token)); - } - - public static Task Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path); - - public static Task Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form); - - public static Task Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form); - - public static Task Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form); - - public static Task Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form); - - public static Task Call(this HttpClient client, HttpMethod method, string path, params (string Key, string Value)[] form) - { - var request = new HttpRequestMessage(method, path); - if (form.Length > 0) - request.Content = new FormUrlEncodedContent(form.Select(f => new KeyValuePair(f.Key, f.Value))); - return client.Exchange(request); - } - - public static Task Json(this HttpClient client, HttpMethod method, string path, JsonNode body) => - client.Exchange(new HttpRequestMessage(method, path) { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json") }); - - public static async Task Exchange(this HttpClient client, HttpRequestMessage request) - { - using (request) - { - using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); - var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - JsonNode body = default; - if (!string.IsNullOrWhiteSpace(text) && text.TrimStart()[0] is '{' or '[') - body = JsonNode.Parse(text); - return new ApiAnswer(response.StatusCode, body, text, response.Headers); - } - } - - public static async Task Status(this Mastodon account, string text, params (string Key, string Value)[] more) => - (await account.Client.Post("/api/v1/statuses", more.Prepend(("status", text)).ToArray())).Ok().Object; - - public static string Text(this JsonNode node, string property) => node?[property]?.GetValue(); - - public static int Number(this JsonNode node, string property) => node![property]!.GetValue(); - - public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue(); - - public static async Task Known(this PrivaPubHost host, RemoteActor actor) => - await host.Get().GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); - - public static string Handle(this RemoteActor actor) => $"{actor.Name}@{new Uri(actor.Id).Authority}"; - - public static string Inbox(this RemoteActor actor) => actor.Id + "/inbox"; - - public static string Origin(this RemoteActor actor) => actor.Id.Split("/users/")[0]; - - public static void WebFinger(this Peer peer, RemoteActor actor) => - peer.ServeText("/.well-known/webfinger", new JsonObject - { - ["subject"] = $"acct:{actor.Handle()}", - ["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Id }) - }.ToJsonString(), "application/jrd+json"); - - public static async Task Deliver(this PrivaPubHost host, RemoteActor actor, string path, JsonObject activity) - { - using var client = host.Client(); - using var response = await client.SendAsync(actor.SignedPost(path, activity), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - await host.RunInbox(activity["id"]!.GetValue(), TestContext.Current.CancellationToken); - } - - public static async Task FollowedBy(this PrivaPubHost host, Mastodon account, RemoteActor actor) - { - var followId = $"{actor.Origin()}/follows/{Guid.NewGuid():N}"; - await host.Deliver(actor, account.Mouth, new JsonObject - { - ["id"] = followId, - ["type"] = "Follow", - ["actor"] = actor.Id, - ["object"] = account.Uri - }); - return followId; - } - - public static JsonObject Create(this RemoteActor actor, string content, IEnumerable to, IEnumerable cc = default, - Action shape = default) - { - var noteId = $"{actor.Origin()}/notes/{Guid.NewGuid():N}"; - var note = new JsonObject - { - ["id"] = noteId, - ["type"] = "Note", - ["attributedTo"] = actor.Id, - ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()), - ["cc"] = new JsonArray((cc ?? Enumerable.Empty()).Select(c => (JsonNode)c).ToArray()), - ["content"] = content, - ["published"] = DateTime.UtcNow.ToString("O") - }; - shape?.Invoke(note); - return new JsonObject - { - ["id"] = noteId + "/activity", - ["type"] = "Create", - ["actor"] = actor.Id, - ["to"] = note["to"]!.DeepClone(), - ["cc"] = note["cc"]!.DeepClone(), - ["object"] = note - }; - } - - public static async Task PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "

a remote thought

", - Action shape = default) - { - var create = actor.Create(content, new[] { Public }, new[] { mentioned.Uri }, note => - { - note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName }); - shape?.Invoke(note); - }); - await host.Deliver(actor, mentioned.Mouth, create); - var noteId = create["object"]!["id"]!.GetValue(); - return await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken); - } - - public static async Task> Delivered(this RemoteActor actor, DateTime since) => - await Jobs.Deliveries(actor.Inbox(), since, TestContext.Current.CancellationToken); - - public static string Type(this JsonObject activity) => activity["type"]!.GetValue(); - - public static byte[] JpegWithMetadata(int width, int height) - { - using var image = (NetVips.Image.Black(width, height, bands: 3) + new double[] { 30, 140, 200 }).Cast(Enums.BandFormat.Uchar); - using var tagged = image.Mutate(m => - { - m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live"); - m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith"); - m.Set(GValue.BlobType, "xmp-data", Encoding.UTF8.GetBytes("")); - }); - return tagged.WriteToBuffer(".jpg"); - } - - public static void AssertNoMetadata(byte[] image) - { - using var loaded = NetVips.Image.NewFromBuffer(image); - var fields = loaded.GetFields(); - Assert.DoesNotContain("exif-data", fields); - Assert.DoesNotContain("xmp-data", fields); - Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd", StringComparison.Ordinal)); - } - - public static MultipartFormDataContent Multipart(params (string Name, byte[] Bytes, string ContentType, string FileName)[] files) - { - var content = new MultipartFormDataContent(); - foreach (var file in files) - { - var part = new ByteArrayContent(file.Bytes); - part.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType); - content.Add(part, file.Name, file.FileName); - } - return content; - } - - public static async Task ActivityPub(this PrivaPubHost host, string path) - { - var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.Accept.ParseAdd("application/activity+json"); - return await host.Client().Exchange(request); - } - - public static async Task Located(this PrivaPubHost host, Mastodon account, string text) - { - using var client = host.As(account.Persona.Root.Jwt); - var response = await client.PostAsJsonAsync("/clientapi/post/insert", - new { avatarId = account.Id, text, latitude = 45.07, longitude = 7.68, rangeKm = 5 }, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.True(response.IsSuccessStatusCode, body); - return JsonNode.Parse(body)!["id"]!.GetValue(); - } - - public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000"; - } -} diff --git a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs deleted file mode 100644 index 626dc97..0000000 --- a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs +++ /dev/null @@ -1,173 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -using PrivaPub.Api.Mastodon.Auth; -using PrivaPub.Domain.Media; -using PrivaPub.Infrastructure.Jobs; -using PrivaPub.Infrastructure.Statistics; - -using System.Net; - -namespace PrivaPub.Tests.Support.Host -{ - public class PrivaPubHost : WebApplicationFactory - { - public const string Host = "privapub.test"; - public const string Base = "https://" + Host; - public const string ClientHeader = "X-Test-Client"; - - static readonly SemaphoreSlim Boot = new(1, 1); - static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule), - typeof(PrivaPub.Domain.Social.FollowResender), typeof(PrivaPub.Infrastructure.Backup.BackupScheduler), - typeof(PrivaPub.Infrastructure.Backup.RestoreWatcher) }; - static PrivaPubHost _shared; - - readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}"); - - public IReadOnlyList Registered { get; private set; } - - public static async Task Shared() - { - await Boot.WaitAsync(); - try - { - if (_shared == default) - { - var host = new PrivaPubHost(); - _ = host.Services; - _shared = host; - } - return _shared; - } - finally - { - Boot.Release(); - } - } - - protected virtual IEnumerable> Settings() => new Dictionary - { - ["MongoSettings:ConnectionString"] = MongoFixture.Connection, - ["MongoSettings:Database"] = MongoFixture.Database, - ["MongoSettings:LogsDatabase"] = "logs", - ["AppConfiguration:Version"] = "0.0.0-test", - ["AppConfiguration:BackendBaseAddress"] = Base, - ["AppConfiguration:MaxAllowedUploadFiles"] = "3", - ["AppConfiguration:MaxAllowedFileSize"] = "2097152", - ["AppConfiguration:SupportedLanguages:0"] = "en", - ["AppConfiguration:SupportedLanguages:1"] = "it", - ["AppConfiguration:HashingOptions:Iterations"] = "1000", - ["AppConfiguration:Jwt:Key"] = "privapub-tests-only-signing-key-0123456789abcdef0123456789abcdef", - ["AppConfiguration:Jwt:Issuer"] = Base, - ["AppConfiguration:Jwt:Audience"] = Base, - ["AppConfiguration:Jwt:HoursTimeout"] = "1", - ["AppConfiguration:EmailConfiguration:SmtpServer"] = "127.0.0.1", - ["AppConfiguration:EmailConfiguration:SmtpPort"] = "9", - ["AppConfiguration:EmailConfiguration:UseSSL"] = "false", - ["AppConfiguration:EmailConfiguration:SmtpUsername"] = "nobody@privapub.test", - ["AppConfiguration:EmailConfiguration:SmtpPassword"] = "none", - ["Federation:AllowPrivateNetworks"] = "true", - ["Federation:AllowPlainHttp"] = "true", - ["Federation:FetchLinkPreviews"] = "false", - ["Registrations:Mode"] = "Open", - ["Statistics:Geo:AutoUpdate"] = "false", - ["Statistics:Cdn:AutoUpdate"] = "false", - ["Media:Root"] = _mediaRoot, - ["Backups:Root"] = _mediaRoot + "-backups", - ["Backups:Nightly"] = "false", - ["RateLimits:UploadsBurst"] = "1000", - ["RateLimits:ProxyBurst"] = "100000", - ["Logging:LogLevel:Default"] = "Warning", - ["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal" - }; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Testing"); - foreach (var (key, value) in Settings()) - builder.UseSetting(key, value); - builder.ConfigureTestServices(services => - { - foreach (var hosted in services.Where(s => s.ServiceType == typeof(IHostedService) && Unwanted.Contains(s.ImplementationType)).ToList()) - services.Remove(hosted); - services.AddDataProtection().UseEphemeralDataProtectionProvider(); - services.AddSingleton(); - Registered = services.ToList(); - }); - } - - protected override void ConfigureClient(HttpClient client) - { - client.BaseAddress = new Uri(Base + "/"); - client.DefaultRequestHeaders.Add(ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}"); - } - - public HttpClient Client(bool cookies = false) => CreateClient(new WebApplicationFactoryClientOptions - { - BaseAddress = new Uri(Base + "/"), - AllowAutoRedirect = false, - HandleCookies = cookies - }); - - public T Get() where T : notnull => Services.GetRequiredService(); - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (!disposing) - return; - foreach (var directory in new[] { _mediaRoot, _mediaRoot + "-backups", _mediaRoot + "-trash", _mediaRoot + "-incoming", _mediaRoot + "-proxy" }) - if (Directory.Exists(directory)) - Directory.Delete(directory, recursive: true); - } - - sealed class ClientAddressFilter : IStartupFilter - { - public Action Configure(Action next) => app => - { - app.Use(async (context, call) => - { - if (IPAddress.TryParse(context.Request.Headers[ClientHeader].ToString(), out var address)) - context.Connection.RemoteIpAddress = address; - await call(context); - }); - next(app); - }; - } - } - - public sealed class SecureModeHost : PrivaPubHost - { - static readonly SemaphoreSlim Boot = new(1, 1); - static SecureModeHost _shared; - - public static new async Task Shared() - { - await PrivaPubHost.Shared(); - await Boot.WaitAsync(); - try - { - if (_shared == default) - { - var host = new SecureModeHost(); - _ = host.Services; - _shared = host; - } - return _shared; - } - finally - { - Boot.Release(); - } - } - - protected override IEnumerable> Settings() => - base.Settings().Append(new KeyValuePair("Federation:SecureMode", "true")); - } -} diff --git a/PrivaPub.Tests/Support/MemoryLedger.cs b/PrivaPub.Tests/Support/MemoryLedger.cs deleted file mode 100644 index cdaa6c3..0000000 --- a/PrivaPub.Tests/Support/MemoryLedger.cs +++ /dev/null @@ -1,27 +0,0 @@ -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Statistics; - -using System.Collections.Concurrent; - -namespace PrivaPub.Tests.Support -{ - public sealed class MemoryLedger : IInteractionLedger - { - public ConcurrentQueue<(InteractionEvent Event, string ActorUri, bool HostClaimed)> Events { get; } = new(); - public ConcurrentDictionary<(string Host, string Key), long> Counts { get; } = new(); - public ConcurrentDictionary<(string Section, string Key), long> ServerCounts { get; } = new(); - - public long Dropped => 0; - - public void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false) => - Events.Enqueue((Interactions.Sanitize(interaction), actorUri, hostClaimed)); - - public void Count(string host, string key, long bytes = 0) => - Counts.AddOrUpdate((Interactions.Host(host), key), 1, (_, count) => count + 1); - - public void CountServer(string section, string key, int? latencyMs = default) => - ServerCounts.AddOrUpdate((section, key), 1, (_, count) => count + 1); - - public IReadOnlyList Of(string channel) => Events.Select(e => e.Event).Where(e => e.Channel == channel).ToList(); - } -} diff --git a/PrivaPub.Tests/Support/MiniMmdb.cs b/PrivaPub.Tests/Support/MiniMmdb.cs deleted file mode 100644 index c5c6267..0000000 --- a/PrivaPub.Tests/Support/MiniMmdb.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Text; - -namespace PrivaPub.Tests.Support -{ - // The smallest MaxMind DB the reader accepts (https://maxmind.github.io/MaxMind-DB/): an IPv4 search tree of one node - // whose two records both mean "no data", an empty data section, and the metadata map, which is all a test of the updater - // needs to tell a city database from an ASN one and to read its build date. - public static class MiniMmdb - { - public static byte[] Build(string databaseType, DateTime built) - { - using var output = new MemoryStream(); - const uint nodeCount = 1; - for (var record = 0; record < 2; record++) - output.Write(new byte[] { 0, 0, (byte)nodeCount });//24-bit records equal to node_count: not found - output.Write(new byte[16]);//data section separator; the data section itself is empty - output.Write(new byte[] { 0xAB, 0xCD, 0xEF }); - output.Write(Encoding.ASCII.GetBytes("MaxMind.com")); - Map(output, - ("binary_format_major_version", w => UInt16(w, 2)), - ("binary_format_minor_version", w => UInt16(w, 0)), - ("build_epoch", w => UInt64(w, (ulong)new DateTimeOffset(built).ToUnixTimeSeconds())), - ("database_type", w => Text(w, databaseType)), - ("description", w => Map(w, ("en", x => Text(x, "a test database")))), - ("ip_version", w => UInt16(w, 4)), - ("languages", w => Array(w, x => Text(x, "en"))), - ("node_count", w => UInt32(w, nodeCount)), - ("record_size", w => UInt16(w, 24))); - return output.ToArray(); - } - - // type in the top three bits (0 and an extra byte for the extended types), size below 29 in the low five, or 29 and the - // rest in one more byte (names longer than 28 characters need it) - static void Control(Stream output, int type, int size) - { - var low = size < 29 ? size : 29; - output.WriteByte((byte)((type <= 7 ? type : 0) << 5 | low)); - if (type > 7) - output.WriteByte((byte)(type - 7)); - if (size >= 29) - output.WriteByte((byte)(size - 29)); - } - - static void Text(Stream output, string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - Control(output, 2, bytes.Length); - output.Write(bytes); - } - - static void Unsigned(Stream output, int type, ulong value) - { - var bytes = BitConverter.GetBytes(value).Reverse().SkipWhile(b => b == 0).ToArray(); - Control(output, type, bytes.Length); - output.Write(bytes); - } - - static void UInt16(Stream output, ushort value) => Unsigned(output, 5, value); - static void UInt32(Stream output, uint value) => Unsigned(output, 6, value); - static void UInt64(Stream output, ulong value) => Unsigned(output, 9, value); - - static void Map(Stream output, params (string Key, Action Value)[] entries) - { - Control(output, 7, entries.Length); - foreach (var (key, value) in entries) - { - Text(output, key); - value(output); - } - } - - static void Array(Stream output, params Action[] items) - { - Control(output, 11, items.Length); - foreach (var item in items) - item(output); - } - } -} diff --git a/PrivaPub.Tests/Support/MongoFixture.cs b/PrivaPub.Tests/Support/MongoFixture.cs deleted file mode 100644 index a0b826a..0000000 --- a/PrivaPub.Tests/Support/MongoFixture.cs +++ /dev/null @@ -1,81 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; -using MongoDB.Driver; -using MongoDB.Entities; - -using PrivaPub.Infrastructure.Data; - -[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))] - -namespace PrivaPub.Tests.Support -{ - public sealed class MongoFixture : IAsyncLifetime - { - public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod"; - const int ProductionPort = 27022; - const int DefaultPort = 27017; - - public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1"; - static bool Required => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_REQUIRE_MONGOD") == "1"; - static bool InCi => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITEA_ACTIONS")); - - public static string Connection { get; } = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? $"mongodb://127.0.0.1:{DefaultPort}"; - public static string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}"; - - public async ValueTask InitializeAsync() - { - if (Required && !Enabled) - throw new InvalidOperationException("PRIVAPUB_TEST_REQUIRE_MONGOD=1 but PRIVAPUB_TEST_MONGOD is not 1: the integration tests would be skipped"); - if (!Enabled) - return; - var settings = MongoClientSettings.FromConnectionString(Connection); - Refuse(settings); - try - { - BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); - } - catch (BsonSerializationException) - { - } - await DB.InitAsync(Database, settings); - await RefuseProductionData(); - EntityMaps.Warm(); - await DB.Default.MigrateAsync(); - await Indexes.Create(); - } - - public async ValueTask DisposeAsync() - { - if (Enabled) - await DB.Default.Database().Client.DropDatabaseAsync(Database); - } - - static void Refuse(MongoClientSettings settings) - { - var ports = settings.Servers.Select(s => s.Port).ToList(); - if (ports.Contains(ProductionPort)) - throw new InvalidOperationException($"the tests never run against port {ProductionPort}: that is production's mongod"); - if (InCi && (Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") == default || ports.Contains(DefaultPort))) - throw new InvalidOperationException("in CI the tests run only against a throwaway mongod: use tools/ci/with-test-mongod.sh"); - } - - static async Task RefuseProductionData() - { - BsonDocument options; - try - { - options = await DB.Default.Database().Client.GetDatabase("admin").RunCommandAsync(new BsonDocument("getCmdLineOpts", 1)); - } - catch (MongoCommandException) - { - return; - } - var dbPath = options.GetValue("parsed", new BsonDocument()).AsBsonDocument - .GetValue("storage", new BsonDocument()).AsBsonDocument - .GetValue("dbPath", BsonNull.Value); - if (dbPath.IsString && dbPath.AsString.StartsWith("/var/lib/privapub", StringComparison.Ordinal)) - throw new InvalidOperationException($"the mongod at {Connection} keeps its data in {dbPath.AsString}: that is production's"); - } - } -} diff --git a/PrivaPub.Tests/Support/Peer.cs b/PrivaPub.Tests/Support/Peer.cs deleted file mode 100644 index 8a503ff..0000000 --- a/PrivaPub.Tests/Support/Peer.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -using PrivaPub.Federation.Moderation; -using PrivaPub.Infrastructure.Http; -using PrivaPub.Infrastructure.Statistics; -using PrivaPub.Models.Federation; - -using System.Collections.Concurrent; - -namespace PrivaPub.Tests.Support -{ - public sealed class Peer : IAsyncDisposable - { - readonly WebApplication _app; - readonly ConcurrentDictionary _documents = new(); - readonly ConcurrentDictionary _answers = new(); - readonly ConcurrentDictionary _files = new(); - readonly ConcurrentDictionary _etags = new(); - - public int Port { get; } - public string A => $"http://127.0.0.1:{Port}"; - public string B => $"http://localhost:{Port}"; - public ConcurrentQueue Requests { get; } = new(); - - Peer(WebApplication app, int port) - { - _app = app; - Port = port; - } - - public static async Task Start() - { - var builder = WebApplication.CreateSlimBuilder(); - builder.WebHost.UseUrls("http://127.0.0.1:0"); - var app = builder.Build(); - Peer peer = default; - app.Run(async context => - { - var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0 - ? await new StreamReader(context.Request.Body).ReadToEndAsync() - : default; - peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body, - context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase))); - var key = context.Request.Path.Value; - if (peer._files.TryGetValue(key, out var file)) - { - await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context); - return; - } - if (peer._answers.TryGetValue(key, out var answer)) - { - if (answer.Delay > TimeSpan.Zero) - await Task.Delay(answer.Delay); - context.Response.StatusCode = answer.Status; - return; - } - if (!peer._documents.TryGetValue(key, out var document)) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - return; - } - if (peer._etags.TryGetValue(key, out var etag)) - { - if (context.Request.Headers.IfNoneMatch.ToString() == etag) - { - context.Response.StatusCode = StatusCodes.Status304NotModified; - return; - } - context.Response.Headers.ETag = etag; - } - context.Response.ContentType = document.ContentType; - await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B)); - }); - await app.StartAsync(); - peer = new Peer(app, new Uri(app.Urls.First()).Port); - return peer; - } - - public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json"); - - // a document with an ETag: answered 304 to a request that names it in If-None-Match - public void ServeTagged(string path, string json, string etag) - { - _documents[path] = (json, "application/activity+json"); - _etags[path] = etag; - } - - public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType); - - public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType); - - public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay); - - public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default, IInteractionLedger ledger = default, - IConnectedAddresses connected = default) - { - var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }; - var services = new ServiceCollection(); - services.AddHttpClient(FederationHttp.ClientName) - .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options, connected)); - return new FederationHttp(services.BuildServiceProvider().GetRequiredService(), - cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions(options), - blocks ?? new NoBlocks(), NullLogger.Instance, ledger); - } - - public async ValueTask DisposeAsync() => await _app.DisposeAsync(); - } - - public sealed class NoBlocks : IDomainBlocks - { - public DomainBlock Find(string host) => default; - public bool IsSuspended(string host) => false; - public Task Reload(CancellationToken token) => Task.CompletedTask; - } - - public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default, - IReadOnlyDictionary Headers = default); - - public sealed class StaticOptions : IOptionsMonitor - { - public StaticOptions(T value) => CurrentValue = value; - public T CurrentValue { get; } - public T Get(string name) => CurrentValue; - public IDisposable OnChange(Action listener) => default; - } -} diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs deleted file mode 100644 index ae4ae48..0000000 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -using PrivaPub.Federation.Signing; - -using System.Globalization; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Nodes; - -namespace PrivaPub.Tests.Support -{ - public sealed class RemoteActor - { - readonly RSA _key = RSA.Create(2048); - readonly bool _namesSharedInbox; - readonly bool _hasWall; - readonly string _ed25519; - - // sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall: - // whether it has a wall (sm:wall, Smithereen's); ed25519: whether it has an Ed25519 key (FEP-521a) to prove what it - // sends (FEP-8b32) - public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false, - bool ed25519 = false) - { - _ed25519 = ed25519 ? PrivaPub.Federation.Signing.IntegrityProofs.NewSeed() : default; - Name = $"{name}{Guid.NewGuid():N}"[..20]; - Id = $"{origin ?? peer.A}/users/{Name}"; - Type = type; - _namesSharedInbox = sharedInbox; - _hasWall = wall; - peer.Serve($"/users/{Name}", Document().ToJsonString()); - } - - public string Type { get; } - - public string Name { get; } - public string Id { get; } - public string KeyId => Id + "#main-key"; - public string SharedInbox => Id.Split("/users/")[0] + "/inbox"; - public string Wall => Id + "/wall"; - - // the activity with its proof by this actor's Ed25519 key - public JsonObject Prove(JsonObject activity) - { - activity["proof"] = PrivaPub.Federation.Signing.IntegrityProofs.Create(activity, Id + "#ed25519-key", _ed25519, DateTime.UtcNow); - return activity; - } - - public JsonObject Document() - { - var document = new JsonObject - { - ["id"] = Id, - ["type"] = Type, - ["preferredUsername"] = Name, - ["inbox"] = Id + "/inbox", - ["followers"] = Id + "/followers", - ["outbox"] = Id + "/outbox", - ["featured"] = Id + "/featured", - ["publicKey"] = new JsonObject - { - ["id"] = KeyId, - ["owner"] = Id, - ["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem() - } - }; - if (_namesSharedInbox) - document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox }; - if (_hasWall) - document["wall"] = Wall; - if (_ed25519 != default) - document["assertionMethod"] = new JsonArray(new JsonObject - { - ["id"] = Id + "#ed25519-key", ["type"] = "Multikey", ["controller"] = Id, - ["publicKeyMultibase"] = PrivaPub.Federation.Signing.IntegrityProofs.Multikey(PrivaPub.Federation.Signing.IntegrityProofs.PublicKey(_ed25519)) - }); - return document; - } - - public HttpRequestMessage SignedGet(string path, string host = "privapub.test") - { - var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.TryAddWithoutValidation("Date", date); - request.Headers.TryAddWithoutValidation("Accept", "application/activity+json"); - request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date")); - return request; - } - - public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default) - { - var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); - date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var digest = HttpSignatures.Digest(body); - var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) }; - request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); - request.Headers.TryAddWithoutValidation("Date", date); - request.Headers.TryAddWithoutValidation("Digest", digest); - request.Headers.TryAddWithoutValidation("Signature", - Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest")); - return request; - } - - // the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the - // request goes - public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test") - { - var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); - var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) }; - request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); - MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body); - request.RequestUri = new Uri(origin + path); - return request; - } - - public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem(); - - public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem(); - - string Signature(string signingString, string headers) - { - var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\""; - } - - public HttpRequest Get(string host, string path) - { - var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var context = new DefaultHttpContext(); - context.Request.Method = "GET"; - context.Request.Host = new HostString(host); - context.Request.Path = path; - context.Features.Get().RawTarget = path; - context.Request.Headers["Date"] = date; - context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"); - return context.Request; - } - - public HttpRequest Post(string host, string path, JsonNode activity) - { - var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); - var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var digest = HttpSignatures.Digest(body); - var context = new DefaultHttpContext(); - context.Request.Method = "POST"; - context.Request.Host = new HostString(host); - context.Request.Path = path; - context.Features.Get().RawTarget = path; - context.Request.Headers["Date"] = date; - context.Request.Headers["Digest"] = digest; - context.Request.Headers["Content-Type"] = "application/activity+json"; - context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"); - context.Request.Body = new MemoryStream(body); - context.Request.ContentLength = body.Length; - return context.Request; - } - } -} diff --git a/PrivaPub.Tests/Web/PublicPagesTests.cs b/PrivaPub.Tests/Web/PublicPagesTests.cs deleted file mode 100644 index 8824e46..0000000 --- a/PrivaPub.Tests/Web/PublicPagesTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -using MongoDB.Entities; - -using PrivaPub.Domain.Statuses; -using PrivaPub.Federation.Actors; -using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; -using PrivaPub.Models.User; -using PrivaPub.Tests.Support; -using PrivaPub.Tests.Support.Host; - -using System.Net; - -using GroupEntity = PrivaPub.Models.Group.Group; - -namespace PrivaPub.Tests.Web -{ - [Trait("Category", "Integration")] - public sealed class PublicPagesTests : IAsyncLifetime - { - const string Csp = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; - - PrivaPubHost _host; - - public async ValueTask InitializeAsync() - { - Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); - _host = await PrivaPubHost.Shared(); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - async Task Author(string name = "author") - { - var persona = await _host.Persona(await _host.SignUp(), name); - return await _host.Get().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken); - } - - async Task Publish(LocalActor author, string text, PostVisibility visibility = PostVisibility.Public, string groupId = default) - { - using var scope = _host.Services.CreateScope(); - var outcome = await scope.ServiceProvider.GetRequiredService() - .Publish(author, new StatusDraft { Text = text, Visibility = visibility, GroupId = groupId }, TestContext.Current.CancellationToken); - Assert.True(outcome.Ok, outcome.Error); - return outcome.Post; - } - - async Task Seeded(LocalActor author, string text, PostVisibility visibility) - { - var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, ActorURI = author.Uri, Text = text, Visibility = visibility }; - post.ID = (string)post.GenerateNewID(); - post.ObjectURI = author.PostUri(post.ID); - await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); - return post; - } - - async Task<(HttpResponseMessage Response, string Body)> Page(string path, string accept = "text/html") - { - using var client = _host.Client(); - using var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.Accept.ParseAdd(accept); - var response = await client.SendAsync(request, TestContext.Current.CancellationToken); - return (response, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - } - - static void Hardened(HttpResponseMessage response) - { - Assert.Equal(Csp, Assert.Single(response.Headers.GetValues("Content-Security-Policy"))); - Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy"))); - Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options"))); - } - - [Fact] - public async Task A_profile_shows_public_and_unlisted_posts_only_behind_hardened_headers() - { - var token = TestContext.Current.CancellationToken; - var author = await Author(); - await Publish(author, "for everyone"); - await Publish(author, "unlisted but linkable", PostVisibility.Unlisted); - await Publish(author, "for followers", PostVisibility.FollowersOnly); - await Seeded(author, "for one person", PostVisibility.Direct); - await Seeded(author, "for the neighbourhood", PostVisibility.LocalGeo); - var removed = await Publish(author, "taken back"); - using (var scope = _host.Services.CreateScope()) - Assert.True((await scope.ServiceProvider.GetRequiredService().Remove(author, removed.ID, token)).Ok); - - var (response, body) = await Page($"/@{author.UserName}"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Hardened(response); - Assert.Contains("for everyone", body); - Assert.Contains("unlisted but linkable", body); - Assert.DoesNotContain("for followers", body); - Assert.DoesNotContain("for one person", body); - Assert.DoesNotContain("for the neighbourhood", body); - Assert.DoesNotContain("taken back", body); - Assert.Contains("", body); - Assert.Contains($"", body); - } - - [Fact] - public async Task A_post_page_serves_public_and_unlisted_posts_and_answers_404_for_everything_else() - { - var token = TestContext.Current.CancellationToken; - var author = await Author(); - var other = await Author("other"); - var open = await Publish(author, "an open post"); - var unlisted = await Publish(author, "an unlisted post", PostVisibility.Unlisted); - var followersOnly = await Publish(author, "a followers post", PostVisibility.FollowersOnly); - var direct = await Seeded(author, "a direct post", PostVisibility.Direct); - var located = await Seeded(author, "a located post", PostVisibility.LocalGeo); - var removed = await Publish(author, "a removed post"); - using (var scope = _host.Services.CreateScope()) - Assert.True((await scope.ServiceProvider.GetRequiredService().Remove(author, removed.ID, token)).Ok); - - var (shown, body) = await Page($"/@{author.UserName}/{open.ID}"); - Assert.Equal(HttpStatusCode.OK, shown.StatusCode); - Hardened(shown); - Assert.Contains("an open post", body); - Assert.Contains($"href=\"{author.PostUri(open.ID)}\"", body); - Assert.Equal(HttpStatusCode.OK, (await Page($"/@{author.UserName}/{unlisted.ID}")).Response.StatusCode); - - foreach (var path in new[] - { - $"/@{author.UserName}/{followersOnly.ID}", - $"/@{author.UserName}/{direct.ID}", - $"/@{author.UserName}/{located.ID}", - $"/@{author.UserName}/{removed.ID}", - $"/@{other.UserName}/{open.ID}", - $"/@{author.UserName}/not-a-post-id", - $"/@{author.UserName}/ffffffffffffffffffffffff", - $"/@nobody{Guid.NewGuid():N}"[..20] + $"/{open.ID}" - }) - { - var (response, missing) = await Page(path); - Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"{path} answered {(int)response.StatusCode}"); - Assert.DoesNotContain("a followers post", missing); - Assert.DoesNotContain("a direct post", missing); - } - } - - [Fact] - public async Task A_circle_has_no_page_a_community_has_one_and_the_instance_actor_has_none() - { - var token = TestContext.Current.CancellationToken; - var member = await Author("member"); - GroupEntity Group(GroupKind kind) => new() - { - UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), Name = $"The {kind}", Kind = kind, PostingPolicy = PostingPolicy.Followers, - Members = new() { new GroupMember { AvatarId = member.Id, Role = GroupRole.Owner } } - }; - var circle = Group(GroupKind.Circle); - var community = Group(GroupKind.Community); - await DB.Default.SaveAsync(new[] { circle, community }, token); - var inCommunity = await Publish(member, "said in the community", groupId: community.ID); - var inCircle = await Seeded(member, "said in the circle", PostVisibility.Circle); - await DB.Default.Update().MatchID(inCircle.ID).Modify(p => p.GroupId, circle.ID).ExecuteAsync(token); - - var (circlePage, circleBody) = await Page($"/@{circle.UserName}"); - var (communityPage, communityBody) = await Page($"/@{community.UserName}"); - - Assert.Equal(HttpStatusCode.NotFound, circlePage.StatusCode); - Assert.DoesNotContain("The Circle", circleBody); - Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}/{inCircle.ID}")).Response.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}", "application/activity+json")).Response.StatusCode); - Assert.Equal(HttpStatusCode.OK, communityPage.StatusCode); - Hardened(communityPage); - Assert.Contains("The Community", communityBody); - Assert.Contains("said in the community", communityBody); - Assert.Contains($"href=\"{member.PostHtmlUrl(inCommunity.ID)}\"", communityBody); - Assert.Equal(HttpStatusCode.NotFound, (await Page("/@privapub")).Response.StatusCode); - } - - [Fact] - public async Task An_activitypub_request_for_a_page_is_sent_to_the_actor_or_the_object() - { - var author = await Author(); - var open = await Publish(author, "fetch me as json"); - var followersOnly = await Publish(author, "not for strangers", PostVisibility.FollowersOnly); - - var (profile, _) = await Page($"/@{author.UserName}", "application/activity+json"); - var (post, _) = await Page($"/@{author.UserName}/{open.ID}", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); - var (hidden, _) = await Page($"/@{author.UserName}/{followersOnly.ID}", "application/activity+json"); - - Assert.Equal(HttpStatusCode.Redirect, profile.StatusCode); - Assert.Equal(author.Uri, profile.Headers.Location!.ToString()); - Assert.Equal(HttpStatusCode.Redirect, post.StatusCode); - Assert.Equal(author.PostUri(open.ID), post.Headers.Location!.ToString()); - Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode); - Assert.False(profile.Headers.Contains("Content-Security-Policy")); - } - - [Fact] - public async Task Markup_in_a_post_a_title_or_a_bio_is_escaped_never_run() - { - var token = TestContext.Current.CancellationToken; - var author = await Author(); - await DB.Default.Update().MatchID(author.Id).Modify(a => a.Biography, " hi").ExecuteAsync(token); - var rendered = await Publish(author, " **bold** "); - var legacy = await Seeded(author, "", PostVisibility.Public); - await DB.Default.Update().MatchID(legacy.ID).Modify(p => p.Title, "").ExecuteAsync(token); - - var (profile, profileBody) = await Page($"/@{author.UserName}"); - var (post, postBody) = await Page($"/@{author.UserName}/{rendered.ID}"); - var (old, oldBody) = await Page($"/@{author.UserName}/{legacy.ID}"); - - Assert.All(new[] { profile, post, old }, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); - foreach (var body in new[] { profileBody, postBody, oldBody }) - { - Assert.DoesNotContain("bold", postBody); - Assert.Contains("<script>alert(legacy)</script>", oldBody); - Assert.Contains("<script>alert(title)</script>", oldBody); - } - } -} diff --git a/PrivaPub.sln b/PrivaPub.sln index ee22584..bcff574 100644 --- a/PrivaPub.sln +++ b/PrivaPub.sln @@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub", "PrivaPub\PrivaP EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivaPub.Tests", "PrivaPub.Tests\PrivaPub.Tests.csproj", "{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,18 +41,6 @@ Global {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.Build.0 = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.ActiveCfg = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.Build.0 = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.ActiveCfg = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.Build.0 = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.ActiveCfg = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.Build.0 = Debug|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.Build.0 = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.ActiveCfg = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.Build.0 = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.ActiveCfg = Release|Any CPU - {BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5d4b7ee..8bbc0f0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -249,6 +249,7 @@ and circles (see Owner decisions). | Crawler | **On in production**, seeded with a handful of large servers of different kinds (`appsettings.Production.json`); the deploy fails if `/stargazing` does not say it is on. | | Sign-up | **Closed: invitations only.** A group invitation creates an account; open sign-up answers 403. NodeInfo, `/api/v1/instance` and `/api/v2/instance` read the same switch (`Registrations:Mode`), and the deploy fails if they disagree. The first login on a server is made from the client's setup page while no administrator exists (2026-10-16: first come, first served, no setup code); it becomes the administrator with its first persona, and setup closes for good (`ServerSetup`, kept across restores). `PrivaPub admin create-root` remains the way in from the box. | | Signed-in smoke check | **Dropped (2026-10-17).** It was `@thepra`, undiscoverable, made and kept by the deploy under the root `deploy-smoke`. The owner took `@thepra` for the administrator's login (`PrivaPub admin move-persona thepra `), `deploy-smoke` was deleted (`admin delete-root`), and the deploy keeps only its anonymous checks. | +| Test projects | **Removed (2026-10-17).** `PrivaPub.Tests` and decePubClient's `tests/e2e` were deleted, and CI and the deploy no longer run tests (they took about 22 of a release's 25 minutes on the runner). Changes are verified by building, running locally and the pasture. | | Signed fetches (SecureMode) | **On in production** once the pasture passes with it on (Phase 2 of the 2026-10-04 plan). | | Circles on Mastodon and GoToSocial | **Each member's copy names that member** in `cc`; a member's refetch names the member, an instance actor's refetch the members on its server. Nothing new is revealed to anyone outside the circle. | | What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". | diff --git a/tools/ci/with-test-mongod.sh b/tools/ci/with-test-mongod.sh deleted file mode 100755 index 5c907fc..0000000 --- a/tools/ci/with-test-mongod.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Runs a command (normally `dotnet test`) with a throwaway mongod: a fresh data directory on a random localhost port, -# stopped and deleted when the command ends. The box's own mongods (27017 shared, 27022 PrivaPub's) are never touched. -# Like production's, it is a one-member replica set (rs0), so the backup's snapshot reads are what the tests exercise. -# usage: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release -set -euo pipefail - -root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/privapub-test-mongod" -mkdir -p "$root" - -# Leftovers of a cancelled run: anything here older than two hours. -find "$root" -mindepth 1 -maxdepth 1 -type d -mmin +120 -print0 2>/dev/null | while IFS= read -r -d '' old; do - [ -f "$old/pid" ] && kill "$(cat "$old/pid")" 2>/dev/null || true - [ -f "$old/container" ] && podman rm -f "$(cat "$old/container")" >/dev/null 2>&1 || true - rm -rf "$old" -done - -listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; } - -port="" -for _ in $(seq 1 20); do - candidate=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') - case "$candidate" in 27017|27022) continue ;; esac - listening "$candidate" && continue - port=$candidate; break -done -[ -n "$port" ] || { echo "::error::no free port for the test mongod"; exit 1; } - -dir=$(mktemp -d "$root/run-XXXXXX") -cleanup() { - status=$? - [ -f "$dir/pid" ] && kill "$(cat "$dir/pid")" 2>/dev/null || true - [ -f "$dir/container" ] && podman rm -f "$(cat "$dir/container")" >/dev/null 2>&1 || true - for _ in $(seq 1 20); do listening "$port" || break; sleep 0.5; done - rm -rf "$dir" - exit $status -} -trap cleanup EXIT INT TERM - -if command -v mongod >/dev/null 2>&1; then - mkdir -p "$dir/db" - timeout 7200 mongod --dbpath "$dir/db" --port "$port" --bind_ip 127.0.0.1 --noauth \ - --wiredTigerCacheSizeGB 0.25 --replSet rs0 --quiet --logpath "$dir/mongod.log" & - echo $! > "$dir/pid" - how="mongod $(mongod --version | head -1)" -elif command -v podman >/dev/null 2>&1; then - podman run -d --rm -p "127.0.0.1:$port:27017" docker.io/library/mongo:8 --quiet --replSet rs0 > "$dir/container" - how="podman mongo:8" -else - echo "::error::neither mongod nor podman is available for the test mongod"; exit 1 -fi - -for _ in $(seq 1 60); do listening "$port" && break; sleep 0.5; done -listening "$port" || { echo "::error::the test mongod did not start"; cat "$dir/mongod.log" 2>/dev/null | tail -20; exit 1; } - -# the one-member replica set, then wait until it is its primary -shell() { - if [ -f "$dir/container" ]; then podman exec "$(cat "$dir/container")" mongosh --quiet --eval "$1" - else mongosh --quiet --port "$port" --eval "$1"; fi -} -member=$([ -f "$dir/container" ] && echo "127.0.0.1:27017" || echo "127.0.0.1:$port") -shell "rs.initiate({_id: 'rs0', members: [{_id: 0, host: '$member'}]})" >/dev/null -for _ in $(seq 1 60); do [ "$(shell 'db.hello().isWritablePrimary' 2>/dev/null)" = "true" ] && break; sleep 0.5; done -[ "$(shell 'db.hello().isWritablePrimary' 2>/dev/null)" = "true" ] || { echo "::error::the test mongod never became its replica set's primary"; exit 1; } -echo "test mongod: $how on 127.0.0.1:$port, replica set rs0" - -export PRIVAPUB_TEST_MONGOD=1 PRIVAPUB_TEST_REQUIRE_MONGOD=1 PRIVAPUB_TEST_MONGO="mongodb://127.0.0.1:$port/?directConnection=true" -"$@" diff --git a/tools/pasture/peers/decepub.sh b/tools/pasture/peers/decepub.sh index cc51ab5..23f4be5 100644 --- a/tools/pasture/peers/decepub.sh +++ b/tools/pasture/peers/decepub.sh @@ -1,9 +1,10 @@ -# decePubClient as tests/e2e/run.sh publishes it (environment Pasture: its API is https://privapub.test), served like the +# decePubClient published with the Pasture environment (its API is then https://privapub.test), served like the # production vhost (deploy/nginx in decePubClient): static files, the SPA fallback, nothing cached that must not be. -DECEPUB_SITE=${DECEPUB_SITE:-$repo/../decePubClient/tests/e2e/.publish/wwwroot} +# In decePubClient: dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish +DECEPUB_SITE=${DECEPUB_SITE:-$repo/../decePubClient/publish/wwwroot} decepub_up() { - [ -f "$DECEPUB_SITE/index.html" ] || { echo "no published decePub in $DECEPUB_SITE (decePubClient/tests/e2e/run.sh publishes it)" >&2; return 1; } + [ -f "$DECEPUB_SITE/index.html" ] || { echo "no published decePub in $DECEPUB_SITE (publish it with WasmApplicationEnvironmentName=Pasture)" >&2; return 1; } mkdir -p "$here/.state" cat > "$here/.state/decepub.Caddyfile" <<'CADDY' :80 { diff --git a/tools/pasture/town/report.py b/tools/pasture/town/report.py index 8b8b262..5f80405 100644 --- a/tools/pasture/town/report.py +++ b/tools/pasture/town/report.py @@ -169,7 +169,7 @@ def main(args): if os.path.exists(path): with open(path) as f: scenarios = [json.loads(line) for line in f if line.strip()] - # decePub's e2e cells (tests/e2e) join the servers' in the same matrix, under the observer "decepub" + # a client's cells, when any, join the servers' in the same matrix, under the observer "decepub" client = os.path.join(seed.OUT, "client.jsonl") if os.path.exists(client): import check