Compare commits

..
87 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 ab526ed291 T12, T13: Sharkey and Akkoma in the pasture, and two bugs Akkoma found
Build / Build (push) Successful in 4m51s
Deploy / privapub.thepra.dev (push) Successful in 5m2s
Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and
home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks
pass.

Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP
release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships.
Its scenario has 44 checks, which pass three runs in a row:
- follows, posts, CW, followers-only posts and the published time;
- replies, likes, boosts and their undos;
- EmojiReact both ways and withdrawn;
- DMs, polls, quotes, media, edits with history and deletes, both ways;
- unfollow, block, unblock and statistics.

The two bugs, both fixed:
- Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private
  only if an address in `to` contains "/followers" or its cc is not empty. Ours is
  /groupies, and a post mentioning nobody had an empty cc. The followers collection is now
  named in cc as well, which tells nobody anything new.
- Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's
  end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end.

Neither of these is a PrivaPub bug:
- Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice
  with to[].
- Its API never reports a remote blocker as blocked_by, so the block is read from its
  database.

Also in this commit:
- The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst,
  InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts
  limit, which back-to-back runs from one address had hit.
- A new Lemmy never sends what it queued for a server before it started that server's
  send worker, so the scenario waits for the worker before its first follow.

All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey
35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 15:24:08 +02:00
thepraandClaude Opus 5.5 aa7e43a61e T14: Lemmy 1.0 in the pasture
peers/lemmy.sh runs the Lemmy 1.0.0-beta.2 backend on the shared Postgres. It trusts
Caddy's CA through SSL_CERT_FILE and reaches the pasture through
DANGER_FEDERATION_ALLOW_LOCAL_IP. Lemmy 0.19 cannot join: its rustls trusts only its
bundled roots. LEMMY_LOG sets RUST_LOG.

scenarios/lemmy.sh drives Lemmy through its v4 API. 20 checks pass, three runs in a row:
- communities both ways;
- a titled thread each way, and alice's mention of a Lemmy community becoming a thread
  there;
- the Lemmy community's announce reaching alice's home;
- comments both ways and alice's like as an upvote;
- private messages both ways;
- statistics.

Two expected failures: votes, which Lemmy sends only through the community as
Announce{Like|Dislike}, and a moderator's removal. Both belong to P7.

What it showed, in docs/INTEROP.md:
- Lemmy 1.0 keeps its user's thread in a remote community pending until the community
  announces it back. It clears the flag before answering that echo 400 ("Object is not
  remote"). Leaving the author's server out of the Announce, as tried here, left every
  such thread pending, so GroupDistributor now says why the echo stays.
- Every bare Announce{object} is answered 400, as Lemmy answers its own compatibility
  Announce(Page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 14:24:15 +02:00
thepraandClaude Opus 5.5 e29da1b47a T12: Misskey 2026.10 in the pasture
peers/misskey.sh runs Misskey on the shared Postgres and Redis. Its config is generated with
the pasture's private networks allowed and a setup password, it trusts Caddy's CA through
NODE_EXTRA_CA_CERTS, and the first account it makes is the scenario's user. A new Misskey
federates with nobody, so the setup also turns federation on.

scenarios/misskey.sh adds 35 checks, all passing, as listed in docs/INTEROP.md. They cover
posts, CW, MFM source, replies, unicode reactions both ways and their withdrawal, likes as
reactions, legacy quotes both ways, polls, specified notes, media, deletes, unfollow,
block and statistics. MISSKEY_NAME and MISSKEY_IMAGE are there so Sharkey can reuse the peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 13:29:17 +02:00
thepraandClaude Opus 5.5 d4e9acdb79 T10: what GoToSocial had not yet been asked, and quick edits that were lost
The GoToSocial scenario gains 17 checks:
- alice's reply threads under gtsuser's post;
- gtsuser's edit arrives with edited_at and two history entries;
- unlike and unboost both ways;
- images with alt text both ways, theirs through our proxy;
- gtsuser follows a PrivaPub community and its announce brings the post;
- gtsuser's request to join a circle waits for the owner and is approved, and the
  circle post is never served unsigned;
- a locked persona holds gtsuser's follow, rejects it, then authorizes it;
- the deploy's Mastodon smoke check passes, signed in.

54 checks passed, three runs in a row. The circle post reaching gtsuser is an expected
failure: GoToSocial keeps no post addressed only to a collection it does not know.

It found a bug. An edit made within the second the post was published carries GoToSocial's
whole-second updated == published, and IsEdit wanted strictly newer, so the edit was taken
as a refresh and lost. A first edit now also counts when it is no older and the text,
warning or title actually changed. A bare refresh still never makes a revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:54:01 +02:00
thepraandClaude Opus 5.5 5f31dbd521 docs: what v1.17.0 ships and what the T/M track still has open
Build / Build (push) Successful in 4m49s
Deploy / privapub.thepra.dev (push) Successful in 5m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:30:18 +02:00
thepraandClaude Opus 5.5 e0f2eb7da7 T11: Mastodon 4.7.3 in the pasture
peers/mastodon.sh runs Mastodon's web and sidekiq containers on a shared Postgres and Redis
(peers/shared.sh). They trust Caddy's CA through SSL_CERT_FILE and reach private addresses
through ALLOWED_PRIVATE_ADDRESSES. Its users and tokens come from tootctl and rails runner.

scenarios/mastodon.sh adds 49 checks, as listed in docs/INTEROP.md: follows, posts,
replies, likes and boosts with undos, DMs, polls, FEP-044f quotes both ways, media through
the proxy, edits, deletes, locked follows, a circle request, reports, blocks and statistics.
Two are expected failures:
- inbound Block (P7);
- circle posts. Mastodon 4.7 loses the recipient of deliveries to its numeric
  /ap/users/<id>/inbox and then drops a post that names no local account. The fix on our
  side changes what a circle reveals, so it waits for the owner.

GoToSocial and Mastodon together: 86 passed, 0 failed.

The pasture now copies Caddy's root certificate reliably, readable by the peers, and
rebuilds the bundle each time. The CA directory is mounted shared (:z), because a private
:Z label locks out every container but the last. pfetch reaches PrivaPub's own https URIs
through Caddy. PRIVAPUB_ENV passes settings to PrivaPub, which scenarios/crawler.sh uses to
check the opt-in crawler against Mastodon: it visits, describes and reads the peers list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:30:05 +02:00
thepraandClaude Opus 5.5 d0a13d9cc2 M11: the planner accepts reserved test names for seeds on a test network, as the crawler does for peers
Found by the pasture's crawler scenario: a mastodon.test seed was dropped before it was ever planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:30:05 +02:00
thepraandClaude Opus 5.5 4f5df743ae M11: the opt-in crawler, PrivaPub-Stargazer, and /stargazing
Off by default (Statistics:Crawler:Enabled), as the owner decided. When it is on:
- CrawlPlan runs hourly, from StatisticsSchedule. It inserts the configured seeds and
  queues up to HostsPerHour servers whose last visit is older than RevisitDays, are not
  paused by the breaker and are not domain-blocked, spread across the hour.
- CrawlInstance visits one server at a time as
  "PrivaPub-Stargazer/<ref> (+<base>/stargazing)":
  - it reads robots.txt (RFC 9309: its own group first, then PrivaPub, then *; longest
    rule wins; a 4xx allows everything; a 5xx or no answer keeps it out);
  - it describes servers that only crawling ever found, through
    InstanceDescriber.Describe with robots.txt as the path filter;
  - it reads /api/v1/instance/peers through the new GetStringArray, which keeps what it
    read from the first 1 MB instead of refusing a large list;
  - it adds the names a server could ever be reached at as "crawled": DNS only,
    punycode, no addresses, ports or hidden services, and the reserved test names only on
    a test network. Never more than MaxNewHostsPerCrawl per visit or MaxHosts in all, and
    never over a touched server.
- It reads nothing but robots.txt, NodeInfo, the instance API and the peers list.
  IFederationHttp.GetText serves robots.txt, and HttpScope.Crawl carries the
  User-Agent.
- /stargazing explains all this and how to keep the crawler out, says whether it is on,
  and credits DB-IP. GET /clientapi/admin/statistics/crawler shows the frontier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:19:29 +02:00
thepraandClaude Opus 5.5 c8a305ae92 M8: every server we touch is described, located and snapshotted weekly
- Touches: the ledger marks a server as touched when it sends us a verified activity, when
  we exchange activities with it, or when we read its actors, keys, objects or WebFinger.
  It upserts RemoteInstance.Seen, FirstSeenAt and LastSeenAt at most hourly per server, and
  queues one DescribeInstance a week with the same dedupe key ObjectRecords uses. Suspended
  servers and pages behind link previews are never described. Migration _010 marks the
  servers already known as touched, with their dates.
- InstanceDescriber.Describe(host, crawled, allowed) reads:
  - NodeInfo 2.2/2.1/2.0, now with its published user counts, posts, comments,
    description, languages and schema version;
  - for software with a Mastodon API, /api/v2/instance falling back to v1: title,
    languages, registration mode, character limit, API version, source URL.

  It never keeps a contact as a field; the raw document is kept for the admin only. It
  locates the server from the address our connection reached (DB-IP Lite city and ASN, the
  CDN named when fronted) and writes a RemoteInstanceSnapshot per ISO week, unreachable
  weeks included. A crawled server is upserted as crawled only on insert, so it never
  downgrades a touched one, and robots.txt can deny any path.
- PublicGeo.Project is the only public form of a location: a CDN-fronted server shows its
  CDN only, a server reporting at least ten users shows its city, coordinates and network,
  any other only its country.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:07:57 +02:00
thepraandClaude Opus 5.5 fc5bb9511f T6: the Mastodon API over HTTP
88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.

Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
  with indexed and array fields_attributes (form and JSON), source[*],
  quote_policy, locked/bot, avatar and header uploads resized and stripped of
  EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
  and a circle's id answer 404); search with and without resolve (only a
  signed-in persona resolves, the Peer is untouched otherwise), by post and
  actor address, hashtags, undiscoverable personas; account statuses with
  pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
  both ways; followers-only posts for followers (local and remote authors);
  community accounts; followers/following only to their owner; follow (open,
  locked, remote Follow delivery), unfollow and Undo; follow requests from
  local and remote followers answered with the original Follow;
  remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
  with duration and the notifications choice, never federated; domain blocks;
  relationships with junk ids; a banned login's tokens; reports forwarded as a
  Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
  local and remote posts (mention, inReplyTo, the author's inbox); polls and
  votes (local, and remote votes only to the author without published); media
  attached only by its owner; quotes, the quotes list and revocation; edit
  history, source and the Update delivery; delete for redraft, the 410
  Tombstone and the Delete delivery; favourite/reblog counts with Like,
  Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
  interaction_policy matching canQuote in the note and in the Update;
  strangers get 404 for followers-only and direct posts; a located post is
  unreachable by id for anyone else on every route; statuses?id[];
  Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
  remote; tag (anonymous); list stub; favourites; conversations and read;
  markers; notifications with types[], exclude_types[], account_id, paging,
  get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
  extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
  unreadable files, video and audio made with ffmpeg lavfi sources and checked
  with ffprobe; every remote media address goes through the proxy; the proxy
  refuses unsigned URLs, streams ranges as 206 without caching, caches whole
  downloads and serves them with ranges, and streams anything over
  Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
  no signature, the trigger as activity) posts, visibility of provenance,
  instance descriptions; reading any of them makes no outbound request.
  Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
  notifications.

Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
  follower. It now sends Reject{Follow} with the stored Follow id, through
  RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
  so a post in their home timeline answered 404 to GET, context, favourite and
  reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
  personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
  drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
  addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
  Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
  post was gone; it answers 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:58:31 +02:00
thepraandClaude Opus 5.5 31d614efd4 tests: the federation surface's group helper is FederatedGroup, so it no longer clashes with the client API's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:54:57 +02:00
thepraandClaude Opus 5.5 2645dea26f T8: inbox gaps, jobs, migrations and pages; a deleted remote account's posts are hidden
Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
  post whose ActorURI is the actor (one update-many), besides dropping its follows and
  timeline rows as before. RemotePosts.Build sets it on a post stored later for an
  account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
  CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
  bookmarks, favourites, polls, reactions, search); provenance, account statuses,
  home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
  home and post/DM lists, a community's outbox and our Announces filter on IsShown or
  IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.

Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
  gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
  seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
  and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
  object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
  Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
  FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
  PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
  the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
  DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
  a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
  OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
  Referrer-Policy and nosniff), circle 404, community page, the instance actor,
  ActivityPub redirects, and markup escaped in posts, titles and bios.

Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
  Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
  the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:53:23 +02:00
thepraandClaude Opus 5.5 2cfea7b60c T7: the federation surface over HTTP
Tests through the real routes of PeasantsController, WellKnownController and
UsersController, on the whole server under test (34 new tests):

- FederationGetTests: the actor document (activity+json, SPKI key at
  #main-key owned by the actor, sharedInbox, published = PublishedOn's day,
  no creation date, no root); ld+json; browsers sent to /@name; Vary: Accept;
  /users 301; the outbox's totalItems and ?page=true&max_id paging across
  the 20-item boundary, boosts as Announces, and no followers-only, direct,
  located, federated-copy or deleted post; /groupies and /stalking naming
  nobody; /trophies (public pins, newest first) and /tattoos; /scribbles
  (public and unlisted 200, browsers redirected, followers-only, direct and
  located 404, deleted 410 Tombstone); a circle post only for a signed member
  or its instance actor; a circle's /groupies, /flock and /wardens only for
  members; /grunts create- and announce- ids; /parrot-licences 200, revoked
  410, wrong author 404; secure mode's 401 for every unsigned GET but the
  instance actor's.
- WellKnownTests: WebFinger by acct:, @-prefixed, bare, upper-case and actor
  URI; other domains, unknown names, a root's login name and no resource;
  the instance actor, a community, a circle (answered: current behaviour);
  NodeInfo links, 2.0 and 2.1 naming no root, unknown versions 404; usage
  counting only public, unlisted, non-boost local posts (Exclusive).
- InboxRouteTests: all three inboxes accept a signed delivery and refuse
  junk (400), unsigned (401), a bad Digest, a two-hour-old Date, a signature
  for another host and a swapped body (401); an unknown persona's /mouth is
  404; ld+json with the ActivityStreams profile is accepted; a signer whose
  actor answers 503 gets 503 with Retry-After; the 301st unsigned POST from
  one address is 429 while a signed server from it is not.
- PersonaSeparationHttpTests: with a sibling persona and its community on
  the same login, every GET under /api (filled with the persona's ids) plus
  search, lookup and relationships, and a crawl of everything federation
  publishes about the persona (actor, outbox pages, collections, scribbles,
  grunts, WebFinger, NodeInfo, /@ pages), never name the sibling, its
  community or the login.

Fixed:
- A circle's /groupies told anyone how many followers (members) it has,
  while its /flock and /wardens were already for members only; it now
  answers 404 to anyone but a signed member or a member's instance actor.
- WebFinger answered 404 to a bare user@domain or @user@domain resource,
  which Mastodon, GoToSocial and Pleroma all accept; it now treats them as
  acct: (noted in docs/INTEROP.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:53:23 +02:00
thepraandClaude Opus 5.5 c5e4934ba6 T5: OAuth and the client API over HTTP
96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
  verify_credentials nor the stored token entries name the root. A wrong password shows
  an error and sets no login cookie; a login without the antiforgery token is a 400; the
  return address never leaves the site; deny answers access_denied with no code; another
  root's persona re-renders the choice with no code; a banned root is sent back to the
  login and a code issued before the ban buys no token; force_login asks again; a code
  works once and its reuse revokes the token it bought; password and refresh_token
  grants are refused; a client_credentials token gets 401 on user routes; a read-only
  token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
  read:follows; revoke works; the login and authorize pages send their CSP and no-store;
  the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
  login and logout, recovery email, settings, password change, invitation sign-up and
  login (refusing a persona named after the login), recovery without an email, through
  an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
  sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
  personas and groups share ReservedName, an update delivers Update{Person} to followers,
  PublishedOn and the id's day fall within two weeks before creation, the list holds only
  one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
  members leave and owners cannot, a remote follow request becomes a member only on
  approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
  /flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
  403; reports are listed without the reporter and resolved; domain blocks are inserted,
  listed and deleted, bad domains refused, and a suspended server's delivery is answered
  202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.

Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
  re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
  when it is banned or deleted, and takes the policy claims from the database, so a
  demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
  answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
  fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
  an invalid address); a mail server failure is now 503 and an invalid address 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:53:23 +02:00
thepraandClaude Opus 5.5 c301f0c498 T9: the pasture as plugins
tools/pasture/run.sh up [peer...] | down | logs | ps and interop.sh [peer...] are now built
from parts:
- lib/pasture.sh: network, Caddy with its CA in a volume and copied to .ca/root.crt, Mongo,
  PrivaPub;
- peers/<name>.sh: each peer's <name>_up;
- lib/interop.sh: ok, ko, and xf for checks expected to fail until a later phase;
  privapub_token <persona>, which gives each peer its own persona under one root; and
  stats_check;
- scenarios/<name>.sh: each peer's checks.

GoToSocial is pinned at 0.22.1, the version the 33 checks were proven on. Its scenario
gains four statistics checks:
- the admin statistics describe gts.test as gotosocial;
- they count inbound and outbound traffic;
- they name no account.

37/37 passed three runs in a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:42:03 +02:00
thepraandClaude Opus 5.5 6ef7e2891a M10: the admin statistics API
Under /clientapi/admin/statistics, admin only (the root JWT's IsAdmin policy, like the domain
blocks; /api tokens are persona tokens):
- GET overview?days: totals per channel, inbound and outbound outcomes, the delivery
  success rate, delivery latency p50/p95 from the buckets, active and known servers,
  distinct accounts, and the ledger's written/dropped/failed counts;
- GET hosts?days&sort=volume|failures|latency|host&software&page&limit: per server
  traffic, refusals, failures, latency, accounts, software and delivery health;
- GET hosts/{host}?days: the server's description, its daily series and its last 100
  events;
- GET events?host&channel&outcome&reason&before&limit, and GET server?days for the
  ServerDay rows;
- POST rollups/{day} refolds a past day; POST hosts/{host}/describe describes a server
  again.

Days not yet rolled up, today included, are folded live from the events, so the numbers are
current. Answers that may carry locations carry the DB-IP attribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:35:40 +02:00
thepraandClaude Opus 5.5 cee85309b8 M9 (first part): the means to locate a server
- IConnectedAddresses remembers which address each host's last connection reached, set in
  SafeHttpHandlerFactory's connect callback. That is once per pooled connection, with no
  second DNS lookup, and the address is never stored.
- IGeoLocator / DbIpLocator reads the offline DB-IP Lite city and ASN databases (MaxMind
  .mmdb, via MaxMind.Db). It maps memory, swaps to new files within ten minutes, rounds
  coordinates to one decimal, never looks up a private address, and answers nothing
  when the files are missing. CdnNetworks names the CDNs whose edge addresses say nothing
  about where a server is.
- deploy/max/geo-update.sh fetches this or last month's databases, checks them and swaps
  them in atomically. The privapub-geo timer runs it monthly as www-data, and setup.sh
  installs the directory, the script, the units and a first download.

Describing servers will use these in M8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:33:30 +02:00
thepraandClaude Opus 5.5 fc15f6356d M7: daily rollups
A RollupDay job folds each finished day's InteractionEvents into one InstanceDay per server:
- Counters for the admin, keyed channel:activity:object:outcome:reason, plus signature
  schemes, audiences, local kinds and features;
- PublicCounters, everything a public page may ever read:
  - inbound and outbound activities from an allowlist, on public, unlisted or unaddressed
    traffic only, with the outcome collapsed (accepted or dropped, delivered or failed);
  - no reasons, no Flag or Block;
  - features of public objects;
  - health:ok or health:failed from reachability (a 4xx means the server answered);
- latency, wait and byte histograms, and the number of distinct accounts from that day's
  hashes.

Re-running a day replaces it and keeps the live Reads counters. The day's salt is then
deleted, so its hashes can never be recomputed, and the next day is queued.
StatisticsSchedule plans today's rollup every hour and catches up any of the last seven
days that have events but no rollup. Waits round up into their bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:30:19 +02:00
thepraandClaude Opus 5.5 bc8ce63707 docs: v1.15.1 and v1.16.0 shipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:25:10 +02:00
thepraandClaude Opus 5.5 90a7e38ce9 M6: provenance fixes, stored features, community edits that are real edits
Build / Build (push) Successful in 2m34s
Deploy / privapub.thepra.dev (push) Successful in 2m53s
- A fetched ObjectRecord no longer wears the signature, key, signed headers and @context
  of the activity that caused the fetch, and its ReceivedAt is the fetch's own time. Its
  activity fields now name the trigger. Provenance answers signature null and
  fetchedBy "instance-actor". Migration _008 clears the old fetched records.
- ObjectRecords store their ObjectFeatures extensions and context namespaces (migration
  _009 fills older records in batches), so statistics can count them. Provenance reads the
  stored lists.
- ForeignAvatar.Features records what an actor document uses (ActorFeatures): featured,
  shared inbox, FEP-521a, FEP-8b32, identity proofs, Misskey fields, indexable,
  undiscoverable, locked, key size, and more.
- RemoteEdits.Apply and RemoteDeletes.Remove are shared by the Update, Delete and Announce
  handlers. A community-wrapped Update is now an edit only when newer, refreshes polls
  and media otherwise, revises the ObjectRecord and re-resolves quotes. A
  community-wrapped Delete now tombstones the object, removes its ObjectRecord, reblogs
  and timeline rows, and lowers the reply count. Any deleted quoting post lowers the
  quoted post's quote count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:15:21 +02:00
thepraandClaude Opus 5.5 d37999970c M5: outbound requests, previews, the media proxy and served traffic
- HttpScope (AsyncLocal) tags each outbound request with a purpose and a trigger:
  - purpose is set by the caller: actor, key, object, webfinger, context, nodeinfo;
  - trigger is set by the job kind, by "verify" during inbox verification, or defaults
    to "request".
- FederationHttp records every JSON, media and stream fetch: status, time, bytes, hops,
  and an outcome of ok, refused or failed, with a reason: disallowed, remembered,
  bad-redirect, too-many-redirects, content-type, too-large, bad-json, private-address,
  timeout, network, or the status. A fetch a reader caused (trigger "request") is only
  counted per server per day.
- Link previews record a 'preview' event: card, no-card or failed.
- The media proxy counts cache hits.
- TrafficMeter counts the client API per endpoint group, method and status class. It
  counts our served documents (actor, outbox, collection, object, activity, licence,
  webfinger, nodeinfo) by kind, status and whether signed, per day and never per server,
  and never names a circle's collections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:10:42 +02:00
thepraandClaude Opus 5.5 e247001bdb M4: every delivery attempt is recorded
DeliveryJobHandler records each attempt as an 'out' event: the activity, object type and
audience read from the body (ActivityShape, shared with the inbox side), the receiving
server, the status, the time the POST took, the wait since it was queued, the bytes, the
attempt number and the signer's kind (none for circles or private traffic). The outcome is
ok, deferred (429 or 503 with Retry-After, or an open breaker: host-unavailable), retry
(5xx, 408, timeout, network), or dead: another 4xx, private-address, not-deliverable,
signer-gone, or a retry at the last attempt. Until now none of this outlived the job's
seven-day TTL, and the last error was overwritten on success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:06:24 +02:00
thepraandClaude Opus 5.5 ac7cbd136b M3: what each handler did with an activity
Arrival carries a verdict that handlers set with one line before an existing return
(Arrival.Drop, Reject, Accept, About), so no handler signature changes. InboxProcessor
records it as an 'in' event, with the time taken, the wait since the inbox accepted it, the
attempt, the audience (from the post's visibility, or else the activity's addressing), the
local actor kind, the object's age for updates, deletes and reactions, and the FEP features
of a delivered object. It also records types nothing handles (unknown-type), actors that
cannot be loaded (deferred) and handler failures.

Drop reasons: fetch-failed, unparseable, misattributed, duplicate, deleted, not-addressed,
not-followed, not-public, not-visible, not-deleted, unknown-object, unknown-recipient,
cross-origin, unsupported. Accepted sub-reasons: stored, poll-vote, edit, refresh,
actor-refresh, actor-delete, removed, tombstone-only, auto-accepted, pending,
follow-answer, quote-answer, quote-granted, undone, reaction, reported. Rejected: blocked,
ignored, quote-refused.

A circle's traffic is private and kindless, and a stranger posting into one is just
not-addressed, so the event store cannot reveal a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:04:42 +02:00
thepraandClaude Opus 5.5 4fa53f63bd M2: every inbox answer is recorded
InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json,
not-activity, missing-type-or-actor, no-signature, the signature check's own codes
(headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired,
algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable,
id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued,
duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object
type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown
/mouth and a rate-limited inbox (429, from OnRejected) are recorded too.

Until the signature verifies, the host is only claimed, so it is kept only if the server is
already known. A suspended server is recorded under its own name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:59:55 +02:00
thepraandClaude Opus 5.5 15cd034b29 M1: the interaction ledger
InteractionEvent records one interaction with a remote server: its channel (recv, in, out,
http, preview, crawl), activity and object type, outcome and reason, status, latency, wait,
bytes, attempt, audience, local actor kind, inbox, signature, features and the object's age.
IInteractionLedger.Record never blocks and never throws: events go into a bounded channel
of 10k, a full channel drops and counts, and a hosted service writes batches of up to 1000
every two seconds.

Privacy, as decided by the owner:
- no persona, root, group or activity id, inbox URL, actor URI or sender IP is stored;
- distinct accounts are counted with an HMAC keyed by a per-day salt (InteractionSalt,
  upserted so restarts agree, never created for a past day);
- the local actor kind survives only on public and unlisted traffic;
- a host claimed by an unverified sender is kept only if it is already known.

Traffic caused by reading is only counted per day (InstanceDay.Reads, ServerDay). Indexes:
a 90-day TTL on events, unique day rows, and a TTL safety net on salts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:56:13 +02:00
thepraandClaude Opus 5.5 125a49a1a0 T4: nothing answers 500
Build / Build (push) Successful in 2m24s
Deploy / privapub.thepra.dev (push) Successful in 2m39s
NeverFiveHundredTests walks every endpoint the server maps and fills each route parameter
with junk ('x', zeroed and random ObjectIds, a dot-dot segment, 5000 characters). It calls
each one anonymously, as a persona, and with a junk token, and asserts that nothing
answers 5xx or throws, and that every /api error carries a Mastodon error body.

It found two bugs:
- A junk 'Authorization: Bearer' on any non-/api route, anonymous ones included
  (/build.json, /peasants/*, inboxes), broke the response. JwtEvents.AuthenticationFailed
  wrote a body during authentication and the endpoint then wrote its own. The 401 body now
  comes from the Challenge event, which runs only when an endpoint needs a user.
- /api answered 401, 404 and 429 with no body. UseMastodonErrorBodies gives any /api error
  that leaves without a body Mastodon's {"error": ...}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:46:20 +02:00
thepraandClaude Opus 5.5 5e34517e73 T3: the whole server under test
PrivaPubHost is a WebApplicationFactory<Program> on the fixture's database, configured only
through UseSetting (visible before Build, unlike ConfigureAppConfiguration). It drops the
background workers so tests run the jobs they queue (Jobs.Run, RunInbox), gives each client
its own address for the rate limiter, and has a SecureMode variant. Accounts signs up roots,
adds personas and gets Mastodon tokens through the real /oauth code flow. RemoteActor signs
HttpRequestMessages for the real /peasants routes; Peer records bodies and headers and
serves files with ranges and text pages.

Program registers the Guid serializer with TryRegisterSerializer, so a second host in one
process starts; the fixture runs the migrations in production's order before any test.

HostBootTests: the host shares the fixture database; the harness handles exactly the
activities the server registers; every job kind has one handler; every controller and page
model can be made; the service graph validates with ValidateOnBuild and ValidateScopes;
Swagger is 404 outside Development; a persona's token never names its root; a signed DM
through the real /mouth route is queued, processed and stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:43:03 +02:00
thepraandClaude Opus 5.5 85fdff606d T2: CI runs every test against a throwaway mongod
tools/ci/with-test-mongod.sh starts mongod (or podman's mongo:8) on a random localhost port
with a temporary data directory, runs the command, and removes both. build.yml and deploy.yml
test through it, so the ~85 integration tests stop being skipped in CI. MongoFixture refuses
production's port and data directory, and in CI anything but the wrapper's mongod; with
PRIVAPUB_TEST_REQUIRE_MONGOD=1 a missing mongod fails instead of skipping.

The deploy now passes the PRIVAPUB_SMOKE_TOKEN secret to the Mastodon smoke check, so its
signed-in half runs once the owner creates the persona and the secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:37:42 +02:00
thepraandClaude Opus 5.5 00b2685cf4 T1: tests stop sharing state they don't own
- JobQueue takes an optional scope, so a test's worker leases and reaps only its own jobs.
- The dead-host delivery test runs alone (Exclusive), on its own jobs, and cleans up the
  breaker rows it trips; the breaker has tests of its own on unique hosts.
- Index and migration tests run alone: they drop indexes and rewrite every post.
- DomainBlocks.Load replaces reflection and a database-wide block in tests.
- Harness.Outgoing sees only deliveries queued since the harness started: Peer ports are
  reused within a run, which made the circle test flaky.
- Two pure-logic tests leave Mongo-gated classes, so CI runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:36:15 +02:00
thepraandClaude Opus 5.5 7c6fe80f1b docs: owner decisions on statistics, and the sweep and statistics plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:30:46 +02:00
thepraandClaude Opus 5.5 e6729c77e7 P6 done: personas can be quoted, under the owner's default of anyone, automatically
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m15s
- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
  (`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
  followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
  (the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
  marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
  that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
  PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.

Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 19:53:13 +02:00
thepraandClaude Opus 5.5 f9658a8e7a P6: remote video and audio play through the proxy
Build / Build (push) Successful in 37s
Deploy / privapub.thepra.dev (push) Successful in 57s
- The media proxy streams ranged requests from the origin (passing the range on, never caching), downloads and caches
  whole files otherwise, and serves cached files with range support. A PeerTube video is never fetched whole for one
  viewer, and clients still never contact the remote host.
- PeerTube's fragmented MP4 files inside an HLS entry are read as variants, so HLS-only instances play too.
- A remote Video or Audio post becomes one playable Mastodon attachment: the best MP4 up to 720p that carries both
  sound and picture, with its poster and duration; the card is kept only when nothing is playable.
- nginx: /media/proxy/ with proxy_buffering off and a 600 s read timeout (applied on the box, with a backup).

Checked live: a GoToSocial image through the proxy answers 206 with exactly the asked range when streamed, 200 when
cached, and 206 with the right Content-Range from the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:59:14 +02:00
thepraandClaude Opus 5.5 6f1ab0073c P6: quote posts, received and sent (FEP-044f and the older keys)
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 54s
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post
  is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent
  quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts
  and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed
  from content when the real quote is shown.
- Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until
  an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted
  the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts
  are delivered to the quoted author too.
- Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote
  policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7.

Checked live: GoToSocial's author-only quote policy is respected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:53:28 +02:00
thepraandClaude Opus 5.5 001fdac3be P6: link previews read by the server, as the owner decided
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 56s
- A public post that links to a page gets a preview card: from the post's own data first (FEP-8967 Link preview, the
  object's image, title and summary); otherwise the server reads the page once, 0-60 s after the post arrives, never
  when someone reads it. OpenGraph and Twitter tags give title, description and image; one LinkPreview per address
  is cached for 7 days and shared by the whole server, so a fetch never points at a persona.
- Lemmy link posts, which carry no title or description, get them filled in.
- The page fetch uses the guarded client (public addresses, three redirects, HTML only, first 512 KB).
- Federation:FetchLinkPreviews switches page fetching off.
- Local public posts get cards too.

Checked live: a link to a GoToSocial profile page becomes a card with its title, description and proxied image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:43:39 +02:00
thepraandClaude Opus 5.5 fdcf5176bc P6: emoji reactions in and out
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or
  `_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one
  grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose
  content is a heart stays a favourite.
- Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post
  gets a `pleroma:emoji_reaction` notification with the emoji.
- Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list),
  which sends an EmojiReact (or its Undo) to the post's author.
- Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer
  reacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:39:18 +02:00
thepraandClaude Opus 5.5 7be6749a23 P6: custom emoji, fuller remote profiles, and polls both ways
Build / Build (push) Successful in 39s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
  Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
  image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
  our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
  replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
  three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
  /api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.

Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:35:20 +02:00
thepraandClaude Opus 5.5 81de470f64 P5 done: a key we cannot fetch for now gets 503, and follow/like/block ids stay private on purpose
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m11s
- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with
  Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not
  verify yet. The fetcher's failure cache now remembers whether a failure was temporary.
- Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish
  who follows, likes and blocks whom. They are always sent with their object embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:25:18 +02:00
thepraandClaude Opus 5.5 b691c6766d P5: downvotes, private messages from Lemmy, late Creates of deleted posts, and typed details for clients
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 55s
- Dislike and its Undo are kept as downvotes (Lemmy, PieFed, Mbin, Friendica) and shown with favourites as votes.
- ChatMessage (Lemmy 0.19, Mbin, PieFed to those two) arrives as a direct message.
- A deleted object's id is remembered for 90 days, so a Create that arrives after its Delete cannot bring the post
  back; the post's ObjectRecord goes with it.
- A Join of one of our objects is answered with Ignore, as FEP-8a8e asks of a server without RSVP.
- A 503 with Retry-After is waited out like a 429 instead of counting as a failure of the host (GoToSocial throttling,
  Mastodon's temporary key failures).
- Status.privapub carries what a Mastodon Status cannot: object type, title, excerpt, cover, the author's source,
  link, video, audio and event details, and up/down votes, with every media URL proxied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:58:22 +02:00
thepraandClaude Opus 5.5 fc111d8c46 P5: remote objects are read in every shape, and videos, events, audio and links keep their own details
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
  rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
  `name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
  `preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
  `@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
  publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
  channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
  time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
  thumbnails and durations, and no media file offered as a preview image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:53:59 +02:00
thepraandClaude Opus 5.5 dcd024100a Every remote object keeps its raw form and how it reached us, for the client's details view
Build / Build (push) Successful in 36s
- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
  refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
  signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
  @context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
  someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
  used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
  links, emoji, polls, language maps, url variants, Markdown content).

Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:49:22 +02:00
thepraandClaude Opus 5.5 bb9bd71391 P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
Build / Build (push) Successful in 50s
Deploy / privapub.thepra.dev (push) Successful in 1m4s
- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
  Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
  is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
  Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
  Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
  before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:39:58 +02:00
thepraandClaude Opus 5.5 4c94254502 The owner's six decisions on what PrivaPub reveals: previews, blocks, authorship, views, bridging, reactions
Build / Build (push) Successful in 57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:32:59 +02:00
thepraandClaude Opus 5.5 829eae3ccf Interop research: every major platform's wire shapes, gotchas, and what PrivaPub still drops
Build / Build (push) Successful in 1m22s
docs/INTEROP.md collects five research passes from 2026-10-01: Mastodon 4.7 and GoToSocial 0.22, the Misskey and
Pleroma families, the threadiverse (Lemmy 0.19/1.0, PieFed, Mbin, NodeBB), media and long-form (PeerTube, Loops,
Pixelfed, WordPress, Ghost, events, audio, books, Threads, Flipboard, Bridgy Fed), and cross-cutting FEPs and
signatures. Each claim was checked against source code or live fetches, with dates and versions.

It is checked against our own code: what is already right, three cheap things that are wrong today (summary read as a
CW on every type, unchecked usernames, an undefined context term), what the rich client needs kept (a post kind with
typed payloads, raw capture and provenance for the details view), and the six privacy choices the owner has to make.

The roadmap's P5 becomes P5 to P8, ordered by that evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 14:01:57 +02:00
thepraandClaude Opus 5.5 8f4d6cbdf9 A private fediverse on the workstation: PrivaPub against a real GoToSocial
Build / Build (push) Successful in 57s
Deploy / privapub.thepra.dev (push) Successful in 1m12s
tools/pasture/run.sh starts PrivaPub, GoToSocial and Mongo on one podman network behind Caddy's internal CA, and
interop.sh drives both through their own client APIs: follows (one to a locked account), posts, CW, replies, likes,
boosts, DMs, edits, deletes and unfollow. All 25 checks pass, three fresh runs in a row.

- Federation:AcceptAnyCertificate joins the two test-network switches; startup refuses all three in Production.
- WebFinger falls back to http only when AllowPlainHttp is on.
- A bootstrap logger, so a failure before the host is built is no longer silent.
- P4 is ticked in the roadmap, with what has not been run live (Lemmy, a Mastodon circle member).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 31542a181e Remote posts take their timeline place on arrival, and an unedited one has no edit date
Found by the first live run against GoToSocial:

- A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as
  the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published
  within the last hour now gets an id for its arrival; backfill keeps its published time.
- NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in
  year one. Migration _006 clears the stored ones.
- published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in
  order within a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 51f2c10c6f Community actors name their moderators the way Lemmy reads them
Build / Build (push) Successful in 41s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
attributedTo points at /wardens and postingRestrictedToMods reflects the
posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:31:13 +02:00
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00
thepraandClaude Opus 5.5 0ccbcd558f P3 is deployed
Build / Build (push) Successful in 32s
v1.5.0 is live; the media directory, libvips and the proxy's signature
check are in place on the box. No upload has been made in production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 c00865d805 Located posts: local only, rounded, matched against their own radius
A post given a latitude and longitude becomes LocalGeo: its position is
rounded to two decimals (about a kilometre) and stored as a 2dsphere
point, its radius clamped to 1-50 km, and it is local only - no Create,
no delivery, no outbox, never in the Mastodon API or on a profile page.
/clientapi/post/nearby takes the viewer's position for the query only
(it is neither stored nor logged), runs $geoNear and keeps posts within
each post's own radius, minus authors the viewer blocks or mutes. A
located post cannot be direct or in a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 28b581b6b1 FEDERATION.md and CLAUDE.md describe media, blocks, pins and reports
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 4a713f3fb6 Media: uploads stripped of metadata, attachments both ways, a remote media proxy
- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
  images go through libvips (NetVips, its native build bundled):
  autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
  comments), capped at 4096 px, with a 640 px preview and a blurhash
  (own encoder, the reference algorithm); animated GIFs are re-encoded;
  video and audio are remuxed by ffmpeg with -map_metadata -1, never
  re-encoded, and a video gets a still preview. Files get random names
  under /var/lib/privapub/media, outside the web root deploys replace, and
  are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
  once); notes carry them as Document attachments with alt text, blurhash,
  focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
  1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
  fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
  served root, trimmed to 5 GB; foreign avatars and headers use it too, so
  a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 8f75317050 Blocks, mutes, bookmarks, pins and reports
- Blocks are per persona and never federate (a Block activity would tell
  the other server who blocked whom): the blocked account is removed as a
  follower, with a Reject{Follow} if it is remote, unfollowed, cleared from
  home and notifications, and refused with Reject if it follows again.
- Mutes (optionally timed, optionally sparing notifications) and per-
  persona domain blocks keep authors out of home timelines, notifications
  and every status list the API returns; the boosts of a hidden author's
  posts are hidden too.
- Bookmarks and pins (at most five, public or unlisted, own posts) with
  their Mastodon endpoints and flags; pinned posts are the actor's
  `featured` collection at /trophies, and `featuredTags` points at
  /tattoos.
- Reports: /api/v1/reports stores the report and, when forwarding to a
  remote account, sends Flag from the instance actor, so the reporting
  persona is never named to the other server. An inbound Flag about a local
  persona or its posts becomes a report; moderators list and resolve them
  under /clientapi/moderator/reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:13:57 +02:00
thepraandClaude Opus 5.5 525b5368a1 P2 is deployed
Build / Build (push) Successful in 39s
v1.4.0 serves OAuth and the Mastodon client API; tools/smoke/mastodon-api.sh
passes against production. The signed-in side was verified locally with two
personas of one login; no real client has logged into production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:12:28 +02:00
thepraandClaude Opus 5.5 d8f163b5ce Persona separation is tested, the API is smoke-checked on deploy, OAuth rows are pruned
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m13s
- PersonaSeparationTests: two personas of one login follow the same
  account and post; nothing the API maps for one contains the other's id,
  username or the root id.
- tools/smoke/mastodon-api.sh checks what a client meets first (instance
  v1/v2, discovery, app registration, client credentials, an app token
  refused by a user endpoint, the public timeline, revocation) and, given
  a persona token, verify_credentials, home and notifications. deploy.yml
  runs it after every deploy.
- OAuthPruner removes invalid tokens and authorizations older than two
  weeks, every six hours.
- The consent page no longer sets form-action, which browsers apply to the
  redirect back to the client after the form is posted.

CLAUDE.md gains the Mastodon API layout and invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:09:19 +02:00
thepraandClaude Opus 5.5 70b4c45eb0 The dead-host test tolerates a loaded machine
It asserted fewer than ten requests to the dead host by the time the live
one arrived, which a busy workstation can exceed without the per-host
limit being broken; the live delivery arriving at all is the point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:07:44 +02:00
thepraandClaude Opus 5.5 0028e96e76 The Mastodon client API: accounts, statuses, timelines, notifications, search
With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
  bot, discoverable, indexable, hide collections, posting defaults; the
  change federates as Update{Person}), get, lookup, statuses (paged, by
  visibility to the viewer), relationships, search, follow and unfollow,
  follow requests (authorize and reject answer remote followers with
  Accept or Reject), remove from followers. Followers and following lists
  are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
  warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
  delete returning the source for redrafting, context, history, source,
  favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
  conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
  unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.

Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.

Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:05:58 +02:00
thepraandClaude Opus 5.5 b54cdf78b2 One StatusService behind both client APIs, with outbound likes and boosts
Domain/Statuses/StatusService takes a persona, not a root, and is what
/clientapi and the Mastodon API share:
- Publish renders Markdown (clientapi) or plain text (Mastodon clients),
  checks the persona may see the post it replies to, opens or reuses the
  conversation for a direct post from its recipients and mentions, fans
  out and hands the Create to the outbox;
- Edit keeps a revision and sends Update{Note}; Remove soft-deletes and
  returns the post, so a client can delete and redraft;
- Favourite and Reblog work on anything the persona can see and send Like,
  Announce and their Undo to the author (and, for boosts, to followers);
  boosts are refused for anything but public and unlisted posts.

PostsService is now the clientapi wrapper that checks the root owns the
persona. A persona's own boost is a local row: the outbox renders it as
Announce, /grunts/announce-{id} resolves, and object endpoints, profile
pages and NodeInfo skip it. ContentFormat gains Plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:55:22 +02:00
thepraandClaude Opus 5.5 4d9be37c1c Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:52:31 +02:00
thepraandClaude Opus 5.5 e6a6fe46d7 P1.2 is deployed and verified
Build / Build (push) Successful in 27s
v1.3.0 is live with follows, timelines, notifications, likes, boosts,
edits, tombstones and thread backfill; its indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:42:16 +02:00
thepraandClaude Opus 5.5 7ff3a61e75 FEDERATION.md and CLAUDE.md describe follows, likes, boosts and timelines
Build / Build (push) Successful in 30s
Deploy / privapub.thepra.dev (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:40:02 +02:00
thepraandClaude Opus 5.5 afd2e727d0 A remote delete clears what it touched
Deleting a remote post now removes its timeline entries and the reblogs
of it and lowers its parent's reply count; deleting a remote actor also
drops the follows pointing at it and its entries in home timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:45 +02:00
thepraandClaude Opus 5.5 b34c71775c Likes and boosts arrive, count, notify, and can be taken back
- Like: a Favourite per account and post (unique), the post's counter, a
  Favourite notification for a local author; only on posts the liker
  could see (public and unlisted, or followers-only and direct when they
  were addressed).
- Announce: the original is always refetched from its own origin (or is
  ours), and must be public or unlisted. A boost of a local post counts
  and notifies; a boost by an account a persona follows is stored as a
  reblog row and fanned out to its followers' home timelines (as a reblog,
  where reblogs are wanted). A boost by a stranger of a stranger's post is
  ignored. FEP-1b12 group announces of activities wait for P4.
- Undo of a Like or an Announce reverses each of them; an Undo naming only
  an id is matched against follows, likes and boosts in turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:10 +02:00
thepraandClaude Opus 5.5 e99b76dbd7 Home timelines, mention notifications, and threads that fetch their parents
Fanout writes a TimelineEntry for every persona a post should reach: the
author, local followers of a local author, local followers of a remote one,
the members of a direct conversation or a circle. Mastodon's home rules
apply when it is written: a reply shows only to followers of both sides
(or to the one replied to), a reblog only where reblogs are wanted. A
mention of a local persona becomes a Mention notification.

Inbound posts are now also kept when a persona follows their author.

RemotePosts holds what CreateHandler and backfill share: building a Post
from a note, and fetching a public parent the first time a reply to it
arrives, so its author is known (a reply to an unknown or non-public
parent stays out of home timelines). Each fetched ancestor queues a
FetchAncestors job for the next one, up to ten deep.

/clientapi/timeline/home and /clientapi/notifications (with
/notifications/read) page by max_id for the persona's own root only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:37:41 +02:00
thepraandClaude Opus 5.5 a76cc34557 Posts reach their audience, edits follow them, deletes leave a tombstone
OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.

- Local posts take a visibility (public, unlisted, followers-only) and a
  spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
  re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
  are cleared, timeline entries removed, the parent's reply count goes
  down, Delete goes to the stored audience, and the object answers 410
  with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:34:35 +02:00
thepraandClaude Opus 5.5 61f6ca0676 Personas can follow: locally at once, remotely by Follow and Accept
Following records what a local persona follows, local or remote, with
its state and the Follow activity's id. FollowService (behind
/clientapi/follow, /clientapi/unfollow and /clientapi/following):
- resolves @user, user@host or an actor URI;
- a local account is followed in-process: accepted unless it approves
  followers by hand, a Follower row on the other side, a community gains
  the persona as a member, and a Follow or FollowRequest notification;
- a remote account gets a Follow signed by the persona, at
  /grunts/follow-{id}, and stays Requested until an Accept;
- unfollowing deletes the rows, or sends Undo{Follow} to a remote account.

Inbound Accept and Reject are matched to the Follow by its id (or, for an
embedded Follow without one, by its actor), and only from the account that
was followed. A remote Follow now notifies the persona too.

Notification is the per-persona record P1.2 builds on, deduplicated by
type, persona, sender and post. TimelineEntry and Favourite are created
with their indexes for the next commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:32:33 +02:00
thepraandClaude Opus 5.5 8af50dad9c P1.1 is deployed and verified
Build / Build (push) Successful in 33s
v1.2.0 serves the themed routes, NodeInfo 2.0 and 2.1 and the day-cut
actor; migrations 1-5 ran and the job queue's indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:31:26 +02:00
thepraandClaude Opus 5.5 60bf21b019 CLAUDE.md describes the code after P1.1
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
The route table lists the themed names now in use, the repo map gains
Jobs, Ids, Moderation, Domain and Web, and the invariants add the job
queue, VisibilityPolicy and the rule for which remote posts are stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:28:37 +02:00
thepraandClaude Opus 5.5 1c78da34a9 One visibility rule for every public read
VisibilityPolicy.IsPublic is the single expression for "anyone may see
this" (Public or Unlisted, not deleted); the outbox, the object and
activity endpoints, the HTML pages and NodeInfo all use it instead of
spelling it out. CanSee answers for a persona: the author, a mentioned
local persona, a conversation member for Direct, a circle member for
Circle; followers-only waits for P1.2's follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:26:51 +02:00
thepraandClaude Opus 5.5 5a13597c00 Persona and group ids say which day, nothing more
An ObjectId carries its creation second and a per-process counter, so two
avatars made one after the other by the same login got ids a few counts
apart: a link between personas that every Mastodon client would have
seen. Avatar and Group now generate ids from the UTC day plus eight random
bytes (still valid ObjectIds), and a remote post's id is made from its
published time with a random tail, so posts page in the order they were
written rather than the order they arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:25:48 +02:00
thepraandClaude Opus 5.5 e6a362c0b8 Domain blocks: suspend, silence, reject media
DomainBlock (domain, severity, reject-media, public and private comment)
covers the domain and its subdomains. Admins manage them under
/clientapi/admin/domainblocks/{list,insert,delete}; the set is kept in
memory, reloaded on every change and at most five minutes stale.

A suspended domain is refused by FederationHttp.IsAllowed, so nothing is
fetched from it and no job delivers to it, and the inbox drops its
activities with a 202 before fetching any key. Reject-media strips the
attachments of posts from that domain. Silence is recorded for the
timelines and notifications that arrive in P1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:25:05 +02:00
thepraandClaude Opus 5.5 9ec1f9930b Profile and post pages, NodeInfo 2.1, and FEDERATION.md
/@user and /@user/{id} are Razor pages showing an avatar's or community's
public and unlisted posts: no scripts, a strict CSP, no-referrer, noindex,
and an alternate link to the ActivityPub document. A client asking them
for activity+json is redirected to the actor or note, and the actor and
note redirect browsers here.

NodeInfo answers 2.1 as well as 2.0 (repository, homepage, a link to
FEDERATION.md) and counts only public local posts.

FEDERATION.md (FEP-67ff) lists the protocols, FEPs, route names,
activities and the security rules a peer will notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:23:03 +02:00
thepraandClaude Opus 5.5 e54e17708f Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.

Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
  indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
  FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
  attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
  bold first line, a content warning without its own text is summarised
  by the title or "Content warning"; Mention and Hashtag tags, contentMap,
  updated, and to/cc by visibility.

Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:21:31 +02:00
thepraandClaude Opus 5.5 e9529310fe One Post model for every visibility, read from notes the way peers write them
Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:17:14 +02:00
thepraandClaude Opus 5.5 796f06acf4 The inbox answers once it has verified, and processes from the job queue
InboxService is split the way the roadmap lays out Federation/Inbox:
- InboxReceiver reads and verifies the request exactly as before, runs
  the checks that need no fetch (the activity id's origin, a Follow of a
  missing or local-only actor, an Undo of someone else's activity, an
  embedded object attributed to someone else), queues a ProcessInbox job
  and answers 202. The job's dedupe key is the activity id, so a peer that
  delivers the same activity twice is processed once.
- InboxProcessor (two at a time, eight attempts) loads the verified actor
  and hands the activity to the handler for its type.
- Handlers/{Follow,Undo,Create,Delete,Update}Handler are the old methods,
  unchanged except that they no longer produce status codes; the JSON
  helpers live in Objects/ActivityJson and the group membership helpers in
  Inbox/ForeignMembers.

A slow fetch of an object or a remote actor now delays the job, not the
sender's HTTP request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:12:34 +02:00
thepraandClaude Opus 5.5 d1a91c40c4 Deliveries run on a Mongo job queue with leases, backoff and per-host limits
The single serial DeliveryWorker is replaced by Infrastructure/Jobs:
- Job rows are leased with one FindOneAndUpdate (oldest RunAt first, a
  two-minute lease) and a reaper returns expired leases every 30 s;
- enqueueing wakes the workers, which otherwise poll every five seconds;
- delivery runs eight at a time with at most two per host, so a slow or
  dead server holds two slots, not the queue;
- a failure waits n^4 + 15 + jitter seconds (Mastodon's curve) for up to
  16 attempts; a 4xx other than 408/429 is final, a 429 honours
  Retry-After;
- RemoteInstance is a per-host circuit breaker: ten consecutive failures
  quarantine a host for an hour, doubling to a week, and its jobs wait
  without spending attempts;
- a delivery is queued once per activity and inbox (unique DedupeKey), and
  finished jobs expire after seven days (TTL on FinishedAt).

Migration _004 moves pending Delivery rows into jobs and marks them
abandoned, so a rollback to the old worker cannot send them twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:09:35 +02:00
thepraandClaude Opus 5.5 934b6fe687 P0 is deployed and verified
Build / Build (push) Successful in 25s
v1.1.0 serves the actor, NodeInfo and WebFinger, answers Swagger with 404
and inbox junk with 400 and 401; migrations 1-3 ran and the unique
indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:07:23 +02:00
thepraandClaude Opus 5.5 1a62e8a567 CLAUDE.md describes the code after P0
Build / Build (push) Successful in 47s
Deploy / privapub.thepra.dev (push) Successful in 51s
The repo map gains Infrastructure/ and Objects/, the federation invariants
are the ones the code now enforces instead of a list of known holes, and
Commands, Data, Testing and Deploy cover the test project, the startup
order (warm maps, migrate, index), the admin CLI and the deploy checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:04:20 +02:00
thepraandClaude Opus 5.5 d80cd42a0c Rate limits on accounts and inboxes, a database dump before every deploy
- Sign-up, login, the invitation flows and password recovery allow ten
  requests a minute per client address; the inboxes give each sending
  origin (the keyId's) a bucket of 300 that refills at 300 a minute, and
  answer 429 beyond it, which peers retry.
- deploy.yml dumps the PrivaPub database to /var/backups before it stops
  the service (the last seven are kept), and after the swap checks that
  Swagger answers 404 and that the shared inbox answers junk with 400 and
  an unsigned activity with 401.
- ActivityPubClient and PostBoost, never used, are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:02:21 +02:00
thepraandClaude Opus 5.5 d2f0c7a14e Unique indexes, one username space, and entity maps warmed before use
Infrastructure/Data/Indexes runs at every start, after the migrations:
unique on Post/DmPost ObjectURI, ForeignAvatar ActorURI, the Follower
triple, RootToAvatar, RootUser UserName and ReservedName; plain indexes on
the lookups the services actually make (PublicKeyId, author and group
post listings, ParticipantsKey, the delivery queue).

Migration _001 runs first and removes the duplicates the races could
already have left (keeping the newest actor row, the oldest post, the
accepted follower), then fills ReservedName from every avatar and group.

ReservedName is one username space for personas, groups and the instance:
a name is reserved by an insert the unique index arbitrates, before the
avatar or group is saved, so two simultaneous sign-ups cannot both get it.
A short list of names (admin, support, abuse, postmaster, ...) is never
available.

EntityMaps.Warm touches every entity's collection one at a time before
anything else runs. MongoDB.Entities maps the Entity base class on first
touch, and two types mapped at once throw "An item with the same key has
already been added" and stay broken for the life of the process; the
parallel test run hit it, and the delivery worker racing a request could
have too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:01:08 +02:00
thepraandClaude Opus 5.5 0f85030744 No admin by username, no Swagger in production, no exception text to clients
S14 and the privacy items of P0:
- signing up as "admin" no longer grants admin; `PrivaPub admin promote
  <root>` (and `demote`) does, run on the box against the configured
  database;
- Swagger is served in Development only;
- every service and controller answers "Something went wrong." where it
  used to send ex.Message, and the SMTP warnings no longer log the
  recipient's address;
- sign-up and login no longer log the IP, User-Agent and root id together;
- invitation sign-up takes the persona's own AvatarUserName (and optional
  AvatarName) instead of naming the avatar after the private login, and
  refuses a persona username equal to the login's. Invitation login uses
  the named persona, creating it if it is new;
- recovery mail comes from "PrivaPub", not collAnon's support address name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:58:39 +02:00
thepraandClaude Opus 5.5 2eb2a63f1e Conversations are keyed by who is in them, and circles stay home
S8: a direct message joins a conversation only when its participants are
exactly that conversation's members, found through a new
DmGroup.ParticipantsKey (a hash of the sorted members). A remote context
no longer decides anything: it let anyone who knew a conversation's
context post into it, and joining by context while dropping a participant
would have shown a reply to someone it was not addressed to. A context is
kept only when it is on the author's origin. Sending a DM to the same
people again reuses their conversation instead of opening a new one.

S9: Group.Kind is Circle or Community. A circle is not a federated actor:
its actor, collections, WebFinger and inbox answer 404, a remote Follow is
refused, and posts in it are IsLocalOnly - never delivered, never in an
outbox, never served. Communities keep today's behaviour until P4.
Migration _003 makes every existing group a circle, marks their posts
local-only and backfills the conversation keys.

End-to-end inbox tests sign real deliveries from a fake peer: a context
injection, a forged activity id, a note attributed to someone else, a
cross-origin object, a bad signature, junk bodies and a Follow of a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:57:01 +02:00
thepraandClaude Opus 5.5 bf7c88ce71 Remote HTML is sanitized before it is stored
S5 of the roadmap. ContentSanitizer wraps HtmlSanitizer with Mastodon's
allowlist: the inline and list tags Mastodon keeps, href/rel/class and
the list attributes, microformat and mention/hashtag/ellipsis/invisible
classes, Mastodon's link schemes, every link rel=nofollow noopener
noreferrer, relative links unlinked, headings folded to a bold paragraph,
and the contents of script, style, svg, iframe and friends dropped rather
than kept as text.

Post and DmPost gain ContentHtml (what is shown) and ContentFormat
(Markdown for local, Html for remote). Inbound Create and Update, and a
remote actor's biography, are sanitized on the way in; local posts store
their Markdig rendering. Migration _002 does the same to what is already
stored, and migrations now run at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:54:17 +02:00
thepraandClaude Opus 5.5 611abb5857 Inbound signatures must be fresh and cover the request line and host
S6 of the roadmap. An inbox POST is refused unless its signature covers
(request-target) and host, as well as the digest and a date or (created).
The Date or (created) may be at most an hour old and fifteen minutes ahead
(it was twelve hours either way), and an (expires) in the past is refused.
The request target is read raw from the server, so a percent-encoded path
verifies as the sender signed it.

Tests cover a Mastodon-shaped delivery and each way of tampering with it,
plus a round trip of our own outbound signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:51:10 +02:00
thepraandClaude Opus 5.5 a060204dd6 A remote actor is believed only from its own origin
S1 and S2 of the roadmap. RemoteActorService:
- FetchObject accepts a document only when its id is the address it was
  served from; a same-origin document naming another address is asked for
  at that address once (how GoToSocial serves its key URIs), anything else
  is dropped;
- GetActorByKeyId accepts a key only when the actor lists it, its owner is
  the actor and it lives on the actor's origin, whether the keyId points at
  the actor or at a key document;
- a refetch for a key or an actor happens at most once per five minutes,
  so a bad signature cannot make us hammer a host;
- the cache row is written by one atomic upsert on ActorURI;
- every fetch is signed by the instance actor, never by the persona that
  happened to receive the activity.

The inbox refuses an activity whose id is not on its actor's origin, and
an Undo of someone else's activity; a Create's object, an Update and a
Delete must be on the actor's origin too, and a cross-origin object is
refetched from its own origin before it is trusted.

Tests: a fake peer on two origins serves forged actors, foreign-owned keys,
cross-origin key documents, aliases and a GoToSocial-style key address
(integration, PRIVAPUB_TEST_MONGOD=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:49:58 +02:00
thepraandClaude Opus 5.5 ccc3597699 Every outbound federation request goes through one guarded client
Infrastructure/Http adds the client the roadmap's S3 and S4 ask for:
- the connect callback resolves the name itself and refuses loopback,
  private, link-local, CGNAT, documentation, multicast, ULA, NAT64, 6to4,
  Teredo and IPv4-mapped/compatible forms, then connects to the vetted
  address, so DNS rebinding cannot swap it afterwards;
- redirects are followed by hand, at most three, each one re-checked;
- bodies are capped at 1 MB after decompression, only JSON media types are
  read, every request has a 15 s budget, and a refused URL is not asked
  again for five minutes.

Actor and WebFinger fetches and inbox deliveries all use it. Test networks
can switch on Federation:AllowPrivateNetworks/AllowPlainHttp; startup
refuses both in Production.

PrivaPub.Tests (xUnit v3) starts with the address table and the fetcher's
limits against an in-process peer; build.yml and deploy.yml run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:46:09 +02:00
thepraandClaude Opus 5.5 034b792801 Move the federation code under Federation/, namespaces only
Build / Build (push) Successful in 16s
Services/Federation and Controllers/ServerToServer become
Federation/{Actors,Signing,Inbox,Outbox,Rendering,Controllers}, the first step
of the roadmap's layout. No type, route or behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:39:41 +02:00
thepraandClaude Opus 5.5 31f6015591 CLAUDE.md, a README, and the roadmap to full ActivityPub interop
Build / Build (push) Successful in 25s
CLAUDE.md records what the project is (one private login owning unlinkable
personas, each its own actor), the deliberately odd route names (peasants,
mouth, anus, human-centipede, sniff/again, and the agreed ones still to come),
the federation and privacy invariants, the data and style conventions, and
how it deploys. docs/ROADMAP.md holds the intent reconstructed from the 2023
code, the gap audit, the owner's decisions of 2026-10-01 (Mastodon client
API, personas unlinkable to others, local-only range posts, communities and
circles), the libraries chosen, and phases P0-P5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:38:35 +02:00
333 changed files with 35814 additions and 1554 deletions

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ jobs:
- name: Build - name: Build
run: dotnet build PrivaPub.sln -c Release 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
+23
View File
@@ -12,6 +12,8 @@ env:
BACKUPS: /var/backups/privapub.thepra.dev BACKUPS: /var/backups/privapub.thepra.dev
LOCAL_URL: http://127.0.0.1:6970 LOCAL_URL: http://127.0.0.1:6970
PUBLIC_URL: https://privapub.thepra.dev PUBLIC_URL: https://privapub.thepra.dev
MONGO_URI: mongodb://127.0.0.1:27022
MONGO_DB: PrivaPub
jobs: jobs:
site: site:
@@ -26,6 +28,9 @@ jobs:
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV" echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$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 - name: Publish
run: | run: |
rm -rf "$GITHUB_WORKSPACE/publish" rm -rf "$GITHUB_WORKSPACE/publish"
@@ -52,6 +57,13 @@ jobs:
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Dump the database
run: |
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP"
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
- name: Stop, sync, start - name: Stop, sync, start
run: | run: |
sudo systemctl stop "$UNIT" sudo systemctl stop "$UNIT"
@@ -78,6 +90,8 @@ jobs:
fi fi
- name: Verify what is being served - name: Verify what is being served
env:
SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }}
run: | run: |
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))") served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; } [ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
@@ -85,4 +99,13 @@ jobs:
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$SMOKE_TOKEN"
[ -n "$SMOKE_TOKEN" ] || echo "::warning::PRIVAPUB_SMOKE_TOKEN is not set, so the signed-in API was not checked"
echo "::notice::serving $served" echo "::notice::serving $served"
+7
View File
@@ -398,3 +398,10 @@ FodyWeavers.xsd
# JetBrains Rider # JetBrains Rider
*.sln.iml *.sln.iml
PrivaPub/media-store/
PrivaPub/media-store-proxy/
tools/pasture/.publish/
.claude/worktrees/
tools/pasture/.ca/
tools/pasture/.state/
+457
View File
@@ -0,0 +1,457 @@
# CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
per-platform evidence behind phases P5 to P8: what each peer sends, what it expects, and what PrivaPub still drops.
Check the platform's section there before writing a parser or a renderer, and add to it whatever you learn.
## What this is
**PrivaPub** (repo name SocialPub) is a self-hosted ActivityPub server in C#, live at https://privapub.thepra.dev.
It is meant as a Pleroma-like microblogging server for privacy-minded people and small communities, federating with
Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
Its defining idea: **one private login owns several public personas.**
- **`RootUser` is the private login.** Username, password, optional email (used only for recovery), policies.
- **`Avatar` is a public persona.** Each one is an ActivityPub actor with its own RSA keys at `/peasants/{username}`.
Root and avatar are linked only through `RootToAvatar`.
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`);
- private notes (`RootUserNote`, `PersonalNote`).
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
server.
## Route names are deliberate
The odd names are the owner's and part of the project's character. **Never "fix" them to conventional ones.** A new
actor-scoped route gets a name in the same spirit, agreed with the owner, and a name is frozen once it has federated.
| Thing | Route |
|---|---|
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here; browsers are sent to `/@{name}`) |
| Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` (`?page=true[&max_id=]` for pages) |
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede`) |
| Followers | `/peasants/{name}/groupies` |
| Following | `/peasants/{name}/stalking` |
| Notes | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` |
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Token refresh | `/clientapi/user/sniff/again` |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
(featured tags), `/flock` and `/wardens` (group members and moderators).
Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`;
- `/api/v1/*` and `/oauth/*`;
- `/@{name}` pages;
- `/media/*`.
## Repo map
```
PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify)
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> 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
```
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
one pure-move commit at a time.
## Commands
```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 \
dotnet run # needs a local mongod on 27017; swagger at /swagger
```
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing):
```bash
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
```
## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
`HttpClient` for federation.
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
object is refetched from its own origin.
5. **Status codes:**
- bad or missing signature: **401**;
- malformed or forged body: **400**;
- accepted: **202**;
- over the rate limit: **429**;
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
6. **Actor documents:**
- served as `application/activity+json`;
- `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id;
- the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision).
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL.
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
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.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice.
## 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.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy`, which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
4. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
5. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.
## Privacy invariants
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
client** ("Something went wrong." instead).
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
- **Usernames are `Constants.UserNameRegex`** (`^[a-z0-9_]+$`): the intersection of what Mastodon and Misskey accept. A
name outside it creates a persona nobody on those servers can reach.
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar.
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.**
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
- Distinct remote accounts are counted with `ActorHash`, an HMAC keyed by that day's `InteractionSalt`, which the
day's rollup deletes.
- `LocalKind` survives `Interactions.Sanitize` only on public and unlisted traffic, so a circle can show neither by
presence nor by absence, and no reason code names one.
- Traffic a reader causes (the media proxy, lookups, the client API, fetches of our own documents) is only counted
per day (`Count`, `CountServer`), never logged per event.
- A host claimed by an unverified sender is kept only if it is already a `RemoteInstance`.
- `Record` never blocks and never throws: it writes to a bounded channel, and a full channel drops and counts.
## Data
- MongoDB through **MongoDB.Entities 25.1**, instance API: `DB.Default.Find<T>()`, `.SaveAsync(e)`, `.Update<T>()`,
`.DeleteAsync<T>()`, `.CountAsync<T>()`. `DbEntities` wraps the finds.
- **Pass no cancellation token to `DeleteAsync` outside a transaction;** v25 throws if you do.
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
- **Every stored remote object has an `ObjectRecord`** (raw JSON up to 256 KB plus its hash; delivered or fetched; the
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity
fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6).
- **An `Update` is an edit only when its `updated` is newer than ours**, or for a first edit no older and with the text
actually changed, since GoToSocial's whole-second timestamps make a quick edit carry `updated == published` (`RemoteEdits.IsEdit`; plain and community-wrapped
Updates both go through `RemoteEdits.Apply`, and deletes through `RemoteDeletes.Remove`). Otherwise it refreshes
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates.
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the
poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes`).
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
- **Quote states come from `QuoteService.Resolve`:**
- with a stamp: accepted only if `Verified` (fetched, on the quoted author's origin, naming both posts exactly);
- a FEP-044f `quote` without a stamp: pending;
- older keys only: accepted when the quoted post is public.
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's
`Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
under the post it answers, and a late arrival landed behind a client's `since_id` and was never seen. `created_at`
comes from `CreationDate`, never from the id.
- **The same ordering problem exists on the other side, so `published` carries milliseconds**
(`ActivityPubRenderer.Timestamp`). GoToSocial (ULIDs) and Mastodon (Snowflakes) derive a remote status's id from
`published` at millisecond resolution. With whole seconds, two of our posts from the same second sorted at random
there.
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
dedupe migration before it.
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
## Code style
- Tabs, block-scoped namespaces, Allman braces.
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
them.
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs`): `result.Invalidate(localizer[...], status)`.
Controllers turn it into a status code.
- Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- **`cond ? value : default` with a value-type branch is the type's default, not null:** `false`, `0`, or year one
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
`NoteParser.Int`, `NoteParser.Time`).
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
documents are read through `ActivityJson.Id`/`Value`, which handle string, object and array.
- 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
`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.
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<Program>`, 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:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
Interop is checked against real servers, starting with the workstation's own pasture:
```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up [gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy
tools/pasture/interop.sh [gts mastodon ...] # each peer's scenario, then what PrivaPub's statistics saw of it
tools/pasture/run.sh down # removes every pasture container and volume
```
- **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh`
for the Postgres and Redis several peers share), `lib/interop.sh` (`ok`, `ko`, `xf` for a check expected to fail until
a later phase, `privapub_token <persona>`, `stats_check <host> <software>`), `scenarios/<name>.sh`. Each peer talks to
its own PrivaPub persona under one root, so OAuth's persona choice is exercised too. Images are pinned.
- Caddy's CA lives in the `pasture-caddy-data` volume and is copied to `tools/pasture/.ca/root.crt` (and `bundle.pem`
with the system roots) for peers that must trust it instead of skipping verification.
- **All sites on one podman network, one Caddy in front.** `privapub.test`, `gts.test`, `mastodon.test` and the other
peers are network aliases of the Caddy container, which serves them all with its internal CA (`tls internal`).
PrivaPub accepts any certificate (`appsettings.Pasture.json`) and GoToSocial is told to skip verification
(`GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`); Mastodon trusts the copied CA through `SSL_CERT_FILE`. Peers fetch over
https only, so plain http between them is not an option.
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. A peer is reached as `https://<name>.test:6443` with
`curl -k --resolve <name>.test:6443:127.0.0.1`, because sign-in cookies are bound to the host name.
- **GoToSocial (0.22.1):**
- Its cached home timeline can stop taking new posts after its first read, its owner's own included, while a
`min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
(`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a
delete is checked as a 404 on `/api/v1/statuses/{id}`.
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
also checks our pending (`requested`) state and the manual Accept.
- 37 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; unfollow; block and unblock; statistics.
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon).
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks.
- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and
home (`/sharkey/.config`); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey`, then follows
again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks.
- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable"
zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
read from its `user_relationships`. 44 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
`tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
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, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
- **Config:** `appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
- **Secrets drift:** `AppConfigurationService` copies `AppConfiguration` into Mongo on first boot and reads the stored
copy afterwards, so changing those keys needs a Mongo update as well.
- **Logs:** `journalctl -u privapub`, plus the `logs` database on the private mongod.
+194
View File
@@ -0,0 +1,194 @@
# Federation
PrivaPub is an ActivityPub server written in C#. This document follows
[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates.
## Supported federation protocols and standards
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
- [WebFinger](https://webfinger.net/)
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
## Tested against
- **GoToSocial 0.22.1, end to end.** It runs in a private network on the workstation (`tools/pasture/`) and is driven
through its own client API. Checked both ways:
- follows, including to a locked account;
- public posts with a content warning;
- replies with notifications;
- likes and boosts;
- direct messages;
- edits and deletes;
- unfollow.
- **Mastodon, Misskey, Lemmy and PeerTube, by unit tests only.** The tests feed the parser documents written in each
server's shape. No live exchange with any of them has run yet.
## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
Planned (see `docs/ROADMAP.md`, phases P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes),
FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move),
FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys
and integrity proofs), FEP-ae0c (relays).
## Actors
Every account is an *avatar*: one private login can own several, and they are deliberately unlinkable. Nothing in an
actor document, a collection, NodeInfo or a delivery relates two avatars of the same login.
| Thing | Address |
|---|---|
| Actor (Person, Group, Application) | `/peasants/{name}` (`/users/{name}` redirects) |
| Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` |
| Shared inbox | `/human-centipede` |
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
| Objects | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` |
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{id}` |
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
personas made on the same day do not share a date. `indexable` is `false`.
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
is used to read another server's content.
## Groups
A group is either a **community** or a **circle**.
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
circle and its members collection, delivered to each member's own inbox and never announced. They are served only
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
## Activities
Received:
| Activity | Effect |
|---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags.
**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
**Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
- With a `quoteAuthorization`, the stamp is fetched and must be a `QuoteAuthorization` on the quoted author's origin,
attributed to them, naming exactly the two posts.
- A FEP-044f `quote` without a stamp is pending until an `Update` brings one.
- A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
- A `Delete` of a stamp revokes the quote.
- **Sent.** Our accounts quote with every key, plus the `RE:` fallback.
- When the quoted post states an `interactionPolicy.canQuote` that allows us, `quote` is included and a `QuoteRequest`
(with the quoting post as `instrument`) goes to the quoted author. The quote stays pending until their `Accept` brings a
stamp we can verify; the post is then updated with `quoteAuthorization`.
- Posts that state no policy are quoted the older way, without `quote`.
- Quoting posts are also delivered to the quoted author.
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
with `Reject`.
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
`Delete{stamp}`.
- Followers-only posts and DMs can never be quoted.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning".
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection.
## Link previews
For a public post that links to a page, PrivaPub builds a preview card. It uses, in order:
1. a Link attachment's own `preview` (FEP-8967) or the post's own image, title and summary;
2. otherwise, the linked page, read once by the server between 0 and 60 seconds after the post arrives (never when
someone reads it), with an `Accept: text/html` request that reads at most 512 KB, through the same address checks as
every other fetch.
The page's OpenGraph and Twitter tags give the title, description and image; the result is cached per address for 7
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
`Federation:FetchLinkPreviews=false` turns page fetching off.
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
## Security rules a peer will notice
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted
activity 202, too many requests 429. Activities are processed after the 202.
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB.
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
sent with their object embedded.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week.
## Known limitations
- Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
- Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
@@ -0,0 +1,33 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
public class DomainBlockForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(253, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Domain { get; set; }
public bool Suspend { get; set; } = true;
public bool RejectMedia { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PublicComment { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PrivateComment { get; set; }
}
public class ViewDomainBlock
{
public string Id { get; set; }
public string Domain { get; set; }
public string Severity { get; set; }
public bool RejectMedia { get; set; }
public string PublicComment { get; set; }
public string PrivateComment { get; set; }
public DateTime CreatedAt { get; set; }
}
}
+1
View File
@@ -9,5 +9,6 @@
public const int GroupInvitationLength = 64; public const int GroupInvitationLength = 64;
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$"; public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
public const string UserNameRegex = "^[a-z0-9_]+$";
} }
} }
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)), StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)), RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
NoWhiteSpaces] NoWhiteSpaces]
public string UserName { get; set; } public string UserName { get; set; }
@@ -24,6 +24,8 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
} }
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
public bool RemoveInvitationPassword { get; set; } public bool RemoveInvitationPassword { get; set; }
public bool RegenerateInvitationCode { get; set; } public bool RegenerateInvitationCode { get; set; }
public bool? IsDiscoverable { get; set; } public bool? IsDiscoverable { get; set; }
public string PostingPolicy { get; set; }
public bool? ManuallyApprovesMembers { get; set; } public bool? ManuallyApprovesMembers { get; set; }
} }
} }
+2
View File
@@ -8,6 +8,8 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string Url { get; set; } public string Url { get; set; }
public string Handle { get; set; } public string Handle { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }
public bool IsDiscoverable { get; set; } public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; } public bool IsOwner { get; set; }
@@ -10,5 +10,12 @@ namespace PrivaPub.ClientModels
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength, StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))] ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
public string InvitationCode { get; set; } public string InvitationCode { get; set; }
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarName { get; set; }
} }
} }
@@ -19,6 +19,41 @@ namespace PrivaPub.ClientModels.Post
public string GroupId { get; set; } public string GroupId { get; set; }
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
public string Visibility { get; set; }//public (default), unlisted, followersonly
[Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Latitude { get; set; }
[Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Longitude { get; set; }
[Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? RangeKm { get; set; }
}
public class UpdatePostForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PostId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Title { get; set; }
public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
} }
public class InsertDmForm public class InsertDmForm
+5
View File
@@ -8,12 +8,17 @@ namespace PrivaPub.ClientModels.Post
public string AuthorActorURI { get; set; } public string AuthorActorURI { get; set; }
public string GroupId { get; set; } public string GroupId { get; set; }
public string DmGroupId { get; set; } public string DmGroupId { get; set; }
public string Visibility { get; set; }
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Text { get; set; } public string Text { get; set; }
public string ContentHtml { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public ViewPost ReblogOf { get; set; }
public double? RangeKm { get; set; }
public double? DistanceKm { get; set; }
} }
public class ViewDmGroup public class ViewDmGroup
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
/// </summary>
public static string InvalidUserName {
get {
return ResourceManager.GetString("InvalidUserName", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required.. /// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
/// </summary> /// </summary>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve"> <data name="InvalidEmail" xml:space="preserve">
<value>{0} è invalida.</value> <value>{0} è invalida.</value>
</data> </data>
<data name="InvalidUserName" xml:space="preserve">
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
</data>
<data name="InvalidPassword" xml:space="preserve"> <data name="InvalidPassword" xml:space="preserve">
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value> <value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
</data> </data>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve"> <data name="InvalidEmail" xml:space="preserve">
<value>{0} is invalid.</value> <value>{0} is invalid.</value>
</data> </data>
<data name="InvalidUserName" xml:space="preserve">
<value>Use only lower-case letters, digits and underscores.</value>
</data>
<data name="InvalidPassword" xml:space="preserve"> <data name="InvalidPassword" xml:space="preserve">
<value>Invalid password, upper-case, lower-case and number characters required.</value> <value>Invalid password, upper-case, lower-case and number characters required.</value>
</data> </data>
@@ -0,0 +1,37 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Social
{
public class FollowForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(2048, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Target { get; set; }//user@host, @user, or an actor URI
}
public class ViewFollowing
{
public string Id { get; set; }
public string TargetActorURI { get; set; }
public string TargetAccountId { get; set; }
public bool TargetIsLocal { get; set; }
public string State { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ViewNotification
{
public string Id { get; set; }
public string Type { get; set; }
public string FromAccountId { get; set; }
public string FromActorURI { get; set; }
public string PostId { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; }
}
}
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)), StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))] Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
public string UserName { get; set; }//preferredUsername public string UserName { get; set; }//preferredUsername
+82
View File
@@ -0,0 +1,82 @@
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<MastodonParams> 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));
}
}
@@ -0,0 +1,61 @@
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);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
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("<p><strong>Rain &amp; sun</strong></p><p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_note_keeps_its_content_alone() =>
Assert.Equal("<p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_event_without_a_body_shows_its_excerpt() =>
Assert.Equal("<p><strong>Meetup</strong></p><p>Saturday &lt;10:00&gt;</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
}));
}
}
@@ -0,0 +1,91 @@
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Domain
{
public class ContentRendererTests
{
const string Base = "https://privapub.test";
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
[Fact]
public async Task Decorates_local_and_remote_mentions_and_hashtags()
{
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
Assert.Contains("<strong>now</strong>", rendered.Html);
Assert.DoesNotContain("<script>", rendered.Html);
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
Assert.Equal("fedi", Assert.Single(rendered.Tags));
}
[Theory]
[InlineData("`@alice` in code")]
[InlineData("mail me@alice.example")]
[InlineData("@nobody is unknown")]
[InlineData("see https://x.example/page#alice")]
public async Task Leaves_what_is_not_a_mention_alone(string text)
{
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
Assert.Empty(rendered.Mentions);
Assert.DoesNotContain("h-card", rendered.Html);
}
[Theory]
[InlineData("issue #123")]
[InlineData("it&#39;s fine")]
[InlineData("[link](https://x.example/#tag)")]
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
[Fact]
public async Task Renders_plain_text_like_mastodon()
{
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
Assert.Equal("<p>line one<br>line &lt;two&gt;</p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
}
sealed class StubLocalActors : ILocalActorService
{
public string BaseAddress => Base;
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
: default);
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
}
sealed class StubRemoteActors : IRemoteActorService
{
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
Task.FromResult(actorUri == "https://m.example/users/bob"
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
: default);
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class FollowTests : 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_local_follow_is_accepted_at_once_and_notified()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token);
Assert.True(result.IsValid);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Accepted, following.State);
Assert.True(following.TargetIsLocal);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted);
Assert.Equal(NotificationType.Follow, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type);
Assert.Empty(await _harness.Outgoing(bob.Inbox));
}
[Fact]
public async Task Someone_elses_persona_cannot_be_made_to_follow()
{
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
[Fact]
public async Task A_remote_follow_is_requested_then_accepted()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Requested, following.State);
var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
Assert.Equal("Follow", follow["type"]!.GetValue<string>());
Assert.Equal(alice.Uri, follow["actor"]!.GetValue<string>());
Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue<string>());
var accept = new JsonObject
{
["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}",
["type"] = "Accept",
["actor"] = carol.Id,
["object"] = follow.DeepClone()
};
Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode);
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
}
[Fact]
public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI
});
Assert.Equal(FollowState.Requested, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI
});
Assert.Null(await DB.Default.Find<Following>().OneAsync(following.ID, token));
}
[Fact]
public async Task Unfollowing_a_remote_account_sends_an_undo()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token));
var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal("Follow", undo["object"]!["type"]!.GetValue<string>());
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using PrivaPub.Domain.Content;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Domain
{
public class LinkPreviewTests
{
[Fact]
public void Reads_open_graph_then_twitter_then_the_page_title()
{
var preview = LinkPreviews.Read("https://news.example/a?utm_source=x", new Uri("https://news.example/a"), """
<html><head><title>Fallback title</title>
<meta property="og:title" content="Rain &amp; sun">
<meta name="twitter:description" content="A <b>long</b> story">
<meta property="og:image" content="/img/cover.jpg">
<meta property="og:site_name" content="News">
</head><body></body></html>
""");
Assert.Equal("Rain & sun", preview.Title);
Assert.Equal("A long story", preview.Description);
Assert.Equal("https://news.example/img/cover.jpg", preview.ImageURL);
Assert.Equal("News", preview.SiteName);
Assert.False(preview.Failed);
Assert.True(LinkPreviews.Read("https://x.example", new Uri("https://x.example"), "<html><body>nothing</body></html>").Failed);
}
[Fact]
public void Picks_the_first_real_link_and_skips_mentions_tags_quotes_and_ourselves() =>
Assert.Equal("https://blog.example/post", LinkPreviews.FirstLink(new Post
{
Url = "https://m.example/@a/1",
ContentHtml = """
<p><span class="h-card"><a href="https://m.example/@bob" class="u-url mention">@bob</a></span>
<a href="https://m.example/tags/x" class="mention hashtag" rel="tag">#x</a>
<span class="quote-inline">RE: <a href="https://k.example/notes/1">link</a></span>
<a href="https://privapub.test/@me">me</a>
<a href="https://m.example/@a/1">self</a>
<a href="https://blog.example/post">read this</a></p>
"""
}, "https://privapub.test"));
}
}
+102
View File
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using NetVips;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Media;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class MediaFlowTests : 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 byte[] Png(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
static IFormFile Upload(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
[Fact]
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, mallory) = await _harness.Persona("mallory");
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
Assert.True(upload.Ok);
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(stolen.Ok);
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.True(posted.Ok);
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
var attachment = note["attachment"]![0]!;
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
Assert.Equal(300, attachment["width"]!.GetValue<int>());
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(reused.Ok);
}
[Fact]
public async Task Unsupported_files_are_refused()
{
var (_, alice) = await _harness.Persona("alice");
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
Assert.False(outcome.Ok);
Assert.Equal(422, outcome.Status);
}
[Fact]
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
var wrapped = proxy.Wrap(_harness.Peer.A + path);
var parts = new Uri(wrapped).AbsolutePath.Split('/');
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
Assert.Equal("image/png", contentType);
Assert.True(File.Exists(file));
Assert.StartsWith(_harness.Media.ProxyRoot, file);
Assert.Null(tampered);
}
}
}
@@ -0,0 +1,72 @@
using NetVips;
using PrivaPub.Domain.Media;
namespace PrivaPub.Tests.Domain
{
public class MediaProcessingTests
{
static byte[] JpegWithMetadata(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).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.GStrType, "exif-ifd2-UserComment", "at home");
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
});
return tagged.WriteToBuffer(".jpg");
}
[Fact]
public void Uploaded_images_lose_every_kind_of_metadata()
{
var input = JpegWithMetadata(800, 600);
using (var original = Image.NewFromBuffer(input))
Assert.Contains("exif-data", original.GetFields());
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false);
using var output = Image.NewFromBuffer(processed.Bytes);
var fields = output.GetFields();
Assert.DoesNotContain("exif-data", fields);
Assert.DoesNotContain("xmp-data", fields);
Assert.DoesNotContain("iptc-data", fields);
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
Assert.Equal((800, 600), (processed.Width, processed.Height));
Assert.Equal("image/jpeg", processed.ContentType);
using var preview = Image.NewFromBuffer(processed.Preview);
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
Assert.DoesNotContain("exif-data", preview.GetFields());
}
[Fact]
public void Large_images_are_capped()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false);
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
}
[Fact]
public void A_blurhash_is_well_formed()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false);
Assert.Equal(28, processed.Blurhash.Length);
Assert.Equal('L', processed.Blurhash[0]);
}
[Fact]
public void A_white_image_encodes_white_as_its_average()
{
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
var hash = Blurhash.Encode(pixels, 16, 16);
Assert.Equal(28, hash.Length);
Assert.Equal("TSUA", hash[2..6]);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class NearbyTests : 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_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
{
var token = TestContext.Current.CancellationToken;
var latitude = 10 + Random.Shared.NextDouble() * 50;
var longitude = 10 + Random.Shared.NextDouble() * 50;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
{
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
}, token);
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
Assert.Contains(three, p => p.Id == post.ID);
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
Assert.DoesNotContain(eight, p => p.Id == post.ID);
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
}
[Fact]
public async Task A_located_post_cannot_be_direct_or_in_a_group()
{
var (root, alice) = await _harness.Persona("alice");
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
}, TestContext.Current.CancellationToken);
Assert.False(result.Ok);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class OutboxTests : 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<ForeignAvatar> Known(RemoteActor actor) =>
await _harness.Remote.GetActor(actor.Id, refresh: false, TestContext.Current.CancellationToken);
[Fact]
public async Task The_audience_follows_mastodons_table()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
var mentioned = await Known(new RemoteActor(_harness.Peer, "mentioned"));
var parentAuthor = await Known(new RemoteActor(_harness.Peer, "parent"));
await _harness.FollowedBy(alice, follower);
var mention = new PostMention { ActorURI = mentioned.ActorURI };
async Task<IReadOnlyList<string>> Audience(PostVisibility visibility, string replyTo = default) =>
await _harness.Outbox.Audience(alice, new Post { Visibility = visibility, Mentions = new() { mention }, To = new() { mentioned.ActorURI }, InReplyToAccountId = replyTo }, token);
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.Public));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL, parentAuthor.InboxURL }, await Audience(PostVisibility.Unlisted, parentAuthor.ID));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.FollowersOnly));
Assert.Equal(new[] { mentioned.InboxURL }, await Audience(PostVisibility.Direct, parentAuthor.ID));
Assert.Empty(await Audience(PostVisibility.Circle));
}
[Fact]
public async Task A_post_is_created_edited_and_deleted_for_its_audience()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "first", Visibility = "followersonly" }, token);
var postId = ((ViewPost)created.Data).Id;
await _harness.Posts.UpdatePost(root, new UpdatePostForm { AvatarId = alice.Id, PostId = postId, Text = "second", SpoilerText = "cw" }, token);
await _harness.Posts.DeletePost(root, new DeletePostForm { AvatarId = alice.Id, PostId = postId }, token);
var sent = await _harness.Outgoing(follower.SharedInbox);
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
// Pleroma reads an empty cc without a "/followers" address as a direct message
Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue<string>() == alice.Followers);
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
Assert.NotNull(sent[1]["object"]!["updated"]);
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
var stored = await DB.Default.Find<Post>().OneAsync(postId, token);
Assert.NotNull(stored.DeletedAt);
Assert.Null(stored.ContentHtml);
Assert.Empty(stored.Revisions);
}
[Fact]
public async Task Someone_elses_post_cannot_be_edited()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (otherRoot, bob) = await _harness.Persona("bob");
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "mine" }, token);
var result = await _harness.Posts.UpdatePost(otherRoot, new UpdatePostForm { AvatarId = bob.Id, PostId = ((ViewPost)created.Data).Id, Text = "yours" }, token);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class RelationshipTests : 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_cuts_follows_both_ways_and_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_blocked_remote_account_is_refused_when_it_follows()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { "Block", "Reject" }, sent.Order());
}
[Fact]
public async Task A_block_is_told_to_the_blocked_server_and_so_is_the_unblock()
{
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"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Relationships.Unblock(alice, mallory.Id, token);
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var block = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Block");
Assert.Equal(mallory.Id, block["object"]!.GetValue<string>());
Assert.Equal(alice.Uri, block["actor"]!.GetValue<string>());
var undo = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal(block["id"]!.GetValue<string>(), undo["object"]!["id"]!.GetValue<string>());
Assert.False(await DB.Default.Find<Block>().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_muted_account_stays_out_of_home_and_notifications()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
}
[Fact]
public async Task An_inbound_flag_becomes_a_report_for_moderators()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var reporter = new RemoteActor(_harness.Peer, "reporter");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
});
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(reporter.Id, report.ReporterActorURI);
Assert.Equal(new[] { post.ID }, report.PostIds);
Assert.Equal("please look", report.Comment);
}
}
}
+162
View File
@@ -0,0 +1,162 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class TimelineTests : 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<List<ViewPost>> Home(string root, string avatarId) =>
(List<ViewPost>)(await _harness.Timelines.Home(root, avatarId, default, 40, TestContext.Current.CancellationToken)).Data;
async Task Accepted(string root, string avatarId, RemoteActor target)
{
await _harness.Follows.Follow(root, new FollowForm { AvatarId = avatarId, Target = target.Id }, TestContext.Current.CancellationToken);
await DB.Default.Update<Following>().Match(f => f.AvatarId == avatarId && f.TargetActorURI == target.Id)
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(TestContext.Current.CancellationToken);
}
static JsonObject Create(RemoteActor author, string inReplyTo = default, string text = "hello", params (string Uri, string Name)[] mentions)
{
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"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(mentions.Select(m => (JsonNode)m.Uri).Prepend(author.Id + "/followers").ToArray()),
["tag"] = new JsonArray(mentions.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.Uri, ["name"] = m.Name }).ToArray())
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_local_post_reaches_the_author_and_local_followers_only()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm { AvatarId = alice.Id, Text = "for my followers" }, token);
Assert.Single(await Home(aliceRoot, alice.Id));
Assert.Equal("<p>for my followers</p>", Assert.Single(await Home(bobRoot, bob.Id)).ContentHtml);
Assert.Empty(await Home(carolRoot, carol.Id));
Assert.Equal(404, (await _harness.Timelines.Home(carolRoot, bob.Id, default, 20, token)).StatusCode);
}
[Fact]
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
await Accepted(bobRoot, bob.Id, carol);
var top = Create(carol, text: "top");
await _harness.Deliver(carol, "/human-centipede", top);
await _harness.Deliver(carol, "/human-centipede", Create(carol, top["object"]!["id"]!.GetValue<string>(), "self reply"));
await _harness.Deliver(dave, "/human-centipede", Create(dave, text: "dave alone"));
var daveRoot = Create(dave, text: "unfollowed root");
await _harness.Deliver(carol, "/human-centipede", Create(carol, $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown", "reply to a stranger"));
var home = (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml).ToList();
Assert.Contains("<p>top</p>", home);
Assert.Contains("<p>self reply</p>", home);
Assert.DoesNotContain("<p>dave alone</p>", home);
Assert.DoesNotContain("<p>reply to a stranger</p>", home);
}
[Fact]
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued()
{
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");
await Accepted(bobRoot, bob.Id, carol);
var origin = new Uri(dave.Id).GetLeftPart(UriPartial.Authority);
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}";
var parentId = $"{origin}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(parentId).AbsolutePath, new JsonObject
{
["id"] = parentId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>parent</p>",
["to"] = new JsonArray(Addressing.Public), ["inReplyTo"] = grandparentId
}.ToJsonString());
await _harness.Deliver(carol, "/human-centipede", Create(carol, parentId, "reply to dave"));
var parent = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token);
var reply = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.InReplyToURI == parentId).ExecuteSingleAsync(token);
Assert.Equal(parent.ID, reply.AnsweringToPostId);
Assert.Equal(parent.AuthorAccountId, reply.InReplyToAccountId);
Assert.True(await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token));
Assert.DoesNotContain("<p>reply to dave</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
Assert.DoesNotContain("<p>parent</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
}
[Fact]
public async Task A_remote_delete_takes_the_post_out_of_home()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await Accepted(bobRoot, bob.Id, carol);
var create = Create(carol, text: "soon gone");
await _harness.Deliver(carol, "/human-centipede", create);
Assert.Single(await Home(bobRoot, bob.Id));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = create["object"]!["id"]!.GetValue<string>(), ["type"] = "Tombstone" }
});
Assert.Empty(await Home(bobRoot, bob.Id));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == bob.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task A_mention_notifies_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Deliver(carol, "/human-centipede", Create(carol, mentions: (bob.Uri, "@" + bob.Handle)));
var notifications = (List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data;
var mention = Assert.Single(notifications);
Assert.Equal("Mention", mention.Type);
Assert.Equal(carol.Id, mention.FromActorURI);
await _harness.Timelines.MarkNotificationsRead(bobRoot, bob.Id, token);
Assert.True(((List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead);
}
}
}
@@ -0,0 +1,42 @@
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public class VisibilityPolicyTests
{
[Fact]
public async Task Who_sees_what()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var conversation = new DmGroup { Members = new() { new GroupMember { AvatarId = "alice" }, new GroupMember { AvatarId = "https://r.example/u/bob", IsForeign = true } } };
var circle = new GroupEntity { UserName = $"c{Guid.NewGuid():N}"[..20], Members = new() { new GroupMember { AvatarId = "carol" } } };
await DB.Default.SaveAsync(conversation, token);
await DB.Default.SaveAsync(circle, token);
var direct = new Post { Visibility = PostVisibility.Direct, ConversationId = conversation.ID, GroupUserId = "dave" };
var circlePost = new Post { Visibility = PostVisibility.Circle, GroupId = circle.ID };
var followersOnly = new Post { Visibility = PostVisibility.FollowersOnly, Mentions = new() { new PostMention { IsLocal = true, AccountId = "erin" } } };
var deleted = new Post { Visibility = PostVisibility.Public, DeletedAt = DateTime.UtcNow };
Assert.True(await VisibilityPolicy.CanSee(new Post(), default, token));
Assert.True(await VisibilityPolicy.CanSee(direct, "alice", token));
Assert.True(await VisibilityPolicy.CanSee(direct, "dave", token));
Assert.False(await VisibilityPolicy.CanSee(direct, "carol", token));
Assert.False(await VisibilityPolicy.CanSee(direct, default, token));
Assert.True(await VisibilityPolicy.CanSee(circlePost, "carol", token));
Assert.False(await VisibilityPolicy.CanSee(circlePost, "alice", token));
Assert.True(await VisibilityPolicy.CanSee(followersOnly, "erin", token));
Assert.False(await VisibilityPolicy.CanSee(followersOnly, "alice", token));
Assert.False(await VisibilityPolicy.CanSee(deleted, "alice", token));
}
}
}
@@ -0,0 +1,50 @@
using PrivaPub.Federation.Actors;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ActorFeaturesTests
{
[Fact]
public void A_rich_actor_reports_the_extensions_it_uses()
{
using var rsa = RSA.Create(4096);
var actor = new JsonObject
{
["id"] = "https://social.example/users/a",
["type"] = "Person",
["featured"] = "https://social.example/users/a/featured",
["alsoKnownAs"] = new JsonArray("https://old.example/users/a"),
["endpoints"] = new JsonObject { ["sharedInbox"] = "https://social.example/inbox" },
["assertionMethod"] = new JsonArray(),
["attachment"] = new JsonArray(new JsonObject { ["type"] = "PropertyValue", ["name"] = "x", ["value"] = "y" }),
["manuallyApprovesFollowers"] = true,
["discoverable"] = false,
["indexable"] = true,
["isCat"] = true,
["_misskey_summary"] = "hi",
["publicKey"] = new JsonObject { ["id"] = "https://social.example/users/a#main-key", ["publicKeyPem"] = rsa.ExportSubjectPublicKeyInfoPem() }
};
var features = ActorFeatures.Detect(JsonDocument.Parse(actor.ToJsonString()).RootElement);
Assert.Equal(new[]
{
"featured", "also-known-as", "shared-inbox", "fep-521a-assertion-method", "property-value", "misskey", "is-cat",
"indexable", "undiscoverable", "locked", "key:rsa-4096"
}, features);
}
[Fact]
public void A_bare_actor_reports_nothing_and_junk_is_survived()
{
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("{\"id\":\"https://a.example/u\",\"type\":\"Person\"}").RootElement));
Assert.Equal(new[] { "public-key-array" },
ActorFeatures.Detect(JsonDocument.Parse("{\"publicKey\":[{\"publicKeyPem\":\"not a key\"}]}").RootElement));
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("[1,2]").RootElement));
}
}
}
@@ -0,0 +1,41 @@
using PrivaPub.Federation.Actors;
using System.Text.Json;
namespace PrivaPub.Tests.Federation
{
public class ActorProfileTests
{
[Fact]
public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
{
using var json = JsonDocument.Parse("""
{
"id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
"inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
"published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
"icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
"image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
"tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
"attachment": [
{ "type": "PropertyValue", "name": "Site", "value": "<a href=\"https://ann.example\" rel=\"me\">ann.example</a><script>x</script>" },
{ "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("<script>", actor.Fields["Site"]);
}
}
}
@@ -0,0 +1,277 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
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.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
public class AuthorGoneRuleTests
{
[Fact]
public async Task A_post_whose_author_is_gone_is_neither_shown_nor_public_nor_seen_by_anyone()
{
var gone = new Post { Visibility = PostVisibility.Public, AuthorGone = true, GroupUserId = "alice" };
Assert.False(VisibilityPolicy.Shown(gone));
Assert.False(VisibilityPolicy.IsPublic.Compile()(gone));
Assert.False(await VisibilityPolicy.CanSee(gone, default, TestContext.Current.CancellationToken));
Assert.False(await VisibilityPolicy.CanSee(gone, "alice", TestContext.Current.CancellationToken));
Assert.True(VisibilityPolicy.Shown(new Post()));
Assert.False(VisibilityPolicy.Shown(new Post { DeletedAt = DateTime.UtcNow }));
Assert.False(VisibilityPolicy.Shown(default));
}
}
[Trait("Category", "Integration")]
public sealed class AuthorGoneTests : 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<Post> Delivered(RemoteActor author, JsonObject note)
{
await _harness.Deliver(author, "/human-centipede", Create(author, note));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task Deleting_itself_keeps_every_post_of_the_account_but_hides_them_and_drops_its_follows_and_rows()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var ghost = new RemoteActor(_harness.Peer, "ghost");
var friend = new RemoteActor(_harness.Peer, "friend");
await Follows(alice.Id, ghost);
await Follows(alice.Id, friend);
await _harness.FollowedBy(alice, ghost);
var post = await Delivered(ghost, PublicNote(ghost, "<p>soon gone</p>"));
var friends = await Delivered(friend, PublicNote(friend, "<p>still here</p>"));
await _harness.Deliver(ghost, "/human-centipede", new JsonObject
{
["id"] = NewId(ghost, "announces"), ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = friends.ObjectURI,
["to"] = new JsonArray(Addressing.Public)
});
var ghostAccount = await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token));
Assert.Equal(202, (await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!))).StatusCode);
var kept = await DB.Default.Find<Post>().Match(p => p.ActorURI == ghost.Id).ExecuteAsync(token);
Assert.Equal(2, kept.Count);
Assert.All(kept, p => Assert.True(p.AuthorGone));
Assert.Contains(kept, p => p.ID == post.ID && p.ContentHtml == "<p>soon gone</p>");
Assert.Contains(kept, p => p.ReblogOfPostId == friends.ID);
Assert.False((await DB.Default.Find<Post>().OneAsync(friends.ID, token)).AuthorGone);
Assert.All(kept, p => Assert.False(VisibilityPolicy.Shown(p)));
foreach (var hidden in kept)
Assert.False(await VisibilityPolicy.CanSee(hidden, alice.Id, token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == ghost.Id).Match(VisibilityPolicy.IsPublic).ExecuteAnyAsync(token));
Assert.Equal(AvatarAccountState.Deleted, (await DB.Default.Find<ForeignAvatar>().OneAsync(ghostAccount.ID, token)).AccountState);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.ActorURI == ghost.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Following>().Match(f => f.TargetActorURI == ghost.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token));
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
Assert.Empty(await mapper.Statuses(kept, alice.Id, token));
var home = (List<PrivaPub.ClientModels.Post.ViewPost>)(await _harness.Timelines.Home(root, alice.Id, default, 40, token)).Data;
Assert.DoesNotContain(home, v => v.AuthorActorURI == ghost.Id);
Assert.Contains(home, v => v.Id == friends.ID);
}
[Fact]
public async Task A_post_by_an_account_already_gone_is_hidden_from_the_moment_it_is_stored()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ghost = new RemoteActor(_harness.Peer, "ghost");
await _harness.FollowedBy(alice, ghost);
await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!));
var late = PublicNote(ghost, "<p>from beyond</p>");
_harness.Peer.Serve(new Uri(IdOf(late)).AbsolutePath, late.ToJsonString());
var stored = await _harness.RemotePosts.StoreContext(IdOf(late), 0, token);
Assert.NotNull(stored);
Assert.True(stored.AuthorGone);
Assert.False(await VisibilityPolicy.CanSee(stored, alice.Id, token));
}
}
[Trait("Category", "Integration")]
public sealed class AuthorGoneOverHttpTests : 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 async Task<(HttpStatusCode Status, JsonNode Body)> Get(HttpClient client, string path)
{
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
return (response.StatusCode, string.IsNullOrEmpty(text) || response.Content.Headers.ContentType?.MediaType?.Contains("json") != true ? default : JsonNode.Parse(text));
}
static async Task<List<string>> Ids(HttpClient client, string path)
{
var (status, body) = await Get(client, path);
Assert.Equal(HttpStatusCode.OK, status);
return body!.AsArray().Select(s => IdOf(s!)).ToList();
}
static async Task<bool> Found(HttpClient client, string path) => (await Get(client, path)).Status == HttpStatusCode.OK;
sealed record Seen(bool Status, bool Context, bool FavouritedBy, bool RebloggedBy, bool History, bool Many, bool Boost, bool InAncestors,
bool BoostersListed, bool OnProfile, bool OnTag, bool OnPublic, bool OnHome, bool Notified, bool InConversation, bool Searched,
bool Favourited, bool Bookmarked, bool Provenance, bool InCommunityOutbox);
[Fact]
public async Task Every_lookup_treats_a_post_whose_author_deleted_itself_as_not_found()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "reader");
var bearer = await _host.MastodonToken(persona);
using var client = _host.As(bearer);
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var community = new GroupEntity
{
UserName = $"commons{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community, PostingPolicy = PostingPolicy.Anyone,
PrivateKey = privateKey, PublicKey = publicKey, Members = new() { new GroupMember { AvatarId = persona.Id, Role = GroupRole.Owner } }
};
await DB.Default.SaveAsync(community, token);
var personaUri = $"{PrivaPubHost.Base}/peasants/{persona.UserName}";
var communityUri = $"{PrivaPubHost.Base}/peasants/{community.UserName}";
var ghost = new RemoteActor(_peer, "ghost");
var friend = new RemoteActor(_peer, "friend");
await Follows(persona.Id, ghost);
await Follows(persona.Id, friend);
var tag = $"gone{Guid.NewGuid():N}"[..16];
var note = PublicNote(ghost, $"<p>hello #{tag}</p>", personaUri, communityUri);
note["tag"] = new JsonArray(
new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" },
new JsonObject { ["type"] = "Hashtag", ["name"] = "#" + tag, ["href"] = $"{Origin(ghost)}/tags/{tag}" });
Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Create(ghost, note)));
var friendsNote = PublicNote(friend, "<p>a friend's post</p>");
await DeliverSigned(_host, friend, Create(friend, friendsNote));
var reply = PublicNote(friend, "<p>a reply</p>");
reply["inReplyTo"] = IdOf(note);
await DeliverSigned(_host, friend, Create(friend, reply));
await DeliverSigned(_host, friend, new JsonObject
{
["id"] = NewId(friend, "announces"), ["type"] = "Announce", ["actor"] = friend.Id, ["object"] = IdOf(note), ["to"] = new JsonArray(Addressing.Public)
});
var ghostBoostId = NewId(ghost, "announces");
await DeliverSigned(_host, ghost, new JsonObject
{
["id"] = ghostBoostId, ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = IdOf(friendsNote), ["to"] = new JsonArray(Addressing.Public)
});
var dm = new JsonObject
{
["id"] = NewId(ghost, "notes"), ["type"] = "Note", ["attributedTo"] = ghost.Id, ["content"] = "<p>psst</p>", ["to"] = new JsonArray(personaUri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" })
};
await DeliverSigned(_host, ghost, Create(ghost, dm));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
var friends = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(friendsNote)).ExecuteSingleAsync(token);
var replyPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteSingleAsync(token);
var ghostBoost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == ghostBoostId).ExecuteSingleAsync(token);
var dmPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(dm)).ExecuteSingleAsync(token);
var ghostAccount = await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token);
Assert.Equal(community.ID, post.GroupId);
//search looks up https addresses only, which the peer does not have
var searchable = new Post
{
ObjectURI = $"https://ghost{Guid.NewGuid():N}.example/notes/1", ActorURI = ghost.Id, AuthorAccountId = ghostAccount.ID, IsFederatedCopy = true,
Visibility = PostVisibility.Public, ContentHtml = "<p>found by its address</p>"
};
await DB.Default.SaveAsync(searchable, token);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/favourite", default, token)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/bookmark", default, token)).StatusCode);
async Task<Seen> Look()
{
var home = (await Get(client, "/api/v1/timelines/home?limit=40")).Body!.AsArray();
var notifications = (await Get(client, "/api/v1/notifications?limit=30")).Body!.AsArray();
var conversations = (await Get(client, "/api/v1/conversations")).Body!.AsArray();
var search = (await Get(client, $"/api/v2/search?type=statuses&q={Uri.EscapeDataString(searchable.ObjectURI)}")).Body!;
using var anonymous = _host.Client();
using var outboxRequest = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{community.UserName}/anus?page=true");
outboxRequest.Headers.Accept.ParseAdd("application/activity+json");
var outbox = JsonNode.Parse(await (await anonymous.SendAsync(outboxRequest, token)).Content.ReadAsStringAsync(token))!;
return new Seen(
await Found(client, $"/api/v1/statuses/{post.ID}"),
await Found(client, $"/api/v1/statuses/{post.ID}/context"),
await Found(client, $"/api/v1/statuses/{post.ID}/favourited_by"),
await Found(client, $"/api/v1/statuses/{post.ID}/reblogged_by"),
await Found(client, $"/api/v1/statuses/{post.ID}/history"),
(await Ids(client, $"/api/v1/statuses?id[]={post.ID}")).Contains(post.ID),
await Found(client, $"/api/v1/statuses/{ghostBoost.ID}"),
(await Get(client, $"/api/v1/statuses/{replyPost.ID}/context")).Body!["ancestors"]!.AsArray().Any(a => IdOf(a!) == post.ID),
(await Ids(client, $"/api/v1/statuses/{friends.ID}/reblogged_by")).Contains(ghostAccount.ID),
(await Ids(client, $"/api/v1/accounts/{ghostAccount.ID}/statuses")).Count > 0,
(await Ids(client, $"/api/v1/timelines/tag/{tag}")).Contains(post.ID),
(await Ids(client, $"/api/v1/timelines/public?remote=true&limit=1&max_id={IdAbove(post.ID)}")).Contains(post.ID),
home.Any(s => IdOf(s!) == post.ID || IdOf(s!) == ghostBoost.ID || s!["reblog"] is JsonObject inner && IdOf(inner) == post.ID),
notifications.Any(n => n!["status"] is JsonObject status && IdOf(status) == post.ID),
conversations.Any(c => c!["last_status"] is JsonObject last && IdOf(last) == dmPost.ID),
search["statuses"]!.AsArray().Count > 0,
(await Ids(client, "/api/v1/favourites")).Contains(post.ID),
(await Ids(client, "/api/v1/bookmarks")).Contains(post.ID),
await Found(client, $"/api/privapub/v1/statuses/{post.ID}/provenance"),
outbox["orderedItems"]!.AsArray().Any(a => a!["object"]?.GetValue<string>() == post.ObjectURI));
}
var before = await Look();
Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!)));
var after = await Look();
var everything = new Seen(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true);
var nothing = new Seen(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false);
Assert.Equal(everything, before);
Assert.Equal(nothing, after);
Assert.True(await DB.Default.Find<Post>().Match(p => p.ID == post.ID && p.AuthorGone && p.DeletedAt == null).ExecuteAnyAsync(token));
Assert.True(await Found(client, $"/api/v1/statuses/{friends.ID}"));
Assert.True(await Found(client, $"/api/v1/statuses/{replyPost.ID}"));
}
}
}
@@ -0,0 +1,131 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
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 CommunityEditsTests : 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);
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced()
{
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");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var note = new JsonObject
{
["id"] = $"{Origin(poster)}/notes/{Guid.NewGuid():N}",
["type"] = "Page",
["name"] = "a title",
["attributedTo"] = poster.Id,
["content"] = "<p>first</p>",
["to"] = new JsonArray(community.Id, Addressing.Public),
["audience"] = community.Id,
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
};
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, note.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/create/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = poster.Id, ["object"] = note.DeepClone() });
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(token);
return (community, poster, note, post);
}
Task Announce(RemoteActor community, JsonObject inner) =>
_harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(community)}/announce/{Guid.NewGuid():N}",
["type"] = "Announce",
["actor"] = community.Id,
["to"] = new JsonArray(Addressing.Public),
["object"] = inner
});
[Fact]
public async Task A_community_edit_is_an_edit_only_when_newer_and_keeps_its_history()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
var refresh = (JsonObject)note.DeepClone();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, refresh.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterRefresh = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var edit = (JsonObject)note.DeepClone();
edit["content"] = "<p>second</p>";
edit["updated"] = DateTime.UtcNow.ToString("O");
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, edit.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterEdit = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Empty(afterRefresh.Revisions);
Assert.Null(afterRefresh.EditedAt);
Assert.Equal("<p>second</p>", afterEdit.ContentHtml);
Assert.Equal("a title", afterEdit.Title);
Assert.Single(afterEdit.Revisions);
Assert.NotNull(afterEdit.EditedAt);
Assert.Equal(2, record.Revisions.Count);
Assert.Equal(new[] { "refresh", "edit" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Announce" && e.Object == "Update").Select(e => e.Reason));
}
[Fact]
public async Task A_community_delete_leaves_a_tombstone_and_no_trace()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
{
var token = TestContext.Current.CancellationToken;
var (_, _, _, post) = await Announced();
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Equal(ObjectPath.Fetched, record.Path);
Assert.Null(record.KeyId);
Assert.Null(record.SignatureScheme);
Assert.Empty(record.SignedHeaders);
Assert.Null(record.ActivityContext);
Assert.Equal("Announce", record.ActivityType);
Assert.InRange(record.ReceivedAt, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
Assert.Contains("fep-1b12-audience", record.Extensions);
Assert.NotNull(record.ContextNamespaces);
}
}
}
@@ -0,0 +1,51 @@
using PrivaPub.Federation.Objects;
namespace PrivaPub.Tests.Federation
{
public class ContentSanitizerTests
{
[Theory]
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
[InlineData("<img src=x onerror=alert(1)>", "")]
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
[InlineData("<svg><script>alert(1)</script></svg>", "")]
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
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 = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
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));
}
}
@@ -0,0 +1,37 @@
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<DomainBlocks>.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<DomainBlocks>.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));
}
}
}
+198
View File
@@ -0,0 +1,198 @@
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.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<string> to, string audience = default)
{
var note = new JsonObject
{
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>to the group</p>",
["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<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Equal(2, announces.Count);
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[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<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
[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<string>());
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "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<string>()));
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<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote);
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(circle.IsCircle);
Assert.False(circle.Discoverable);
}
[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<Post>().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<TimelineEntry>().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<Following>().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<string>();
_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<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
}
}
}
@@ -0,0 +1,135 @@
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<string, string>
{
["(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<IHttpRequestFeature>().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()}\"")));
[Fact]
public void Signs_with_the_raw_request_target()
{
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
context.Features.Get<IHttpRequestFeature>().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<IHttpRequestFeature>().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));
}
}
}
@@ -0,0 +1,139 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
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 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<Post> 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<Post>().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<Post>().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<Post>().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"] = "<p>late</p>",
["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<Post>().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"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
Assert.NotNull(message.ConversationId);
}
[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);
}
[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<string>());
}
}
}
+232
View File
@@ -0,0 +1,232 @@
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<ForeignAvatar> Stored(RemoteActor actor) =>
DB.Default.Find<ForeignAvatar>().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);
[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"] = "<p>a new bio<script>alert(1)</script></p>";
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("<p>a new bio</p>", 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<Follower>(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<Follower>().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<Follower>().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, "<p>ask me first</p>", 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<Post>().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<string>() == "QuoteRequest");
await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!));
Assert.Equal(QuoteState.Pending, (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).QuoteState);
await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request));
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
Assert.Equal(QuoteState.Rejected, quoting.QuoteState);
Assert.Null(quoting.QuoteAuthorizationURI);
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason));
}
[Fact]
public async Task A_followed_community_announcing_a_vote_or_its_undo_is_recorded_and_changes_nothing_yet()
{
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<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var page = PublicNote(poster, "<p>a post</p>", 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<Post>().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");
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");
//votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create")
Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object));
Assert.Equal(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object));
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, after.FavouritesCount);
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
}
[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<Avatar>().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<Follower>().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<Notification>().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<string>());
Assert.Equal(alice.Uri, accept["actor"]!.GetValue<string>());
Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!));
Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue<string>());
var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
Assert.Equal("Reject", reject["type"]!.GetValue<string>());
Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!));
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.Contains(await DB.Default.Find<Notification>().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));
}
}
}
@@ -0,0 +1,331 @@
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<AppConfiguration>(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<DomainBlocks>.Instance);
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.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<InboxProcessor>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
async Task<LocalActor> 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"] = "<p>psst</p>",
["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<InboxResult> 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<string>() : default);
var job = await DB.Default.Find<Job>().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<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token);
var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
var bobConversation = await DB.Default.Find<DmGroup>().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<Post>().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");
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<Post>().Match(p => p.ActorURI == victim.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<Post>().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"] = "<p>hello</p>",
["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<Post>().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<Post>().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<Post>().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<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
var mention = Assert.Single(post.Mentions);
Assert.True(mention.IsLocal);
Assert.Equal(alice.Id, mention.AccountId);
}
[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<Post>().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<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
}
}
}
@@ -0,0 +1,137 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.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
{
[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<Post>().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<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.Equal(NotificationType.Favourite, (await DB.Default.Find<Notification>().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<Post>().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<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
[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<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.Equal(NotificationType.Reblog, (await DB.Default.Find<Notification>().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<string>()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == announce["id"]!.GetValue<string>()).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<Following>().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"] = "<p>worth sharing</p>", ["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<Post>().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<ViewPost>)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data;
var boosted = Assert.Single(home);
Assert.Equal("<p>worth sharing</p>", boosted.ReblogOf.ContentHtml);
Assert.Equal(carol.Id, boosted.AuthorActorURI);
}
}
}
@@ -0,0 +1,537 @@
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<JsonObject>();
for (var i = 0; i < RemotePosts.MaxDepth + 3; i++)
{
var note = PublicNote(poster, $"<p>number {i}</p>");
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 = "<p>the leaf</p>"
};
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<string> { "ancestors|" + leaf.ID };
var ran = 0;
while (await DB.Default.Find<Job>().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<Job>().MatchID(job.ID).Modify(j => j.State, JobState.Done).ExecuteAsync(token);
ran++;
var uris = chain.Select(IdOf).ToList();
keys = (await DB.Default.Find<Post>().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<Post>().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<Post>().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);
}
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<List<JsonObject>> Updates(RemoteActor to) =>
(await _harness.Outgoing(to.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Update").ToList();
static int[] Counts(JsonNode question) =>
question["oneOf"]!.AsArray().Select(o => o!["replies"]!["totalItems"]!.GetValue<int>()).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<Job>().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<string>());
Assert.Equal(new[] { 0, 1 }, Counts(question));
Assert.Null(question["closed"]);
Assert.Null((await DB.Default.Find<Post>().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<Job>().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<Post>().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<Notification>().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, "<p>pick</p>", 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<Post>().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<Job>().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<Notification>().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<Post>().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"] = "<p>hi</p>", ["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<List<Job>> Jobs() => DB.Default.Find<Job>().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<RemoteInstance>().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<int>());
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>(job.ID);
await Record();
Assert.Empty(await Jobs());
await DB.Default.Update<RemoteInstance>().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<RemoteInstance>().Match(i => i.Host == plain).ExecuteSingleAsync(token)).DescriptionError);
Assert.Equal("no NodeInfo", (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == silent).ExecuteSingleAsync(token)).DescriptionError);
}
LinkPreviews Previews() => new(_harness.Db, _harness.Local, Peer.Http(), _harness.Queue,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
async Task<Post> Linking(PostVisibility visibility, string path)
{
var post = new Post
{
ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", IsFederatedCopy = true, Visibility = visibility,
ContentHtml = $"<p>read <a href=\"{_harness.Peer.A}{path}\">this</a></p>"
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
_harness.Peer.ServeText(path, """
<html><head><title>Fallback</title>
<meta property="og:title" content="A headline">
<meta property="og:description" content="What it says">
<meta property="og:image" content="/cover.jpg">
</head><body></body></html>
""", "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<Post>().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<LinkPreview>().Match(p => p.Url == _harness.Peer.A + publicPath).ExecuteAnyAsync(token));
Assert.Null((await DB.Default.Find<Post>().OneAsync(followersOnly.ID, token)).Link);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == privatePath);
Assert.False(await DB.Default.Find<LinkPreview>().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<MediaOptions>(new MediaOptions { Root = root, ProxyCacheBytes = 800 });
var media = new MediaService(options, _harness.Local, default, NullLogger<MediaService>.Instance);
var seeded = new List<string>();
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<MediaJanitor>.Instance).Sweep(token);
Assert.False(await DB.Default.Find<MediaAttachment>().Match(m => m.ID == stale.ID).ExecuteAnyAsync(token));
Assert.False(File.Exists(Path.Combine(root, stale.FilePath)));
Assert.False(File.Exists(Path.Combine(root, stale.PreviewPath)));
Assert.True(await DB.Default.Find<MediaAttachment>().Match(m => m.ID == fresh.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<MediaAttachment>().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<MediaAttachment>(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<IOpenIddictTokenManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var subject = $"pruned{Guid.NewGuid():N}";
async Task<string> 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<string> 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<OAuthPruner>.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<IOpenIddictTokenManager>();
authorizations = after.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
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<RemoteInstance>(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<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
async Task<JobOutcome> Attempt(LocalActor signer, string inbox)
{
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<Job>().Match(j => j.DedupeKey == $"{id}|{inbox}").ExecuteSingleAsync(token);
job.Attempts = 1;
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.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<string>());
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);
}
}
}
@@ -0,0 +1,137 @@
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": "<p><span class=\"h-card\"><a href=\"https://privapub.test/peasants/bob\" class=\"u-url mention\">@<span>bob</span></a></span> hi <a href=\"https://m.example/tags/Fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>Fedi</span></a><script>x</script></p>",
"contentMap": { "en": "<p>hi</p>" },
"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" } }
]
}
""";
[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("<script>", note.ContentHtml);
Assert.Contains("u-url mention", note.ContentHtml);
Assert.Equal("https://privapub.test/peasants/bob", Assert.Single(note.Mentions).ActorURI);
Assert.Equal("fedi", Assert.Single(note.Tags));
var media = Assert.Single(note.Attachments);
Assert.Equal("a cat", media.Description);
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
Assert.Null(note.Updated);
}
[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": "<p>body</p>", "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 <b>there</b>\nnext", "_misskey_quote": "https://k.example/notes/8"
}
"""));
Assert.Equal("https://k.example/users/9", note.AttributedTo);
Assert.Equal("hello &lt;b&gt;there&lt;/b&gt;<br>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": "<p>body</p>"
}
"""));
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));
}
}
@@ -0,0 +1,36 @@
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));
}
}
}
@@ -0,0 +1,254 @@
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("<strong>Bold</strong>", 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": "<p>Saturday at the library</p>", "content": "<p>Bring broken things.</p>",
"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_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": "<p>look</p>",
"attachment": [{ "type": "Link", "href": "https://news.example/a",
"preview": { "type": "Article", "name": "Headline &amp; more", "summary": "<p>What happened</p>", "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": "<p>pics</p>",
"@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));
}
}
+138
View File
@@ -0,0 +1,138 @@
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<string>() == "Create")["object"]!;
Assert.Equal("Question", question["type"]!.GetValue<string>());
Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue<string>()));
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<Post>().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<Post>().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().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"] = "<p>pick</p>",
["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<Post>().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<string>() == "Create").Select(a => a["object"]!).ToList();
Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue<string>()).Order().Reverse());
Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue<string>()));
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<Post>().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<Job>().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<Post>().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt);
var told = await DB.Default.Find<Notification>().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<Post>().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok);
}
}
}
@@ -0,0 +1,72 @@
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"] = "<p>hi</p>",
["_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"] = "<p>hi again</p>";
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<ObjectRecord>().Match(r => r.ObjectURI == note["id"]!.GetValue<string>()).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<string>(), record.ActivityId);
Assert.Contains("digest", record.SignedHeaders);
Assert.Equal("<p>hi</p>", JsonNode.Parse(record.Raw)!["content"]!.GetValue<string>());
Assert.Contains("legacy-quote", ObjectFeatures.Detect(JsonNode.Parse(record.Raw)!.AsObject()));
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
}
}
}
+221
View File
@@ -0,0 +1,221 @@
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<Post> Delivered(RemoteActor author, string mentioned, Action<JsonObject> shape = default)
{
var note = new JsonObject
{
["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "<p>hello</p>",
["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<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).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"] = $"<p>look<span class=\"quote-inline\"><br>RE: <a href=\"{original.ObjectURI}\">{original.ObjectURI}</a></span></p>";
});
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(original.ID, quoting.QuotedPostId);
Assert.Equal(1, (await DB.Default.Find<Post>().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<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Equal(0, (await DB.Default.Find<Post>().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<string>() == "QuoteRequest");
Assert.Equal(original.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue<string>());
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id,
["object"] = request["id"]!.GetValue<string>(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI)
});
var quoting = await DB.Default.Find<Post>().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<string>() == "Update"
&& a["object"]!["quoteAuthorization"]?.GetValue<string>() == 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"] = "<p>so true</p>" }
};
[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<string>() == "Create")["object"]!;
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
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<string>() == "Accept" && a["result"] != null);
var licence = accept["result"]!.GetValue<string>();
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<Post>().OneAsync(original.ID, token)).QuotesCount);
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == 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<string>());
}
[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<Post>().OneAsync(original.ID, token)).QuotesCount);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
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("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<CustomEmoji> { 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<Post> 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<Post>().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<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
Assert.Equal(new HashSet<string> { "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<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Notification>().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<Reaction>().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"] = "<p>hi</p>",
["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<Post>().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<string>() == "EmojiReact");
Assert.Equal("🎉", react["content"]!.GetValue<string>());
Assert.Equal(noteId, react["object"]!.GetValue<string>());
Assert.Equal(react["id"]!.GetValue<string>(), Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo")["object"]!["id"]!.GetValue<string>());
}
}
}
@@ -0,0 +1,254 @@
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<AppConfiguration>(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<AppConfiguration>(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));
}
}
@@ -0,0 +1,38 @@
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 = "<p>before</p>" };
[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 = "<p>before</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "<p>after</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "<p>after</p>" }, 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 = "<p>after</p>" }, Stored()));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>", SpoilerText = "now with a warning" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>" }, 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 = "<p>again</p>" }, Stored(edited)));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "<p>again</p>" }, Stored(edited)));
}
}
}
+103
View File
@@ -0,0 +1,103 @@
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** <b>cats</b>",
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
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<string>());
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
Assert.False(actor["indexable"]!.GetValue<bool>());
Assert.Equal("<p>I <strong>like</strong> &lt;b&gt;cats&lt;/b&gt;</p>", actor["summary"]!.GetValue<string>());
var fields = actor["attachment"]!.AsArray();
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
Assert.Equal("&lt;i&gt;ok&lt;/i&gt;", fields[1]!["value"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
}
[Fact]
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
{
var post = new Post
{
ID = "p1",
Title = "On <cats>",
ContentHtml = "<p>body</p>",
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<string>());
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray();
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
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 = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
}
[Fact]
public void A_direct_note_is_addressed_to_its_recipients_only()
{
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", 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<string>()));
Assert.Empty(note["cc"]!.AsArray());
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
}
}
}
@@ -0,0 +1,445 @@
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Models.Group;
using PrivaPub.Models.User;
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<HttpResponseMessage> LogIn(string userName, string password)
{
using var client = _host.Client();
return await client.PostJson("/clientapi/user/login", new { userName, password });
}
async Task<HttpStatusCode> Settings(string jwt)
{
using var client = _host.As(jwt);
return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode;
}
static async Task<string> Message(HttpResponseMessage response)
{
var body = await response.JsonBody();
return body["errorMessage"]?.GetValue<string>() ?? body["title"]?.GetValue<string>();
}
string Jwt(string rootId, string userName, DateTime expires, string key = default)
{
var configuration = _host.Get<IConfiguration>();
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<string>());
Assert.Equal(new[] { Policies.IsUser }, jwt["policies"]!.AsArray().Select(p => p!.GetValue<string>()));
Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue<string>()));
var stored = await DB.Default.Find<RootUser>().MatchID(jwt["userId"]!.GetValue<string>()).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<RootUser>(u => u.UserName == root.UserName, TestContext.Current.CancellationToken));
}
public static TheoryData<string> 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<string>());
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<string>());
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal("Wrong password.", await Message(wrong));
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
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<RootUser>().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<RootUser>().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<string>());
Assert.Equal(100, settings["lightThemeIndexColour"]!.GetValue<int>());
Assert.Equal(200, settings["darkThemeIndexColour"]!.GetValue<int>());
Assert.Equal(50, settings["iconsThemeIndexColour"]!.GetValue<int>());
Assert.True(settings["themeIsDarkMode"]!.GetValue<bool>());
Assert.True(settings["themeIsDarkGray"]!.GetValue<bool>());
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<RootUser>().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<string>();
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<string>());
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<string>());
Assert.Equal("Guest", avatar["name"]!.GetValue<string>());
var stored = await DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
Assert.Contains(stored.Members, m => !m.IsForeign && m.AvatarId == avatar["id"]!.GetValue<string>() && 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<string>();
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<RootUser>().Match(u => u.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken));
Assert.False(await DB.Default.Find<Avatar>().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<string>(),
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<string>());
var groups = await (await joined.GetAsync("/clientapi/group/list?avatarId=" + avatar["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).JsonItems();
Assert.Equal(group["id"]!.GetValue<string>(), Assert.Single(groups)!["id"]!.GetValue<string>());
}
[Fact]
public async Task Recovery_without_an_email_is_refused()
{
var root = await _host.SignUp("noemail");
using var client = _host.Client();
var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") });
var neither = await client.PostJson("/clientapi/user/recover/password", new { });
Assert.Equal(HttpStatusCode.Locked, response.StatusCode);
Assert.Contains("doesn't have an email", await Message(response));
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode);
}
[Fact]
public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more()
{
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 byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email });
foreach (var response in new[] { byName, byEmail })
{
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue<string>());
foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " })
Assert.DoesNotContain(leak, body);
}
}
[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, RecoveryCode = code }, TestContext.Current.CancellationToken);
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<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[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<string>());
Assert.Equal(root.UserName, jwt["username"]!.GetValue<string>());
Assert.True(jwt["expiration"]!.GetValue<long>() > DateTime.UtcNow.Ticks);
Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue<string>()));
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<int>());
Assert.False(body["isValid"]!.GetValue<bool>());
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<RootUser>().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));
}
}
}
+257
View File
@@ -0,0 +1,257 @@
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<Persona> NewPersona(string name) => await _host.Persona(await _host.SignUp(name), name);
static Task<GroupEntity> Stored(JsonObject group) =>
DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
async Task<List<string>> 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<string>()).ToList();
}
async Task<HttpResponseMessage> 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<string>(), 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<string>());
Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode);
return (owner, circle, member);
}
async Task<HttpStatusCode> 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<bool>());
Assert.False(circle["isCommunity"]!.GetValue<bool>());
foreach (var group in new[] { community, circle })
{
Assert.True(group["isOwner"]!.GetValue<bool>());
Assert.Equal(64, group["invitationCode"]!.GetValue<string>().Length);
Assert.Equal($"{PrivaPubHost.Base}/peasants/{group["userName"]!.GetValue<string>()}", group["url"]!.GetValue<string>());
Assert.Equal(1, group["membersCount"]!.GetValue<int>());
}
Assert.True(circle["isPasswordRequired"]!.GetValue<bool>());
Assert.False(community["isPasswordRequired"]!.GetValue<bool>());
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<string>(), circle["id"]!.GetValue<string>() }.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<string>();
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<int>());
Assert.False(view["isOwner"]!.GetValue<bool>());
Assert.Null(view["invitationCode"]);
Assert.Equal(HttpStatusCode.OK, twice.StatusCode);
Assert.Equal(2, (await Stored(circle)).Members.Count);
Assert.Equal(new[] { circle["id"]!.GetValue<string>() }, 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<string>() });
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<string>();
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<string>() })).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<string>());
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle["id"]!.GetValue<string>() && 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<Follower>().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<string>());
Assert.Equal(circle["url"]!.GetValue<string>(), accept["actor"]!.GetValue<string>());
}
[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<string>();
var communityName = community["userName"]!.GetValue<string>();
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<string>(), TestContext.Current.CancellationToken)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await searcher.GetAsync("/api/v1/accounts/" + community["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).StatusCode);
foreach (var q in new[] { circleName, $"@{circleName}@{PrivaPubHost.Host}", circle["url"]!.GetValue<string>() })
{
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<string>())}", 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<string>();
var communityName = community["userName"]!.GetValue<string>();
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}"));
}
}
}
}
@@ -0,0 +1,308 @@
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<HttpStatusCode> Settings(string jwt)
{
using var client = _host.As(jwt);
return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode;
}
async Task<HttpStatusCode> Credentials(string token)
{
using var client = _host.As(token);
return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
}
async Task<HttpResponseMessage> LogIn(Root root)
{
using var client = _host.Client();
return await client.PostJson("/clientapi/user/login", new { userName = root.UserName, password = root.Password });
}
static Task<HttpResponseMessage> Remove(HttpClient client, params string[] ids) =>
client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = ids }) });
static Task<RootUser> Stored(string id) => DB.Default.Find<RootUser>().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"] = "<p>hello</p>" }
};
}
[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<string>());
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<HttpResponseMessage>
{
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<int>());
Assert.Equal("Forbidden.", body["errorMessage"]!.GetValue<string>());
}
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<DomainBlock>().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<Models.Social.Report>().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<string>() == reportId)!;
Assert.Equal("local", listed["reporter"]!.GetValue<string>());
Assert.Equal(target.Id, listed["targetAccountId"]!.GetValue<string>());
Assert.Equal("spam", listed["category"]!.GetValue<string>());
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<Models.Social.Report>().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<string>());
Assert.Equal("Silence", view["severity"]!.GetValue<string>());
Assert.True(view["rejectMedia"]!.GetValue<bool>());
Assert.Contains(listed, b => b!["domain"]!.GetValue<string>() == domain);
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
Assert.Equal("Suspend", (await suspended.JsonBody())["severity"]!.GetValue<string>());
Assert.Equal(view["id"]!.GetValue<string>(), (await suspended.JsonBody())["id"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
Assert.DoesNotContain(after, b => b!["domain"]!.GetValue<string>() == domain);
Assert.Null(_host.Get<IDomainBlocks>().Find(domain));
}
finally
{
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
await _host.Get<IDomainBlocks>().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<DomainBlock>().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<Job>().Match(j => j.DedupeKey == "inbox|" + blocked["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
Assert.Equal(0, await _host.RunInbox(blocked["id"]!.GetValue<string>(), token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == blocked["object"]!["id"]!.GetValue<string>()).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<string>(), token));
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == welcome["object"]!["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
}
finally
{
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
await _host.Get<IDomainBlocks>().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<string>()).Order().ToList();
Assert.Equal(new[] { "en", "it" }, codes);
}
}
}
@@ -0,0 +1,204 @@
using MongoDB.Bson;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class ClientApiPersonasTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20];
async Task<HttpResponseMessage> Insert(Root root, object body)
{
using var client = _host.As(root.Jwt);
return await client.PostJson("/clientapi/avatar/private/insert", body);
}
async Task<List<string>> Listed(Root root)
{
using var client = _host.As(root.Jwt);
return (await (await client.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())
.Select(a => a!["id"]!.GetValue<string>()).ToList();
}
static DateTime IdDay(string id) => ObjectId.Parse(id).CreationTime;
static void WithinTwoWeeksBefore(DateTime created, DateTime published, string id)
{
Assert.Equal(published.Date, published);
Assert.InRange(published, created.Date.AddDays(-13), DateTime.UtcNow.Date);
Assert.Equal(published, IdDay(id));
}
[Fact]
public async Task A_root_id_in_the_body_is_ignored()
{
var root = await _host.SignUp("owner");
var other = await _host.SignUp("victim");
var userName = Name("planted");
var response = await Insert(root, new { userName, name = "planted", biography = "testing", rootId = other.Id });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var id = (await response.JsonBody())["id"]!.GetValue<string>();
var link = await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(root.Id, link.RootId);
Assert.Contains(id, await Listed(root));
Assert.DoesNotContain(id, await Listed(other));
Assert.DoesNotContain(root.Id, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("Upper")]
[InlineData("has-dash")]
[InlineData("dot.ted")]
[InlineData("spa ce")]
[InlineData("ünïcode")]
[InlineData("at@sign")]
[InlineData("")]
public async Task Usernames_outside_the_regex_are_refused(string userName)
{
var root = await _host.SignUp("regex");
var response = await Insert(root, new { userName, name = "regex", biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains((await response.JsonBody())["errors"]!.AsObject(), e => e.Key.Equals("userName", StringComparison.OrdinalIgnoreCase));
Assert.Empty(await Listed(root));
if (userName.Length > 0)
Assert.False(await DB.Default.Find<ReservedName>().Match(r => r.Name == userName || r.Name == userName.ToLowerInvariant()).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("admin")]
[InlineData("privapub")]
[InlineData("root")]
[InlineData("moderator")]
[InlineData("abuse")]
public async Task Reserved_names_are_refused(string userName)
{
var root = await _host.SignUp("reserved");
var response = await Insert(root, new { userName, name = userName, biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue<string>());
Assert.Empty(await Listed(root));
}
[Fact]
public async Task Personas_and_groups_share_one_name_space()
{
var root = await _host.SignUp("names");
var persona = await _host.Persona(root, "names");
var group = await _host.Group(persona, community: true);
var groupName = group["userName"]!.GetValue<string>();
using var client = _host.As(root.Jwt);
var personaOverGroup = await Insert(root, new { userName = groupName, name = "copy", biography = "testing" });
var groupOverPersona = await client.PostJson("/clientapi/group/insert", new { avatarId = persona.Id, userName = persona.UserName, name = "copy", isCommunity = true });
var personaOverPersona = await Insert(await _host.SignUp("names"), new { userName = persona.UserName, name = "copy", biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, personaOverGroup.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, groupOverPersona.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, personaOverPersona.StatusCode);
var reserved = await DB.Default.Find<ReservedName>().Match(r => r.Name == groupName || r.Name == persona.UserName).ExecuteAsync(TestContext.Current.CancellationToken);
Assert.Equal(2, reserved.Count);
Assert.Contains(reserved, r => r.Name == persona.UserName && r.OwnerKind == LocalActorKind.Person && r.OwnerId == persona.Id);
Assert.Contains(reserved, r => r.Name == groupName && r.OwnerKind == LocalActorKind.Group && r.OwnerId == group["id"]!.GetValue<string>());
Assert.Equal(1, await DB.Default.CountAsync<Avatar>(a => a.UserName == persona.UserName, TestContext.Current.CancellationToken));
Assert.Equal(1, await DB.Default.CountAsync<GroupEntity>(g => g.UserName == groupName, TestContext.Current.CancellationToken));
}
[Fact]
public async Task An_update_is_delivered_to_followers()
{
await using var peer = await Peer.Start();
var persona = await _host.Persona(await _host.SignUp("update"), "update");
var follower = new RemoteActor(peer, "fan");
await _host.Follow(follower, peer.A, persona.UserName);
var since = DateTime.UtcNow.AddSeconds(-1);
using var client = _host.As(persona.Root.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Renamed", biography = "new biography" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var update = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, TestContext.Current.CancellationToken), d => d["type"]!.GetValue<string>() == "Update");
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", update["actor"]!.GetValue<string>());
var person = update["object"]!.AsObject();
Assert.Equal("Person", person["type"]!.GetValue<string>());
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", person["id"]!.GetValue<string>());
Assert.Equal("Renamed", person["name"]!.GetValue<string>());
Assert.DoesNotContain(persona.Root.Id, update.ToJsonString());
Assert.DoesNotContain(persona.Root.UserName, update.ToJsonString());
}
[Fact]
public async Task Updating_another_roots_persona_is_refused()
{
var persona = await _host.Persona(await _host.SignUp("mine"), "mine");
var intruder = await _host.SignUp("intruder");
using var client = _host.As(intruder.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Hijacked", biography = "testing" });
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.NotEqual("Hijacked", (await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Name);
}
[Fact]
public async Task Published_days_and_ids_fall_within_two_weeks_before_creation()
{
var created = DateTime.UtcNow;
var persona = await _host.Persona(await _host.SignUp("published"), "published");
var group = await _host.Group(persona, community: true);
using var client = _host.Client();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{persona.UserName}");
request.Headers.Accept.ParseAdd("application/activity+json");
var avatar = await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
var stored = await DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
var actor = JsonNode.Parse(await (await client.SendAsync(request, TestContext.Current.CancellationToken)).Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!;
WithinTwoWeeksBefore(created, avatar.PublishedOn, avatar.ID);
WithinTwoWeeksBefore(created, stored.PublishedOn, stored.ID);
var published = DateTime.Parse(actor["published"]!.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
Assert.Equal(avatar.PublishedOn, published);
Assert.DoesNotContain(avatar.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture), actor.ToJsonString());
}
[Fact]
public async Task The_list_holds_only_the_roots_own_personas()
{
var root = await _host.SignUp("list");
var other = await _host.SignUp("list");
var first = await _host.Persona(root, "first");
var second = await _host.Persona(root, "second");
var theirs = await _host.Persona(other, "theirs");
Assert.Equal(new[] { first.Id, second.Id }.Order(), (await Listed(root)).Order());
Assert.Equal(new[] { theirs.Id }, await Listed(other));
}
}
}
+468
View File
@@ -0,0 +1,468 @@
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<Avatar>().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<string>());
Assert.Equal("Person", actor["type"]!.GetValue<string>());
Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue<string>());
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal(id + "/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal(id + "/stalking", actor["following"]!.GetValue<string>());
Assert.Equal(id + "/trophies", actor["featured"]!.GetValue<string>());
Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue<string>());
Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue<string>());
Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
var key = actor["publicKey"]!;
Assert.Equal(id + "#main-key", key["id"]!.GetValue<string>());
Assert.Equal(id, key["owner"]!.GetValue<string>());
var pem = key["publicKeyPem"]!.GetValue<string>();
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<string>());
Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue<string>());
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<string>());
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<string>());
}
[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<Post>();
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<Post>
{
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<string>());
Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>());
Assert.Equal(22, outbox.Json["totalItems"]!.GetValue<int>());
Assert.Null(outbox.Json["orderedItems"]);
var first = outbox.Json["first"]!.GetValue<string>();
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<string>());
Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue<string>());
var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList();
Assert.Equal(20, items1.Count);
var next = page1.Json["next"]!.GetValue<string>();
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<string>());
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<string>()).Distinct().Count());
var announce = items[0];
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue<string>());
Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
var creates = items.Skip(1).ToList();
Assert.All(creates, c =>
{
Assert.Equal("Create", c["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue<string>());
});
Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue<string>()));
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<string>());
Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue<string>());
Assert.Equal(1, followers.Json["totalItems"]!.GetValue<int>());
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<string>());
Assert.Equal(0, following.Json["totalItems"]!.GetValue<int>());
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<string>());
Assert.Equal(2, featured.Json["totalItems"]!.GetValue<int>());
var notes = featured.Json["orderedItems"]!.AsArray();
Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue<string>()));
Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue<string>()));
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<string>());
Assert.Equal(0, tags.Json["totalItems"]!.GetValue<int>());
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<string>());
Assert.Equal("Note", note.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue<string>());
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<string>());
Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue<string>());
Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue<string>());
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<string>());
Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"]));
Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text);
Assert.Equal(HttpStatusCode.OK, asMemberServer.Status);
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);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{owner.UserName}/grunts/create-{post.ID}")).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<string>());
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<int>());
Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue<int>());
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<string>());
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-{original.ID}"));
Assert.Equal(HttpStatusCode.OK, create.Status);
Assert.Equal(ActivityJson, create.MediaType);
Assert.Equal("Create", create.Json["type"]!.GetValue<string>());
Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue<string>());
Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue<string>());
Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, announce.Status);
Assert.Equal("Announce", announce.Json["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue<string>());
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($"/peasants/{friend.UserName}/grunts/announce-{original.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<string>());
Assert.Equal(licenceUri, licence.Json["id"]!.GetValue<string>());
Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue<string>());
Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status);
Assert.Equal(HttpStatusCode.NotFound, unknown.Status);
Assert.True(await _host.Get<IQuoteService>().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<string>());
Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue<string>());
Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue<string>());
}
[Fact]
public async Task Secure_mode_refuses_unsigned_gets_except_for_the_instance_actor()
{
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<string>());
foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
}
}
}
+146
View File
@@ -0,0 +1,146 @@
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<AppConfiguration>().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<IActivityHandler>().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<IJobHandler>().GroupBy(h => h.Kind).ToDictionary(g => g.Key, g => g.Count());
foreach (var kind in Enum.GetValues<JobKind>())
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<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.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<ServiceDescriptor>)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"] = "<p>hello through the front door</p>",
["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<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
Assert.Equal(PostVisibility.Direct, stored.Visibility);
Assert.Contains("front door", stored.ContentHtml);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
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<string> 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"] = "<p>knock knock</p>",
["published"] = DateTime.UtcNow.ToString("O")
}
};
}
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
async Task<bool> Queued(JsonObject activity) =>
await DB.Default.Find<Job>().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken);
async Task<HttpResponseMessage> 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<HttpStatusCode> 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<HttpStatusCode>();
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);
}
}
}
@@ -0,0 +1,628 @@
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_instance_actor()
{
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<JsonObject>(Token))!["id"]!.GetValue<string>();
}
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=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);
}
[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<JsonObject>(Token))!["id"]!.GetValue<string>();
}
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<Mute>().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<Mute>().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<string>()));
(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 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("<p>for my followers</p>", new[] { bob.Id + "/followers" });
await _host.Deliver(bob, alice.Mouth, create);
var noteId = create["object"].Text("id");
var stored = await DB.Default.Find<Post>().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<RootUser>().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, "<p>something nasty</p>");
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<string>()));
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<string>()));
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);
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/lists")).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());
}
}
}
@@ -0,0 +1,133 @@
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.False(v1.Body.Flag("registrations"));
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<string>()));
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.False(v2.Body["registrations"].Flag("enabled"));
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("<p>", 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")]
[InlineData("/api/v1/trends")]
[InlineData("/api/v1/trends/tags")]
[InlineData("/api/v1/trends/statuses")]
[InlineData("/api/v1/trends/links")]
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/filters")]
[InlineData("/api/v2/filters")]
[InlineData("/api/v1/lists")]
[InlineData("/api/v1/suggestions")]
[InlineData("/api/v2/suggestions")]
[InlineData("/api/v1/followed_tags")]
[InlineData("/api/v1/endorsements")]
[InlineData("/api/v1/featured_tags")]
[InlineData("/api/v1/scheduled_statuses")]
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<string>()));
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"));
}
}
}
+369
View File
@@ -0,0 +1,369 @@
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;
static Task<ApiAnswer> 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<byte[]> 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<string> 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<double>());
Assert.Equal(-0.25, uploaded.Body["meta"]!["focus"]!["y"]!.GetValue<double>());
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<double>(), described.Body["meta"]!["focus"]!["y"]!.GetValue<double>()));
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"));
}
[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/v2/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4")).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);
}
[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));
var uploaded = (await Upload(alice, "/api/v2/media", video, "video/mp4", "clip.mp4")).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);
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");
var uploaded = (await Upload(alice, "/api/v2/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);
}
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, "<p>look :blob:</p>", 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<IMediaProxy>();
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<IMediaProxy>();
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<IMediaProxy>();
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);
}
[Fact]
public async Task Anything_over_the_proxy_limit_is_streamed_and_never_cached()
{
var host = await SmallProxyHost.Shared();
var proxy = host.Get<IMediaProxy>();
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));
Assert.Equal(4, _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<SmallProxyHost> 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<KeyValuePair<string, string>> Settings() =>
base.Settings().Append(new KeyValuePair<string, string>("Media:MaxProxiedBytes", Limit.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
}
@@ -0,0 +1,549 @@
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<string> Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue<string>());
static async Task<JsonObject> 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"));
}
[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"));
}
[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<string>();
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<ApiAnswer> Keyed(string text)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["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<int>()));
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, "<p>which?</p>", 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);
}
}
}
@@ -0,0 +1,236 @@
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<string>();
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<List<string>> Window(HttpClient client, string filter, string since, string until)
{
var ids = new List<string>();
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 A_list_timeline_is_an_empty_stub()
{
var alice = await _host.Mastodon("alice");
Assert.Empty((await alice.Client.Get("/api/v1/timelines/list/anything")).Ok().Array);
}
[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 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"));
}
}
}
@@ -0,0 +1,127 @@
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<string> 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<string>();
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<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
foreach (var endpoint in endpoints)
{
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.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<bool> 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;
}
}
+346
View File
@@ -0,0 +1,346 @@
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<Persona> NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name);
static async Task<bool> 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<string>()));
}
[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<string>();
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<string>());
}
[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<string>(),
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<string>());
}
[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<string>());
using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue<string>());
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<string>());
}
[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<string>();
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);
}
}
}
@@ -0,0 +1,261 @@
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<Post> _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<IFollowService>();
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<Post>
{
_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<IStatusService>().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<string>()).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<string>();
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<string>(seeds);
var seen = new HashSet<string>(seeds);
var failures = new List<string>();
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<string> 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<string> Routes()
{
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
foreach (var endpoint in endpoints)
{
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.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();
}
}
@@ -0,0 +1,224 @@
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("<p>signed and sealed</p>", 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<Post>().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<string>()));
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"] = "<p>the start of it</p>",
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
}.ToJsonString());
var reply = bob.Create("<p>and a reply</p>", 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<Post>().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("<p>between us</p>", 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<Post>().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<string> { "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<string>());
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);
}
}
}
+206
View File
@@ -0,0 +1,206 @@
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<Fetched> 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<string>() == rel)!["href"]!.GetValue<string>();
[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<string>());
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<string>() == "self")!["type"]!.GetValue<string>());
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_instance_actor_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<string>());
Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self"));
Assert.Single(instance.Json["links"]!.AsArray());
Assert.Equal(HttpStatusCode.OK, group.Status);
Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue<string>());
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<string>(), l => l!["href"]!.GetValue<string>());
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<string>());
Assert.Equal("privapub", document["software"]!["name"]!.GetValue<string>());
Assert.Equal(version == "2.1", document["software"]!["repository"] != default);
Assert.Equal(new[] { "activitypub" }, Strings(document["protocols"]));
Assert.True(document["usage"]!["users"]!["total"]!.GetValue<long>() >= 1);
Assert.True(document["usage"]!["localPosts"]!.GetValue<long>() >= 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<long> LocalPosts()
{
using var client = _host.Client();
return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue<long>();
}
[Fact]
public async Task Local_posts_count_only_what_anyone_may_see()
{
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 + 2, await LocalPosts());
await _host.Remove(persona, shown);
Assert.Equal(start + 1, await LocalPosts());
}
}
}
@@ -0,0 +1,99 @@
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<List<string>> StoredPolicies(Root root) =>
(await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Policies.Order().ToList();
async Task<HttpStatusCode> 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() }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
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 }));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
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}" }));
Assert.Equal(1, await AdminCommands.Run(new[] { "demote", $"nobody{Guid.NewGuid():N}" }));
}
public static TheoryData<string[]> Misuses() => new()
{
Array.Empty<string>(),
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));
}
[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 }));
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<string>()));
}
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root));
}
}
}
@@ -0,0 +1,95 @@
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;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Infrastructure
{
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public sealed class DeliveryWorkerTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_peer == default)
return;
await _peer.DisposeAsync();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
[Fact]
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
{
var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar, token);
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var sender = local.FromAvatar(avatar);
var run = $"https://privapub.test/a/{Guid.NewGuid():N}/";
var queue = new JobQueue(j => j.DedupeKey.StartsWith(run));
var delivery = new DeliveryService(new DbEntities(), queue);
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
_peer.Answer("/live/inbox", 202);
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = run + "dead", ["type"] = "Create" }, token);
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = run + "live", ["type"] = "Create" }, token);
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
await worker.StartAsync(token);
try
{
await Until(() => Task.FromResult(_peer.Requests.Any(r => r.Path == "/live/inbox")), token);
var deadSoFar = _peer.Requests.Count(r => r.Path == "/dead/inbox");
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
Assert.True(deadSoFar < 30, $"{deadSoFar} dead deliveries were tried before the live one");
await Until(() => DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.State == JobState.Pending && j.Attempts > 0).ExecuteAnyAsync(token), token);
}
finally
{
await worker.StopAsync(CancellationToken.None);
}
var retried = await DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.Attempts > 0 && j.State == JobState.Pending).ExecuteFirstAsync(token);
Assert.Equal("503", retried.LastError?.Split(' ')[0]);
Assert.True(retried.RunAt > DateTime.UtcNow);
Assert.True((await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token))?.ConsecutiveFailures > 0);
}
static async Task Until(Func<Task<bool>> condition, CancellationToken token)
{
var deadline = DateTime.UtcNow.AddSeconds(30);
while (!await condition())
{
Assert.True(DateTime.UtcNow < deadline, "timed out");
await Task.Delay(50, token);
}
}
}
}
@@ -0,0 +1,127 @@
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.Models.Federation;
namespace PrivaPub.Tests.Infrastructure
{
public sealed class FederationHttpTests : IAsyncLifetime
{
WebApplication _peer;
string _base;
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
_peer = builder.Build();
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410));
await _peer.StartAsync();
_base = _peer.Urls.First();
}
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
static FederationHttp Client(bool allowTestNetwork = true)
{
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
}
[Fact]
public async Task Reads_a_json_document()
{
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
}
[Fact]
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
{
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
}
[Fact]
public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_body_over_the_limit() =>
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_an_error_status() =>
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_private_network_in_production_mode() =>
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
[Theory]
[InlineData("https://mastodon.social/users/Gargron", true)]
[InlineData("http://mastodon.social/users/Gargron", false)]
[InlineData("https://user:pass@mastodon.social/", false)]
[InlineData("https://localhost/", false)]
[InlineData("https://printer.local/", false)]
[InlineData("https://metadata.google.internal/", false)]
[InlineData("https://127.0.0.1/", false)]
[InlineData("https://[::1]/", false)]
[InlineData("ftp://example.org/", false)]
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
[Fact]
public void IsAllowed_refuses_a_suspended_domain_and_its_subdomains()
{
var http = new FederationHttp(new ServiceCollection().AddHttpClient().BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"),
NullLogger<FederationHttp>.Instance);
Assert.False(http.IsAllowed(new Uri("https://evil.example/users/x")));
Assert.False(http.IsAllowed(new Uri("https://cdn.evil.example/a.png")));
Assert.True(http.IsAllowed(new Uri("https://notevil.example/users/x")));
}
sealed class StaticBlocks : IDomainBlocks
{
readonly string[] _suspended;
public StaticBlocks(params string[] suspended) => _suspended = suspended;
public DomainBlock Find(string host) => _suspended.Any(s => host == s || host.EndsWith("." + s))
? new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }
: default;
public bool IsSuspended(string host) => Find(host) != default;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
{
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
public FederationOptions CurrentValue { get; }
public FederationOptions Get(string name) => CurrentValue;
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
}
}
}
@@ -0,0 +1,69 @@
using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Infrastructure.Geo;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Tests.Support;
using System.Net;
namespace PrivaPub.Tests.Infrastructure
{
public class GeoLocatorTests
{
static DbIpLocator Locator(string directory) =>
new(new StaticOptions<StatisticsOptions>(new StatisticsOptions { GeoDirectory = directory }), NullLogger<DbIpLocator>.Instance);
[Fact]
public void Without_databases_nothing_is_located()
{
using var locator = Locator(Path.Combine(Path.GetTempPath(), $"no-geo-{Guid.NewGuid():N}"));
Assert.Null(locator.Locate(IPAddress.Parse("1.1.1.1")));
Assert.Null(locator.Source);
}
[Fact]
public void A_private_address_is_never_looked_up()
{
using var locator = Locator(Path.GetTempPath());
Assert.Null(locator.Locate(IPAddress.Parse("10.1.2.3")));
Assert.Null(locator.Locate(IPAddress.Loopback));
Assert.Null(locator.Locate(default));
}
[Fact]
public void The_real_databases_give_a_country_a_city_and_a_network()
{
var directory = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_GEO");
Assert.SkipWhen(string.IsNullOrEmpty(directory), "set PRIVAPUB_TEST_GEO to a directory holding the DB-IP Lite city and ASN files");
using var locator = Locator(directory);
var fix = locator.Locate(IPAddress.Parse("1.1.1.1"));
Assert.NotNull(fix);
Assert.Equal(2, fix.Country.Length);
Assert.Equal(13335, fix.Asn);
Assert.Equal("Cloudflare", CdnNetworks.Of(fix.Asn));
Assert.NotNull(fix.AsnOrg);
Assert.NotNull(fix.Latitude);
Assert.Equal(Math.Round(fix.Latitude.Value, 1), fix.Latitude);
Assert.StartsWith("DB-IP Lite ", locator.Source);
}
[Fact]
public void A_connection_remembers_its_address_as_ipv4()
{
var connected = new ConnectedAddresses();
connected.Remember("Social.Example", IPAddress.Parse("::ffff:203.0.113.7"));
Assert.Equal(IPAddress.Parse("203.0.113.7"), connected.Of("social.example"));
Assert.Null(connected.Of("other.example"));
Assert.Equal("Fastly", CdnNetworks.Of(54113));
Assert.Null(CdnNetworks.Of(64496));
Assert.Null(CdnNetworks.Of(default));
}
}
}
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public sealed class HostCircuitBreakerTests : IAsyncLifetime
{
readonly string _host = $"breaker{Guid.NewGuid():N}.example";
public ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (MongoFixture.Enabled)
await DB.Default.DeleteAsync<RemoteInstance>(i => i.Host == _host);
}
[Fact]
public async Task A_host_is_quarantined_only_past_the_threshold_and_freed_by_a_success()
{
var token = TestContext.Current.CancellationToken;
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
for (var i = 1; i < HostCircuitBreaker.Threshold; i++)
await breaker.Failed(_host, "503", token);
Assert.Null(await breaker.UnavailableUntil(_host, token));
await breaker.Failed(_host, "503", token);
var until = await breaker.UnavailableUntil(_host, token);
Assert.NotNull(until);
Assert.InRange(until.Value, DateTime.UtcNow.AddMinutes(55), DateTime.UtcNow.AddMinutes(65));
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
Assert.Equal(HostCircuitBreaker.Threshold, instance.ConsecutiveFailures);
Assert.Equal("503", instance.LastError);
await breaker.Succeeded(_host, token);
Assert.Null(await breaker.UnavailableUntil(_host, token));
instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
Assert.Equal(0, instance.ConsecutiveFailures);
Assert.NotNull(instance.LastSuccessAt);
}
[Fact]
public async Task A_healthy_host_is_written_at_most_once_an_hour()
{
var token = TestContext.Current.CancellationToken;
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
await breaker.Succeeded(_host, token);
var first = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
await breaker.Succeeded(_host, token);
var second = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
Assert.Equal(first, second);
}
}
}
@@ -0,0 +1,64 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public class IndexTests
{
[Fact]
public async Task Duplicates_are_removed_and_the_unique_indexes_then_hold()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
await DB.Default.Index<ForeignAvatar>().DropAllAsync(token);
await DB.Default.Index<Follower>().DropAllAsync(token);
var actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
var localId = Guid.NewGuid().ToString("N")[..24];
await DB.Default.SaveAsync(new[]
{
new ForeignAvatar { ActorURI = actorUri, Name = "old", UpdatedAt = DateTime.UtcNow.AddDays(-2) },
new ForeignAvatar { ActorURI = actorUri, Name = "new", UpdatedAt = DateTime.UtcNow }
}, token);
await DB.Default.SaveAsync(new[]
{
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = false },
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = true }
}, token);
await new _001_deduplicate_before_unique_indexes().UpgradeAsync();
await Indexes.Create(token);
Assert.Equal("new", (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteSingleAsync(token)).Name);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == localId).ExecuteSingleAsync(token)).IsAccepted);
var duplicate = await Assert.ThrowsAsync<MongoWriteException>(async () =>
await DB.Default.SaveAsync(new ForeignAvatar { ActorURI = actorUri }, token));
Assert.Equal(ServerErrorCategory.DuplicateKey, duplicate.WriteError.Category);
}
[Fact]
public async Task A_username_is_reserved_once_across_personas_and_groups()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var name = $"n{Guid.NewGuid():N}"[..20];
Assert.True(await local.TryReserveUserName(name, LocalActorKind.Person, "a", token));
Assert.False(await local.TryReserveUserName(name.ToUpperInvariant(), LocalActorKind.Group, "b", token));
Assert.True(await local.IsUserNameTaken(name, token));
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
}
}
}
@@ -0,0 +1,86 @@
using System.Net;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public class IpRangeGuardTests
{
[Theory]
[InlineData("1.1.1.1")]
[InlineData("8.8.8.8")]
[InlineData("93.184.215.14")]
[InlineData("172.15.255.255")]
[InlineData("172.32.0.1")]
[InlineData("100.63.255.255")]
[InlineData("100.128.0.0")]
[InlineData("2a01:4f8::1")]
[InlineData("2606:4700:4700::1111")]
[InlineData("::ffff:1.1.1.1")]
public void Public_addresses_are_allowed(string address) =>
Assert.True(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Theory]
[InlineData("0.0.0.0")]
[InlineData("10.1.2.3")]
[InlineData("100.64.0.1")]
[InlineData("100.127.255.255")]
[InlineData("127.0.0.1")]
[InlineData("127.255.255.254")]
[InlineData("169.254.169.254")]
[InlineData("172.16.0.1")]
[InlineData("172.31.255.255")]
[InlineData("192.0.0.170")]
[InlineData("192.0.2.1")]
[InlineData("192.88.99.1")]
[InlineData("192.168.1.1")]
[InlineData("198.18.0.1")]
[InlineData("198.51.100.7")]
[InlineData("203.0.113.9")]
[InlineData("224.0.0.251")]
[InlineData("239.255.255.250")]
[InlineData("240.0.0.1")]
[InlineData("255.255.255.255")]
[InlineData("::")]
[InlineData("::1")]
[InlineData("::127.0.0.1")]
[InlineData("::ffff:127.0.0.1")]
[InlineData("::ffff:10.0.0.1")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("64:ff9b::7f00:1")]
[InlineData("64:ff9b::808:808")]
[InlineData("64:ff9b:1::1")]
[InlineData("100::1")]
[InlineData("2001::1")]
[InlineData("2001:0:4136:e378:8000:63bf:3fff:fdd2")]
[InlineData("2001:db8::1")]
[InlineData("2001:10::1")]
[InlineData("2002:7f00:1::1")]
[InlineData("2002:c0a8:101::1")]
[InlineData("3fff::1")]
[InlineData("fc00::1")]
[InlineData("fd12:3456:789a::1")]
[InlineData("fe80::1")]
[InlineData("fec0::1")]
[InlineData("ff02::1")]
public void Private_and_special_addresses_are_refused(string address) =>
Assert.False(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Fact]
public async Task Connect_refuses_a_name_that_resolves_to_loopback()
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint("localhost", 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("[::1]")]
[InlineData("169.254.169.254")]
public async Task Connect_refuses_a_private_literal(string host)
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint(host, 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
}
}
@@ -0,0 +1,94 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
public class BackoffTests
{
[Fact]
public void Grows_like_mastodons()
{
Assert.InRange(Backoff.After(1).TotalSeconds, 16, 16 + 9 * 2);
Assert.InRange(Backoff.After(10).TotalSeconds, 10015, 10015 + 9 * 11);
}
[Fact]
public void Quarantines_a_host_only_past_the_threshold()
{
Assert.Equal(TimeSpan.Zero, Backoff.HostQuarantine(9, 10));
Assert.Equal(TimeSpan.FromHours(1), Backoff.HostQuarantine(10, 10));
Assert.Equal(TimeSpan.FromHours(4), Backoff.HostQuarantine(12, 10));
Assert.Equal(TimeSpan.FromDays(7), Backoff.HostQuarantine(40, 10));
}
}
[Trait("Category", "Integration")]
public sealed class JobQueueTests : IAsyncLifetime
{
public ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static Job NewJob(string host = default, string dedupe = default) => new()
{
Kind = (JobKind)99,
Host = host,
DedupeKey = dedupe,
Payload = "{}"
};
[Fact]
public async Task A_dedupe_key_is_queued_once()
{
var queue = new JobQueue();
var key = Guid.NewGuid().ToString("N");
var inserted = await queue.EnqueueMany(new[] { NewJob(dedupe: key), NewJob(dedupe: key) }, TestContext.Current.CancellationToken);
Assert.Equal(1, inserted);
}
[Fact]
public async Task A_busy_host_is_skipped_and_a_failed_job_backs_off()
{
var token = TestContext.Current.CancellationToken;
var queue = new JobQueue();
var kind = (JobKind)(100 + Random.Shared.Next(1000));
var busy = $"busy{Guid.NewGuid():N}.example";
var free = $"free{Guid.NewGuid():N}.example";
await queue.EnqueueMany(new[] { new Job { Kind = kind, Host = busy }, new Job { Kind = kind, Host = free } }, token);
var leased = await queue.Lease(kind, new[] { busy }, token);
Assert.Equal(free, leased.Host);
Assert.Equal(1, leased.Attempts);
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
var after = await DB.Default.Find<Job>().OneAsync(leased.ID, token);
Assert.Equal(JobState.Pending, after.State);
Assert.True(after.RunAt > DateTime.UtcNow.AddSeconds(10));
leased.Attempts = 3;
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
Assert.Equal(JobState.Dead, (await DB.Default.Find<Job>().OneAsync(leased.ID, token)).State);
}
[Fact]
public async Task An_expired_lease_returns_to_the_queue()
{
var token = TestContext.Current.CancellationToken;
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
await DB.Default.SaveAsync(job, token);
await new JobQueue(j => j.ID == job.ID).Reap(token);
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
}
}
}
@@ -0,0 +1,224 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Infrastructure
{
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public class MigrationTests
{
[Fact]
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { remote, local }, token);
await new _002_sanitize_stored_content().UpgradeAsync();
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
Assert.Equal("<p>hi</p>", migratedRemote.Text);
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml);
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
}
[Fact]
public async Task Direct_posts_move_into_posts_with_their_conversation()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
await DB.Default.SaveAsync(dm, token);
await new _005_direct_posts_join_posts().UpgradeAsync();
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
Assert.Equal(PostVisibility.Direct, post.Visibility);
Assert.Equal("conversation1", post.ConversationId);
Assert.Equal("a1", post.AuthorAccountId);
}
[Fact]
public async Task Fetched_records_lose_the_signature_they_never_had_and_every_record_gets_its_extensions()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var fetched = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Fetched, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Algorithm = "rsa-sha256", SignedHeaders = new() { "host" }, ActivityContext = "\"https://www.w3.org/ns/activitystreams\"",
Raw = "{\"type\":\"Note\",\"quoteUrl\":\"https://q.example/1\"}"
};
var delivered = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Delivered, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Raw = "not json"
};
await DB.Default.SaveAsync(new[] { fetched, delivered }, token);
await new _008_fetched_records_lose_the_trigger_signature().UpgradeAsync();
await new _009_object_records_keep_their_extensions().UpgradeAsync();
var afterFetched = await DB.Default.Find<ObjectRecord>().OneAsync(fetched.ID, token);
var afterDelivered = await DB.Default.Find<ObjectRecord>().OneAsync(delivered.ID, token);
Assert.Null(afterFetched.KeyId);
Assert.Null(afterFetched.SignatureScheme);
Assert.Empty(afterFetched.SignedHeaders);
Assert.Null(afterFetched.ActivityContext);
Assert.Equal("https://x.example/u#k", afterDelivered.KeyId);
Assert.Contains("legacy-quote", afterFetched.Extensions);
Assert.Empty(afterDelivered.Extensions);
}
[Fact]
public async Task Conversations_get_their_participants_key_groups_become_circles_and_their_posts_stay_home()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var members = new List<GroupMember> { new() { AvatarId = "a1" }, new() { AvatarId = "https://r.example/u/b", IsForeign = true } };
var conversation = new DmGroup { Members = members };
var group = new GroupEntity { UserName = $"old{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community };
await DB.Default.SaveAsync(conversation, token);
await DB.Default.SaveAsync(group, token);
var localInGroup = new Post { GroupId = group.ID, Text = "inside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
var remoteInGroup = new Post { GroupId = group.ID, IsFederatedCopy = true, Text = "visiting", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var ungrouped = new Post { Text = "outside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { localInGroup, remoteInGroup, ungrouped }, token);
//the migration turns every group into a circle and keeps every local group post home: put the others back afterwards
var communities = (await DB.Default.Find<GroupEntity>().Match(g => g.Kind != GroupKind.Circle && g.ID != group.ID).ExecuteAsync(token)).Select(g => g.ID).ToList();
var federatedGroupPosts = (await DB.Default.Find<Post>().Match(p => p.GroupId != null && !p.IsFederatedCopy && !p.IsLocalOnly && p.ID != localInGroup.ID)
.ExecuteAsync(token)).Select(p => p.ID).ToList();
try
{
await new _003_conversation_keys_and_circles().UpgradeAsync();
Assert.Equal(DmGroup.KeyOf(members), (await DB.Default.Find<DmGroup>().OneAsync(conversation.ID, token)).ParticipantsKey);
Assert.Equal(GroupKind.Circle, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).Kind);
Assert.True((await DB.Default.Find<Post>().OneAsync(localInGroup.ID, token)).IsLocalOnly);
Assert.False((await DB.Default.Find<Post>().OneAsync(remoteInGroup.ID, token)).IsLocalOnly);
Assert.False((await DB.Default.Find<Post>().OneAsync(ungrouped.ID, token)).IsLocalOnly);
}
finally
{
if (communities.Count > 0)
await DB.Default.Update<GroupEntity>().Match(g => communities.Contains(g.ID)).Modify(g => g.Kind, GroupKind.Community).ExecuteAsync(token);
if (federatedGroupPosts.Count > 0)
await DB.Default.Update<Post>().Match(p => federatedGroupPosts.Contains(p.ID)).Modify(p => p.IsLocalOnly, false).ExecuteAsync(token);
}
}
[Fact]
public async Task Pending_deliveries_move_to_the_job_queue_once_and_the_rest_are_left_alone()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var host = $"r{Guid.NewGuid():N}.example";
var activityId = $"https://privapub.test/a/{Guid.NewGuid():N}";
var body = new JsonObject { ["id"] = activityId, ["type"] = "Create" }.ToJsonString();
var nextAttempt = DateTime.UtcNow.AddMinutes(7);
Delivery Legacy(string inbox, DateTime? deliveredAt = default, DateTime? abandonedAt = default) => new()
{
SignerId = "000000000000000000000001", SignerKind = LocalActorKind.Person, InboxURL = inbox, Body = body, Attempts = 3,
NextAttemptAt = nextAttempt, DeliveredAt = deliveredAt, AbandonedAt = abandonedAt
};
var pending = Legacy($"https://{host.ToUpperInvariant()}/inbox");
var twice = Legacy($"https://{host}/shared");
var already = Legacy($"https://{host}/shared");
var broken = Legacy("not an address");
var delivered = Legacy($"https://{host}/done", deliveredAt: DateTime.UtcNow.AddDays(-1));
var abandoned = Legacy($"https://{host}/given-up", abandonedAt: DateTime.UtcNow.AddDays(-1));
await DB.Default.SaveAsync(new[] { pending, twice, already, broken, delivered, abandoned }, token);
await new _004_pending_deliveries_become_jobs().UpgradeAsync();
var jobs = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(activityId + "|")).ExecuteAsync(token);
Assert.Equal(2, jobs.Count);
var job = Assert.Single(jobs, j => j.DedupeKey == $"{activityId}|{pending.InboxURL}");
Assert.Equal(host, job.Host);
Assert.Equal(3, job.Attempts);
Assert.InRange(job.RunAt, nextAttempt.AddSeconds(-1), nextAttempt.AddSeconds(1));
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
Assert.Equal((pending.SignerId, LocalActorKind.Person, pending.InboxURL, body), (payload.SignerId, payload.SignerKind, payload.Inbox, payload.Body));
Assert.Contains(jobs, j => j.DedupeKey == $"{activityId}|{twice.InboxURL}");
foreach (var moved in new[] { pending, twice, already, broken })
{
var after = await DB.Default.Find<Delivery>().OneAsync(moved.ID, token);
Assert.NotNull(after.AbandonedAt);
Assert.Equal("moved to the job queue", after.LastError);
}
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(delivered.ID, token)).AbandonedAt);
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(abandoned.ID, token)).LastError);
}
[Fact]
public async Task Year_one_edit_dates_become_no_edit_and_year_one_revisions_take_the_creation_date()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var created = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc);
var edited = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc);
var broken = new Post
{
IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = DateTime.MinValue, UpdateDate = DateTime.MinValue,
Revisions = new() { new PostRevision { ContentHtml = "<p>first</p>", EditedAt = DateTime.MinValue }, new PostRevision { ContentHtml = "<p>second</p>", EditedAt = edited } }
};
var fine = new Post { IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = edited, UpdateDate = edited };
await DB.Default.SaveAsync(new[] { broken, fine }, token);
await new _006_unedited_remote_posts_lose_year_one().UpgradeAsync();
var repaired = await DB.Default.Find<Post>().OneAsync(broken.ID, token);
Assert.Null(repaired.EditedAt);
Assert.Equal(created, repaired.UpdateDate);
Assert.Equal(new[] { created, edited }, repaired.Revisions.Select(r => r.EditedAt));
var untouched = await DB.Default.Find<Post>().OneAsync(fine.ID, token);
Assert.Equal((edited, edited), (untouched.EditedAt, untouched.UpdateDate));
}
[Fact]
public async Task Personas_and_groups_without_a_published_day_get_one_within_two_weeks_before_creation()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var created = new DateTime(2026, 6, 15, 18, 30, 0, DateTimeKind.Utc);
var avatar = new Avatar { UserName = $"aged{Guid.NewGuid():N}"[..20], CreatedAt = created };
var group = new GroupEntity { UserName = $"aged{Guid.NewGuid():N}"[..20], CreationDate = created };
var dated = new Avatar { UserName = $"dated{Guid.NewGuid():N}"[..20], CreatedAt = created, PublishedOn = new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc) };
await DB.Default.SaveAsync(new[] { avatar, dated }, token);
await DB.Default.SaveAsync(group, token);
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(b => b.Unset(a => a.PublishedOn)).ExecuteAsync(token);
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(b => b.Unset(g => g.PublishedOn)).ExecuteAsync(token);
Assert.False(await DB.Default.Find<Avatar>().Match(new BsonDocument("_id", ObjectId.Parse(avatar.ID)).Add(nameof(Avatar.PublishedOn), new BsonDocument("$exists", true))).ExecuteAnyAsync(token));
await new _007_personas_publish_a_spread_day().UpgradeAsync();
foreach (var published in new[] { (await DB.Default.Find<Avatar>().OneAsync(avatar.ID, token)).PublishedOn, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).PublishedOn })
{
Assert.Equal(TimeSpan.Zero, published.TimeOfDay);
Assert.InRange(published, created.Date.AddDays(-13), created.Date);
}
Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find<Avatar>().OneAsync(dated.ID, token)).PublishedOn);
}
}
}
@@ -0,0 +1,70 @@
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));
}
}
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PrivaPub\PrivaPub.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,126 @@
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<string, long> { ["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<string, long>
{
["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<string, long> { ["ok"] = 4, ["retry"] = 2 }, StatisticsQueries.Outcomes(counters, "out"));
Assert.Equal(new Dictionary<string, long> { ["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<InteractionLedger>().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<bool>());
Assert.True(today["counters"]!["in:Create:Note:accepted:stored"]!.GetValue<long>() >= 1);
Assert.Contains("DB-IP", host["attribution"]!.GetValue<string>());
Assert.True(overview["channels"]!["in"]!.GetValue<long>() >= 1);
Assert.Contains(hosts["hosts"]!.AsArray(), h => h!["host"]!.GetValue<string>() == "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<Job>().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<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == describe).ExecuteAnyAsync(token));
}
}
}
+276
View File
@@ -0,0 +1,276 @@
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<string>();
FederationHttp.ReadStrings(Encoding.UTF8.GetBytes("[\"a.example\", 3, {\"x\":1}, \"b.example\", \"c.exa"), 10, items);
var capped = new List<string>();
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<StatisticsOptions>(new StatisticsOptions { Crawler = crawler }),
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }),
new StaticOptions<FederationOptions>(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<DomainBlocks>.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<RemoteInstance>().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<RemoteInstance>().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<RemoteInstance>().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<RemoteInstance>().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<RemoteInstance>(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<DomainBlocks>.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<StatisticsOptions>(options)).Handle(new Job(), token);
await new CrawlPlanner(new JobQueue(j => j.DedupeKey == "never"), blocks, new StaticOptions<StatisticsOptions>(new StatisticsOptions())).Handle(new Job(), token);
var planned = (await DB.Default.Find<Job>().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<RemoteInstance>().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("<strong>off</strong>", page);
Assert.Contains("Disallow: /", page);
Assert.Contains("DB-IP", page);
Assert.StartsWith("default-src 'none'", response.Headers.GetValues("Content-Security-Policy").Single());
}
}
}
@@ -0,0 +1,111 @@
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<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness == default)
return;
await _harness.DisposeAsync();
await DB.Default.DeleteAsync<RemoteInstance>(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<DeliveryJobHandler>.Instance, ledger);
signer ??= (await _harness.Persona("sender")).Avatar;
var id = activity["id"]!.GetValue<string>();
await _harness.Delivery.Enqueue(signer, new[] { _harness.Peer.A + path }, activity, TestContext.Current.CancellationToken);
var job = await DB.Default.Find<Job>().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);
}
}
}
+261
View File
@@ -0,0 +1,261 @@
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":"<p>A <b>small</b>\n place</p>","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);
}
[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("[]")));
}
[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_depends_on_size_and_on_a_cdn()
{
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(new RemoteInstance { Geo = geo, UsersTotal = 10 }, 10));
Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new RemoteInstance { Geo = geo, UsersTotal = 1 }, 10));
Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new RemoteInstance { Geo = geo }, 10));
Assert.Equal(new PublicLocation(null, null, null, null, null, "Cloudflare"),
PublicGeo.Project(new RemoteInstance { Geo = new InstanceGeo { Country = "US", City = "SF", Cdn = "Cloudflare" }, UsersTotal = 5000 }, 10));
Assert.Null(PublicGeo.Project(new RemoteInstance(), 10));
}
}
[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) : base(http, geo, addresses) => _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<RemoteInstance>().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<RemoteInstanceSnapshot>().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 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<RemoteInstance>().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<RemoteInstance>().Match(i => i.Host == gone).ExecuteSingleAsync(token);
Assert.Equal("no NodeInfo", goneOne.DescriptionError);
Assert.False((await DB.Default.Find<RemoteInstanceSnapshot>().Match(s => s.Host == gone).ExecuteSingleAsync(token)).Reachable);
}
[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<RemoteInstance>().Match(i => i.Host == touched).ExecuteSingleAsync(token)).Seen);
var shyOne = await DB.Default.Find<RemoteInstance>().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<DomainBlocks>.Instance);
blocks.Load(new[] { new DomainBlock { Domain = spammer, Severity = DomainBlockSeverity.Suspend } });
var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.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<RemoteInstance>().Match(i => i.Host == friend).ExecuteSingleAsync(token);
Assert.Equal("touched", seen.Seen);
Assert.NotNull(seen.FirstSeenAt);
Assert.Single(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == friend).ExecuteAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == looked).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == spammer).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<RemoteInstance>().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<RemoteInstance>().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));
}
}
}
@@ -0,0 +1,211 @@
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<string> 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"] = "<p>hello</p>",
["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));
}
}
}
@@ -0,0 +1,169 @@
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<DomainBlocks>.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<InboxReceiver>.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);
}
}
}
@@ -0,0 +1,105 @@
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"));
}
[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<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.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<StatisticsOptions>(new StatisticsOptions { Enabled = false }), NullLogger<InteractionLedger>.Instance);
for (var i = 0; i < 10_005; i++)
ledger.Record(new InteractionEvent { Channel = Interactions.In });
Assert.Equal(0, ledger.Dropped);
}
}
}
@@ -0,0 +1,113 @@
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<List<BsonDocument>> StoredSince(DateTime since, CancellationToken token)
{
await _host.Get<InteractionLedger>().Flush(token);
return await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent))
.Find(Builders<BsonDocument>.Filter.Gte(nameof(InteractionEvent.At), since) & Builders<BsonDocument>.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<InteractionLedger>().Flush(token);
var before = await DB.Default.Find<ServerDay>().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<InteractionLedger>().Flush(token);
var after = await DB.Default.Find<ServerDay>().Match(d => d.Day == day).ExecuteFirstAsync(token);
long Grew(Dictionary<string, long> now, Dictionary<string, long> 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));
}
}
}
@@ -0,0 +1,89 @@
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<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.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<InteractionEvent>().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<InteractionEvent>().Match(e => e.Host == _stranger).ExecuteAnyAsync(token));
var raw = await DB.Default.Database().GetCollection<BsonDocument>(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<InstanceDay>().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<ServerDay>().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);
}
}
}
@@ -0,0 +1,142 @@
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", "<html></html>", "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"));
}
}
}
+152
View File
@@ -0,0 +1,152 @@
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<InteractionEvent> 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<string, long>
{
["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<InstanceDay>().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<InstanceDay>().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<InteractionSalt>().Match(s => s.Day == Day).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().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<StatisticsSchedule>.Instance).Plan(token);
var today = await DB.Default.Find<Job>().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<Job>().Match(j => j.DedupeKey == $"rollup|{RollupJob.DayKey(missed)}").ExecuteAnyAsync(token));
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace PrivaPub.Tests.Support
{
[CollectionDefinition(nameof(Exclusive), DisableParallelization = true)]
public sealed class Exclusive
{
}
}
+55
View File
@@ -0,0 +1,55 @@
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 = "<p>hello</p>", 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<string>();
//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<HttpStatusCode> 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..];
}
}
+166
View File
@@ -0,0 +1,166 @@
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.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<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
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);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes),
new RejectHandler(Db, Local, Quotes),
new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions),
new QuoteRequestHandler(Quotes),
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local)
};
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
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<IActivityHandler> 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 TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
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<Harness> 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<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
{
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
if (job != default)
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
return result;
}
public async Task<List<JsonObject>> Outgoing(string inbox) =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync())
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(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<T> : IStringLocalizer<T>
{
public LocalizedString this[string name] => new(name, name);
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
}
public sealed class NoPreviews : ILinkPreviews
{
public Task Wanted(PrivaPub.Models.Post.Post post, CancellationToken token) => Task.CompletedTask;
}
}
+117
View File
@@ -0,0 +1,117 @@
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<Root> 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<JsonObject>())!;
return new Root(jwt["userId"]!.GetValue<string>(), userName, Password, jwt["token"]!.GetValue<string>());
}
public static async Task<Root> 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<JsonObject>())!;
return root with { Jwt = jwt["token"]!.GetValue<string>() };
}
public static async Task<Root> Admin(this PrivaPubHost host)
{
var root = await host.SignUp("admin");
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
return await host.LogIn(root);
}
public static async Task<Persona> 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<JsonObject>())!;
return new Persona(avatar["id"]!.GetValue<string>(), userName, root);
}
public static async Task<string> 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<string>();
var clientSecret = app["client_secret"]!.GetValue<string>();
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<string>();
}
public static async Task<string> 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<string, string>
{
["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<string, string>(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<JsonObject> Form(HttpClient client, string path, params (string Key, string Value)[] fields)
{
var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(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("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
private static partial Regex HiddenInput();
[GeneratedRegex("<code>([^<]*)</code>")]
private static partial Regex Code();
}
}
@@ -0,0 +1,166 @@
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<JsonObject> JsonBody(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
public static async Task<JsonArray> JsonItems(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray();
public static Task<HttpResponseMessage> PostJson(this HttpClient client, string path, object body) =>
client.PostAsJsonAsync(path, body);
public static Task<HttpResponseMessage> Form(this HttpClient client, string path, params (string Key, string Value)[] fields) =>
client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
public static async Task<OAuthApp> 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<string>(), app["client_secret"]!.GetValue<string>(), scopes);
}
public static async Task<HttpResponseMessage> 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<string, string>
{
["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<List<KeyValuePair<string, string>>> Choice(this HttpClient client, OAuthApp app)
{
var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1");
return HiddenInput().Matches(page).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
}
public static Task<HttpResponseMessage> Decide(this HttpClient client, IEnumerable<KeyValuePair<string, string>> 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<HttpResponseMessage> 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<string> 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<HttpResponseMessage> 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<string> 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<string>();
}
public static async Task<JsonObject> 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<RootUser>().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync();
public static async Task<JsonObject> 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<string>()));
return follow;
}
public static async Task<List<BsonDocument>> StoredTokens(string clientId)
{
var database = DB.Default.Database();
var application = await database.GetCollection<BsonDocument>("openiddict.applications")
.Find(Builders<BsonDocument>.Filter.Eq("client_id", clientId)).FirstAsync();
return await database.GetCollection<BsonDocument>("openiddict.tokens")
.Find(Builders<BsonDocument>.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("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
private static partial Regex HiddenInput();
[GeneratedRegex("<code>([^<]*)</code>")]
private static partial Regex CodeElement();
}
}
@@ -0,0 +1,141 @@
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<Fetched> 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<Fetched> 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<LocalActor> Actor(this PrivaPubHost host, Persona persona) =>
await host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
public static async Task<Post> Publish(this PrivaPubHost host, Persona persona, StatusDraft draft)
{
var outcome = await host.Get<IStatusService>().Publish(await host.Actor(persona), draft, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
public static Task<Post> 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<Post> 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<IStatusService>().Remove(await host.Actor(persona), post.ID, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
}
public static async Task<Post> Reblog(this PrivaPubHost host, Persona persona, Post original)
{
var outcome = await host.Get<IStatusService>().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<Post> 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 = "<p>a copy that is not ours to serve</p>"
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
public static async Task<LocalGroup> 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<JsonObject>(TestContext.Current.CancellationToken))!;
return new LocalGroup(group["id"]!.GetValue<string>(), userName);
}
public static async Task AddForeignMember(this LocalGroup group, string actorUri) =>
await DB.Default.Update<GroupEntity>().MatchID(group.Id)
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = actorUri, IsForeign = true }))
.ExecuteAsync(TestContext.Current.CancellationToken);
public static IEnumerable<JsonNode> 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<string> Keys(JsonNode node) =>
Nodes(node).OfType<JsonObject>().SelectMany(o => o.Select(p => p.Key));
public static IEnumerable<string> Strings(JsonNode node) =>
Nodes(node).OfType<JsonValue>().Where(v => v.TryGetValue<string>(out _)).Select(v => v.GetValue<string>());
public static string PathOf(string url) => new Uri(url).PathAndQuery;
}
}
+64
View File
@@ -0,0 +1,64 @@
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<int> Run(this PrivaPubHost host, Expression<Func<Job, bool>> which, CancellationToken token = default)
{
var handlers = host.Services.GetServices<IJobHandler>().ToDictionary(h => h.Kind);
var queue = host.Get<IJobQueue>();
var ran = 0;
for (var round = 0; round < 50; round++)
{
var pending = await DB.Default.Find<Job>().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<Job>()
.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<int> 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<List<JsonObject>> Deliveries(string inbox, DateTime since, CancellationToken token = default) =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Inbox == inbox)
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
.ToList();
}
}
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more