Compare commits

...
26 Commits
Author SHA1 Message Date
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
169 changed files with 14588 additions and 646 deletions

No files matched your search

+2 -2
View File
@@ -15,5 +15,5 @@ jobs:
- name: Build - name: Build
run: dotnet build PrivaPub.sln -c Release run: dotnet build PrivaPub.sln -c Release
- name: Test - name: Test (unit and integration, on a throwaway mongod)
run: dotnet test PrivaPub.sln -c Release --no-build run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build
+6 -3
View File
@@ -28,8 +28,8 @@ 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 - name: Test (unit and integration, on a throwaway mongod)
run: dotnet test PrivaPub.sln -c Release run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
- name: Publish - name: Publish
run: | run: |
@@ -90,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; }
@@ -104,5 +106,6 @@ jobs:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ 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") --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; } [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL" 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"
+3
View File
@@ -402,3 +402,6 @@ FodyWeavers.xsd
PrivaPub/media-store/ PrivaPub/media-store/
PrivaPub/media-store-proxy/ PrivaPub/media-store-proxy/
tools/pasture/.publish/ tools/pasture/.publish/
.claude/worktrees/
tools/pasture/.ca/
tools/pasture/.state/
+80 -30
View File
@@ -124,6 +124,7 @@ dotnet build PrivaPub.sln -c Release
dotnet test PrivaPub.sln # unit tests; integration tests skip 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_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) # (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 \ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \ 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 \ AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
@@ -182,7 +183,9 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`): 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 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`). 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`. 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 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 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. fetched as context. Followers-only is detected by the author's stored `followers` URL.
@@ -198,7 +201,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read 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. 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`. 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. 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. 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, 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 cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
@@ -244,6 +250,17 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server 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. something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.** - **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 ## Data
@@ -257,13 +274,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's 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 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 revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. The raw form lives outside `Post` so timelines never load it. Read through 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`. `/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 - **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 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 `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). (that fetch is the owner's decision 1, for P6).
- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes - **An `Update` is an edit only when its `updated` is newer than ours** (`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 the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates. 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` - **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
@@ -316,18 +335,37 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`, stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
`NoteParser.Int`, `NoteParser.Time`). `NoteParser.Int`, `NoteParser.Time`).
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound - ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
documents are read through `InboxService.Id`/`Value`, which handle string, object and array. 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 + - 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. OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
## Testing ## Testing
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip `PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production). 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 - `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. can be tested; `Support/RemoteActor` signs real deliveries with its own key.
- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers. - 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: Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post); - the client API (sign up, create an avatar, a group, a post);
@@ -336,31 +374,43 @@ Beyond the tests, verify by building, running locally, and exercising:
Interop is checked against real servers, starting with the workstation's own pasture: Interop is checked against real servers, starting with the workstation's own pasture:
```bash ```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up [gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy
tools/pasture/interop.sh # 25 checks, each side driven through its own Mastodon API tools/pasture/interop.sh [gts mastodon ...] # each peer's scenario, then what PrivaPub's statistics saw of it
tools/pasture/run.sh down tools/pasture/run.sh down # removes every pasture container and volume
``` ```
- **Two sites on one podman network, one Caddy in front.** `privapub.test` and `gts.test` are network aliases of the - **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh`
Caddy container, which serves both with its internal CA (`tls internal`). Both servers are told to accept any for the Postgres and Redis several peers share), `lib/interop.sh` (`ok`, `ko`, `xf` for a check expected to fail until
certificate: PrivaPub through `appsettings.Pasture.json`, GoToSocial through `GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`. a later phase, `privapub_token <persona>`, `stats_check <host> <software>`), `scenarios/<name>.sh`. Each peer talks to
GoToSocial WebFingers and fetches over https only, so plain http between them is not an option. its own PrivaPub persona under one root, so OAuth's persona choice is exercised too. Images are pinned.
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. GoToSocial is reached as `https://gts.test:6443` - Caddy's CA lives in the `pasture-caddy-data` volume and is copied to `tools/pasture/.ca/root.crt` (and `bundle.pem`
with `curl -k --resolve gts.test:6443:127.0.0.1`, because its sign-in cookie is bound to the host name. with the system roots) for peers that must trust it instead of skipping verification.
- **GoToSocial's 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` - **All sites on one podman network, one Caddy in front.** `privapub.test`, `gts.test`, `mastodon.test` and the other
(`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves peers are network aliases of the Caddy container, which serves them all with its internal CA (`tls internal`).
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a delete PrivaPub accepts any certificate (`appsettings.Pasture.json`) and GoToSocial is told to skip verification
is checked as a 404 on `/api/v1/statuses/{id}`. (`GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`); Mastodon trusts the copied CA through `SSL_CERT_FILE`. Peers fetch over
- **GoToSocial creates its accounts locked**, so `interop.sh` approves alice's request through https only, so plain http between them is not an option.
`/api/v1/follow_requests`. That also checks our pending (`requested`) state and the manual Accept. - From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. A peer is reached as `https://<name>.test:6443` with
- The scenario covers: `curl -k --resolve <name>.test:6443:127.0.0.1`, because sign-in cookies are bound to the host name.
- discovery and follows both ways; - **GoToSocial (0.22.1):**
- posts and CW; - Its cached home timeline can stop taking new posts after its first read, its owner's own included, while a
- a reply and its notification; `min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
- likes and boosts both ways; (`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
- DMs both ways, and the DM staying off public timelines; nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a
- edit, delete both ways, and unfollow. 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).
- **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 - `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. `tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
@@ -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,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,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));
}
}
}
+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));
}
}
}
@@ -269,38 +269,14 @@ namespace PrivaPub.Tests.Federation
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice"); var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob", _peer.B); var bob = new RemoteActor(_peer, "bob", _peer.B);
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token); _blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
try var before = _peer.Requests.Count;
{
await _blocks.Reload(token);
var before = _peer.Requests.Count;
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
Assert.Equal(202, result.StatusCode); Assert.Equal(202, result.StatusCode);
Assert.Equal(before, _peer.Requests.Count); Assert.Equal(before, _peer.Requests.Count);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
}
finally
{
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
await _blocks.Reload(token);
}
}
[Fact]
public void A_block_covers_subdomains_but_not_lookalikes()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, DateTime.UtcNow);
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] [Fact]
@@ -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,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));
}
}
}
@@ -68,18 +68,5 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>()); 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)); Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
} }
[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);
}
} }
} }
@@ -32,6 +32,16 @@ namespace PrivaPub.Tests.Federation
await _peer.DisposeAsync(); 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") => static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
new JsonObject new JsonObject
{ {
@@ -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,548 @@
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"]));
Assert.Empty(quietNote["cc"]!.AsArray());
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,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);
}
}
}
@@ -12,6 +12,7 @@ using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure namespace PrivaPub.Tests.Infrastructure
{ {
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")] [Trait("Category", "Integration")]
public class IndexTests public class IndexTests
{ {
+4 -55
View File
@@ -1,19 +1,9 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Infrastructure namespace PrivaPub.Tests.Infrastructure
{ {
public class BackoffTests public class BackoffTests
@@ -38,19 +28,13 @@ namespace PrivaPub.Tests.Infrastructure
[Trait("Category", "Integration")] [Trait("Category", "Integration")]
public sealed class JobQueueTests : IAsyncLifetime public sealed class JobQueueTests : IAsyncLifetime
{ {
Peer _peer; public ValueTask InitializeAsync()
public async ValueTask InitializeAsync()
{ {
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start(); return ValueTask.CompletedTask;
} }
public async ValueTask DisposeAsync() public ValueTask DisposeAsync() => ValueTask.CompletedTask;
{
if (_peer != default)
await _peer.DisposeAsync();
}
static Job NewJob(string host = default, string dedupe = default) => new() static Job NewJob(string host = default, string dedupe = default) => new()
{ {
@@ -102,44 +86,9 @@ namespace PrivaPub.Tests.Infrastructure
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) }; var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
await DB.Default.SaveAsync(job, token); await DB.Default.SaveAsync(job, token);
await new JobQueue().Reap(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); Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
} }
[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 queue = new JobQueue();
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"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["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);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
await Task.Delay(50, token);
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
await Task.Delay(50, token);
await worker.StopAsync(token);
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token);
Assert.True(instance.ConsecutiveFailures > 0);
}
} }
} }
@@ -1,11 +1,24 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Data.Migrations; using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using System.Text.Json;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Infrastructure namespace PrivaPub.Tests.Infrastructure
{ {
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")] [Trait("Category", "Integration")]
public class MigrationTests public class MigrationTests
{ {
@@ -45,5 +58,167 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal("conversation1", post.ConversationId); Assert.Equal("conversation1", post.ConversationId);
Assert.Equal("a1", post.AuthorAccountId); 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);
}
} }
} }
+1
View File
@@ -9,6 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" /> <PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -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..];
}
}
+12 -5
View File
@@ -34,6 +34,8 @@ namespace PrivaPub.Tests.Support
public const string Host = "privapub.test"; public const string Host = "privapub.test";
public const string Base = "https://" + Host; public const string Base = "https://" + Host;
readonly DateTime _started = Millisecond(DateTime.UtcNow);
Harness(Peer peer) Harness(Peer peer)
{ {
Peer = peer; Peer = peer;
@@ -49,8 +51,8 @@ namespace PrivaPub.Tests.Support
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery); Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Processor = new InboxProcessor(Remote, new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes), new AcceptHandler(Db, Local, Quotes),
@@ -61,12 +63,13 @@ namespace PrivaPub.Tests.Support
new QuoteRequestHandler(Quotes), new QuoteRequestHandler(Quotes),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), 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 CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local) new FlagHandler(Db, Local)
}, NullLogger<InboxProcessor>.Instance); };
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
@@ -80,12 +83,14 @@ namespace PrivaPub.Tests.Support
public Peer Peer { get; } public Peer Peer { get; }
public DbEntities Db { get; } = new(); public DbEntities Db { get; } = new();
public MemoryLedger Ledger { get; } = new();
public JobQueue Queue { get; } = new(); public JobQueue Queue { get; } = new();
public LocalActorService Local { get; } public LocalActorService Local { get; }
public RemoteActorService Remote { get; } public RemoteActorService Remote { get; }
public DeliveryService Delivery { get; } public DeliveryService Delivery { get; }
public InboxReceiver Receiver { get; } public InboxReceiver Receiver { get; }
public InboxProcessor Processor { get; } public InboxProcessor Processor { get; }
public IReadOnlyList<IActivityHandler> Handlers { get; }
public FollowService Follows { get; } public FollowService Follows { get; }
public ContentRenderer Content { get; } public ContentRenderer Content { get; }
public OutboxPublisher Outbox { get; } public OutboxPublisher Outbox { get; }
@@ -136,13 +141,15 @@ namespace PrivaPub.Tests.Support
} }
public async Task<List<JsonObject>> Outgoing(string inbox) => public async Task<List<JsonObject>> Outgoing(string inbox) =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync()) (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync())
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload)) .Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Inbox == inbox) .Where(p => p.Inbox == inbox)
.Select(p => JsonNode.Parse(p.Body)!.AsObject()) .Select(p => JsonNode.Parse(p.Body)!.AsObject())
.ToList(); .ToList();
public async ValueTask DisposeAsync() => await Peer.DisposeAsync(); 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 sealed class KeyLocalizer<T> : IStringLocalizer<T>
+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();
}
}
@@ -0,0 +1,232 @@
using MongoDB.Entities;
using NetVips;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace PrivaPub.Tests.Support.Host
{
public sealed record Mastodon(Persona Persona, string Token, HttpClient Client)
{
public string Id => Persona.Id;
public string UserName => Persona.UserName;
public string Uri => $"{PrivaPubHost.Base}/peasants/{Persona.UserName}";
public string Mouth => $"/peasants/{Persona.UserName}/mouth";
}
public sealed record ApiAnswer(HttpStatusCode Status, JsonNode Body, string Text, HttpResponseHeaders Headers)
{
public JsonObject Object => Body.AsObject();
public JsonArray Array => Body.AsArray();
public IEnumerable<string> Ids => Array.Select(item => item!["id"]!.GetValue<string>());
public ApiAnswer Ok()
{
Assert.True(Status == HttpStatusCode.OK, $"expected 200, got {(int)Status}: {Text}");
return this;
}
public string Link(string rel) =>
Headers.TryGetValues("Link", out var values)
? Regex.Matches(string.Join(", ", values), "<([^>]+)>; rel=\"([a-z]+)\"").FirstOrDefault(m => m.Groups[2].Value == rel)?.Groups[1].Value
: default;
}
public static class MastodonHelpers
{
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
public static async Task<Mastodon> Mastodon(this PrivaPubHost host, string name = "persona", Root root = default, string scopes = "read write follow")
{
root ??= await host.SignUp();
var persona = await host.Persona(root, name);
var token = await host.MastodonToken(persona, scopes);
return new Mastodon(persona, token, host.As(token));
}
public static Task<ApiAnswer> Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path);
public static Task<ApiAnswer> Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form);
public static Task<ApiAnswer> Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form);
public static Task<ApiAnswer> Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form);
public static Task<ApiAnswer> Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form);
public static Task<ApiAnswer> Call(this HttpClient client, HttpMethod method, string path, params (string Key, string Value)[] form)
{
var request = new HttpRequestMessage(method, path);
if (form.Length > 0)
request.Content = new FormUrlEncodedContent(form.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
return client.Exchange(request);
}
public static Task<ApiAnswer> Json(this HttpClient client, HttpMethod method, string path, JsonNode body) =>
client.Exchange(new HttpRequestMessage(method, path) { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json") });
public static async Task<ApiAnswer> Exchange(this HttpClient client, HttpRequestMessage request)
{
using (request)
{
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
JsonNode body = default;
if (!string.IsNullOrWhiteSpace(text) && text.TrimStart()[0] is '{' or '[')
body = JsonNode.Parse(text);
return new ApiAnswer(response.StatusCode, body, text, response.Headers);
}
}
public static async Task<JsonObject> Status(this Mastodon account, string text, params (string Key, string Value)[] more) =>
(await account.Client.Post("/api/v1/statuses", more.Prepend(("status", text)).ToArray())).Ok().Object;
public static string Text(this JsonNode node, string property) => node?[property]?.GetValue<string>();
public static int Number(this JsonNode node, string property) => node![property]!.GetValue<int>();
public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue<bool>();
public static async Task<ForeignAvatar> Known(this PrivaPubHost host, RemoteActor actor) =>
await host.Get<IRemoteActorService>().GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
public static string Handle(this RemoteActor actor) => $"{actor.Name}@{new Uri(actor.Id).Authority}";
public static string Inbox(this RemoteActor actor) => actor.Id + "/inbox";
public static string Origin(this RemoteActor actor) => actor.Id.Split("/users/")[0];
public static void WebFinger(this Peer peer, RemoteActor actor) =>
peer.ServeText("/.well-known/webfinger", new JsonObject
{
["subject"] = $"acct:{actor.Handle()}",
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Id })
}.ToJsonString(), "application/jrd+json");
public static async Task Deliver(this PrivaPubHost host, RemoteActor actor, string path, JsonObject activity)
{
using var client = host.Client();
using var response = await client.SendAsync(actor.SignedPost(path, activity), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
await host.RunInbox(activity["id"]!.GetValue<string>(), TestContext.Current.CancellationToken);
}
public static async Task<string> FollowedBy(this PrivaPubHost host, Mastodon account, RemoteActor actor)
{
var followId = $"{actor.Origin()}/follows/{Guid.NewGuid():N}";
await host.Deliver(actor, account.Mouth, new JsonObject
{
["id"] = followId,
["type"] = "Follow",
["actor"] = actor.Id,
["object"] = account.Uri
});
return followId;
}
public static JsonObject Create(this RemoteActor actor, string content, IEnumerable<string> to, IEnumerable<string> cc = default,
Action<JsonObject> shape = default)
{
var noteId = $"{actor.Origin()}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = noteId,
["type"] = "Note",
["attributedTo"] = actor.Id,
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray((cc ?? Enumerable.Empty<string>()).Select(c => (JsonNode)c).ToArray()),
["content"] = content,
["published"] = DateTime.UtcNow.ToString("O")
};
shape?.Invoke(note);
return new JsonObject
{
["id"] = noteId + "/activity",
["type"] = "Create",
["actor"] = actor.Id,
["to"] = note["to"]!.DeepClone(),
["cc"] = note["cc"]!.DeepClone(),
["object"] = note
};
}
public static async Task<Post> PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "<p>a remote thought</p>",
Action<JsonObject> shape = default)
{
var create = actor.Create(content, new[] { Public }, new[] { mentioned.Uri }, note =>
{
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName });
shape?.Invoke(note);
});
await host.Deliver(actor, mentioned.Mouth, create);
var noteId = create["object"]!["id"]!.GetValue<string>();
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
public static async Task<List<JsonObject>> Delivered(this RemoteActor actor, DateTime since) =>
await Jobs.Deliveries(actor.Inbox(), since, TestContext.Current.CancellationToken);
public static string Type(this JsonObject activity) => activity["type"]!.GetValue<string>();
public static byte[] JpegWithMetadata(int width, int height)
{
using var image = (NetVips.Image.Black(width, height, bands: 3) + new double[] { 30, 140, 200 }).Cast(Enums.BandFormat.Uchar);
using var tagged = image.Mutate(m =>
{
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
m.Set(GValue.BlobType, "xmp-data", Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
});
return tagged.WriteToBuffer(".jpg");
}
public static void AssertNoMetadata(byte[] image)
{
using var loaded = NetVips.Image.NewFromBuffer(image);
var fields = loaded.GetFields();
Assert.DoesNotContain("exif-data", fields);
Assert.DoesNotContain("xmp-data", fields);
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd", StringComparison.Ordinal));
}
public static MultipartFormDataContent Multipart(params (string Name, byte[] Bytes, string ContentType, string FileName)[] files)
{
var content = new MultipartFormDataContent();
foreach (var file in files)
{
var part = new ByteArrayContent(file.Bytes);
part.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
content.Add(part, file.Name, file.FileName);
}
return content;
}
public static async Task<ApiAnswer> ActivityPub(this PrivaPubHost host, string path)
{
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Accept.ParseAdd("application/activity+json");
return await host.Client().Exchange(request);
}
public static async Task<string> Located(this PrivaPubHost host, Mastodon account, string text)
{
using var client = host.As(account.Persona.Root.Jwt);
var response = await client.PostAsJsonAsync("/clientapi/post/insert",
new { avatarId = account.Id, text, latitude = 45.07, longitude = 7.68, rangeKm = 5 }, TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.IsSuccessStatusCode, body);
return JsonNode.Parse(body)!["id"]!.GetValue<string>();
}
public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
}
}
+161
View File
@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Domain.Media;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using System.Net;
namespace PrivaPub.Tests.Support.Host
{
public class PrivaPubHost : WebApplicationFactory<Program>
{
public const string Host = "privapub.test";
public const string Base = "https://" + Host;
public const string ClientHeader = "X-Test-Client";
static readonly SemaphoreSlim Boot = new(1, 1);
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule) };
static PrivaPubHost _shared;
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
public IReadOnlyList<ServiceDescriptor> Registered { get; private set; }
public static async Task<PrivaPubHost> Shared()
{
await Boot.WaitAsync();
try
{
if (_shared == default)
{
var host = new PrivaPubHost();
_ = host.Services;
_shared = host;
}
return _shared;
}
finally
{
Boot.Release();
}
}
protected virtual IEnumerable<KeyValuePair<string, string>> Settings() => new Dictionary<string, string>
{
["MongoSettings:ConnectionString"] = MongoFixture.Connection,
["MongoSettings:Database"] = MongoFixture.Database,
["MongoSettings:LogsDatabase"] = "logs",
["AppConfiguration:Version"] = "0.0.0-test",
["AppConfiguration:BackendBaseAddress"] = Base,
["AppConfiguration:MaxAllowedUploadFiles"] = "3",
["AppConfiguration:MaxAllowedFileSize"] = "2097152",
["AppConfiguration:SupportedLanguages:0"] = "en",
["AppConfiguration:SupportedLanguages:1"] = "it",
["AppConfiguration:HashingOptions:Iterations"] = "1000",
["AppConfiguration:Jwt:Key"] = "privapub-tests-only-signing-key-0123456789abcdef0123456789abcdef",
["AppConfiguration:Jwt:Issuer"] = Base,
["AppConfiguration:Jwt:Audience"] = Base,
["AppConfiguration:Jwt:HoursTimeout"] = "1",
["AppConfiguration:EmailConfiguration:SmtpServer"] = "127.0.0.1",
["AppConfiguration:EmailConfiguration:SmtpPort"] = "9",
["AppConfiguration:EmailConfiguration:UseSSL"] = "false",
["AppConfiguration:EmailConfiguration:SmtpUsername"] = "nobody@privapub.test",
["AppConfiguration:EmailConfiguration:SmtpPassword"] = "none",
["Federation:AllowPrivateNetworks"] = "true",
["Federation:AllowPlainHttp"] = "true",
["Federation:FetchLinkPreviews"] = "false",
["Media:Root"] = _mediaRoot,
["Logging:LogLevel:Default"] = "Warning",
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
};
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
foreach (var (key, value) in Settings())
builder.UseSetting(key, value);
builder.ConfigureTestServices(services =>
{
foreach (var hosted in services.Where(s => s.ServiceType == typeof(IHostedService) && Unwanted.Contains(s.ImplementationType)).ToList())
services.Remove(hosted);
services.AddDataProtection().UseEphemeralDataProtectionProvider();
services.AddSingleton<IStartupFilter, ClientAddressFilter>();
Registered = services.ToList();
});
}
protected override void ConfigureClient(HttpClient client)
{
client.BaseAddress = new Uri(Base + "/");
client.DefaultRequestHeaders.Add(ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
}
public HttpClient Client(bool cookies = false) => CreateClient(new WebApplicationFactoryClientOptions
{
BaseAddress = new Uri(Base + "/"),
AllowAutoRedirect = false,
HandleCookies = cookies
});
public T Get<T>() where T : notnull => Services.GetRequiredService<T>();
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_mediaRoot))
Directory.Delete(_mediaRoot, recursive: true);
}
sealed class ClientAddressFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use(async (context, call) =>
{
if (IPAddress.TryParse(context.Request.Headers[ClientHeader].ToString(), out var address))
context.Connection.RemoteIpAddress = address;
await call(context);
});
next(app);
};
}
}
public sealed class SecureModeHost : PrivaPubHost
{
static readonly SemaphoreSlim Boot = new(1, 1);
static SecureModeHost _shared;
public static new async Task<SecureModeHost> Shared()
{
await PrivaPubHost.Shared();
await Boot.WaitAsync();
try
{
if (_shared == default)
{
var host = new SecureModeHost();
_ = host.Services;
_shared = host;
}
return _shared;
}
finally
{
Boot.Release();
}
}
protected override IEnumerable<KeyValuePair<string, string>> Settings() =>
base.Settings().Append(new KeyValuePair<string, string>("Federation:SecureMode", "true"));
}
}
+27
View File
@@ -0,0 +1,27 @@
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using System.Collections.Concurrent;
namespace PrivaPub.Tests.Support
{
public sealed class MemoryLedger : IInteractionLedger
{
public ConcurrentQueue<(InteractionEvent Event, string ActorUri, bool HostClaimed)> Events { get; } = new();
public ConcurrentDictionary<(string Host, string Key), long> Counts { get; } = new();
public ConcurrentDictionary<(string Section, string Key), long> ServerCounts { get; } = new();
public long Dropped => 0;
public void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false) =>
Events.Enqueue((Interactions.Sanitize(interaction), actorUri, hostClaimed));
public void Count(string host, string key, long bytes = 0) =>
Counts.AddOrUpdate((Interactions.Host(host), key), 1, (_, count) => count + 1);
public void CountServer(string section, string key, int? latencyMs = default) =>
ServerCounts.AddOrUpdate((section, key), 1, (_, count) => count + 1);
public IReadOnlyList<InteractionEvent> Of(string channel) => Events.Select(e => e.Event).Where(e => e.Channel == channel).ToList();
}
}
+40 -3
View File
@@ -13,15 +13,24 @@ namespace PrivaPub.Tests.Support
public sealed class MongoFixture : IAsyncLifetime public sealed class MongoFixture : IAsyncLifetime
{ {
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod"; public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
const int ProductionPort = 27022;
const int DefaultPort = 27017;
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1"; public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
static bool Required => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_REQUIRE_MONGOD") == "1";
static bool InCi => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITEA_ACTIONS"));
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}"; public static string Connection { get; } = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? $"mongodb://127.0.0.1:{DefaultPort}";
public static string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
public async ValueTask InitializeAsync() public async ValueTask InitializeAsync()
{ {
if (Required && !Enabled)
throw new InvalidOperationException("PRIVAPUB_TEST_REQUIRE_MONGOD=1 but PRIVAPUB_TEST_MONGOD is not 1: the integration tests would be skipped");
if (!Enabled) if (!Enabled)
return; return;
var settings = MongoClientSettings.FromConnectionString(Connection);
Refuse(settings);
try try
{ {
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
@@ -29,9 +38,10 @@ namespace PrivaPub.Tests.Support
catch (BsonSerializationException) catch (BsonSerializationException)
{ {
} }
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017"; await DB.InitAsync(Database, settings);
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection)); await RefuseProductionData();
EntityMaps.Warm(); EntityMaps.Warm();
await DB.Default.MigrateAsync<Program>();
await Indexes.Create(); await Indexes.Create();
} }
@@ -40,5 +50,32 @@ namespace PrivaPub.Tests.Support
if (Enabled) if (Enabled)
await DB.Default.Database().Client.DropDatabaseAsync(Database); await DB.Default.Database().Client.DropDatabaseAsync(Database);
} }
static void Refuse(MongoClientSettings settings)
{
var ports = settings.Servers.Select(s => s.Port).ToList();
if (ports.Contains(ProductionPort))
throw new InvalidOperationException($"the tests never run against port {ProductionPort}: that is production's mongod");
if (InCi && (Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") == default || ports.Contains(DefaultPort)))
throw new InvalidOperationException("in CI the tests run only against a throwaway mongod: use tools/ci/with-test-mongod.sh");
}
static async Task RefuseProductionData()
{
BsonDocument options;
try
{
options = await DB.Default.Database().Client.GetDatabase("admin").RunCommandAsync<BsonDocument>(new BsonDocument("getCmdLineOpts", 1));
}
catch (MongoCommandException)
{
return;
}
var dbPath = options.GetValue("parsed", new BsonDocument()).AsBsonDocument
.GetValue("storage", new BsonDocument()).AsBsonDocument
.GetValue("dbPath", BsonNull.Value);
if (dbPath.IsString && dbPath.AsString.StartsWith("/var/lib/privapub", StringComparison.Ordinal))
throw new InvalidOperationException($"the mongod at {Connection} keeps its data in {dbPath.AsString}: that is production's");
}
} }
} }
+19 -11
View File
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -17,7 +18,7 @@ namespace PrivaPub.Tests.Support
public sealed class Peer : IAsyncDisposable public sealed class Peer : IAsyncDisposable
{ {
readonly WebApplication _app; readonly WebApplication _app;
readonly ConcurrentDictionary<string, string> _documents = new(); readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new(); readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new(); readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
@@ -40,12 +41,15 @@ namespace PrivaPub.Tests.Support
Peer peer = default; Peer peer = default;
app.Run(async context => app.Run(async context =>
{ {
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString())); var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0
? await new StreamReader(context.Request.Body).ReadToEndAsync()
: default;
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body,
context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase)));
var key = context.Request.Path.Value; var key = context.Request.Path.Value;
if (peer._files.TryGetValue(key, out var file)) if (peer._files.TryGetValue(key, out var file))
{ {
context.Response.ContentType = file.ContentType; await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
await context.Response.Body.WriteAsync(file.Bytes);
return; return;
} }
if (peer._answers.TryGetValue(key, out var answer)) if (peer._answers.TryGetValue(key, out var answer))
@@ -60,29 +64,32 @@ namespace PrivaPub.Tests.Support
context.Response.StatusCode = StatusCodes.Status404NotFound; context.Response.StatusCode = StatusCodes.Status404NotFound;
return; return;
} }
context.Response.ContentType = "application/activity+json"; context.Response.ContentType = document.ContentType;
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B)); await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
}); });
await app.StartAsync(); await app.StartAsync();
peer = new Peer(app, new Uri(app.Urls.First()).Port); peer = new Peer(app, new Uri(app.Urls.First()).Port);
return peer; return peer;
} }
public void Serve(string path, string json) => _documents[path] = json; public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType); public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay); public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default) public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default, IInteractionLedger ledger = default,
IConnectedAddresses connected = default)
{ {
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }; var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName) services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options, connected));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(), return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options), cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance); blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance, ledger);
} }
public async ValueTask DisposeAsync() => await _app.DisposeAsync(); public async ValueTask DisposeAsync() => await _app.DisposeAsync();
@@ -95,7 +102,8 @@ namespace PrivaPub.Tests.Support
public Task Reload(CancellationToken token) => Task.CompletedTask; public Task Reload(CancellationToken token) => Task.CompletedTask;
} }
public sealed record HttpRequestRecord(string Method, string Path, string Signature); public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
IReadOnlyDictionary<string, string> Headers = default);
public sealed class StaticOptions<T> : IOptionsMonitor<T> public sealed class StaticOptions<T> : IOptionsMonitor<T>
{ {
+32 -7
View File
@@ -44,18 +44,46 @@ namespace PrivaPub.Tests.Support
} }
}; };
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.TryAddWithoutValidation("Date", date);
request.Headers.TryAddWithoutValidation("Accept", "application/activity+json");
request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"));
return request;
}
public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) };
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
request.Headers.TryAddWithoutValidation("Date", date);
request.Headers.TryAddWithoutValidation("Digest", digest);
request.Headers.TryAddWithoutValidation("Signature",
Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"));
return request;
}
string Signature(string signingString, string headers)
{
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"";
}
public HttpRequest Get(string host, string path) public HttpRequest Get(string host, string path)
{ {
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Method = "GET"; context.Request.Method = "GET";
context.Request.Host = new HostString(host); context.Request.Host = new HostString(host);
context.Request.Path = path; context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path; context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date; context.Request.Headers["Date"] = date;
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\""; context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date");
return context.Request; return context.Request;
} }
@@ -64,9 +92,6 @@ namespace PrivaPub.Tests.Support
var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body); var digest = HttpSignatures.Digest(body);
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Method = "POST"; context.Request.Method = "POST";
context.Request.Host = new HostString(host); context.Request.Host = new HostString(host);
@@ -75,7 +100,7 @@ namespace PrivaPub.Tests.Support
context.Request.Headers["Date"] = date; context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest; context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json"; context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\""; context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest");
context.Request.Body = new MemoryStream(body); context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length; context.Request.ContentLength = body.Length;
return context.Request; return context.Request;
+222
View File
@@ -0,0 +1,222 @@
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Web
{
[Trait("Category", "Integration")]
public sealed class PublicPagesTests : IAsyncLifetime
{
const string Csp = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
async Task<LocalActor> Author(string name = "author")
{
var persona = await _host.Persona(await _host.SignUp(), name);
return await _host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
}
async Task<Post> Publish(LocalActor author, string text, PostVisibility visibility = PostVisibility.Public, string groupId = default)
{
using var scope = _host.Services.CreateScope();
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>()
.Publish(author, new StatusDraft { Text = text, Visibility = visibility, GroupId = groupId }, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
async Task<Post> Seeded(LocalActor author, string text, PostVisibility visibility)
{
var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, ActorURI = author.Uri, Text = text, Visibility = visibility };
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
async Task<(HttpResponseMessage Response, string Body)> Page(string path, string accept = "text/html")
{
using var client = _host.Client();
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Accept.ParseAdd(accept);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
return (response, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
static void Hardened(HttpResponseMessage response)
{
Assert.Equal(Csp, Assert.Single(response.Headers.GetValues("Content-Security-Policy")));
Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy")));
Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options")));
}
[Fact]
public async Task A_profile_shows_public_and_unlisted_posts_only_behind_hardened_headers()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
await Publish(author, "for everyone");
await Publish(author, "unlisted but linkable", PostVisibility.Unlisted);
await Publish(author, "for followers", PostVisibility.FollowersOnly);
await Seeded(author, "for one person", PostVisibility.Direct);
await Seeded(author, "for the neighbourhood", PostVisibility.LocalGeo);
var removed = await Publish(author, "taken back");
using (var scope = _host.Services.CreateScope())
Assert.True((await scope.ServiceProvider.GetRequiredService<IStatusService>().Remove(author, removed.ID, token)).Ok);
var (response, body) = await Page($"/@{author.UserName}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Hardened(response);
Assert.Contains("for everyone", body);
Assert.Contains("unlisted but linkable", body);
Assert.DoesNotContain("for followers", body);
Assert.DoesNotContain("for one person", body);
Assert.DoesNotContain("for the neighbourhood", body);
Assert.DoesNotContain("taken back", body);
Assert.Contains("<meta name=\"robots\" content=\"noindex, noarchive, nofollow\">", body);
Assert.Contains($"<link rel=\"alternate\" type=\"application/activity+json\" href=\"{author.Uri}\">", body);
}
[Fact]
public async Task A_post_page_serves_public_and_unlisted_posts_and_answers_404_for_everything_else()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
var other = await Author("other");
var open = await Publish(author, "an open post");
var unlisted = await Publish(author, "an unlisted post", PostVisibility.Unlisted);
var followersOnly = await Publish(author, "a followers post", PostVisibility.FollowersOnly);
var direct = await Seeded(author, "a direct post", PostVisibility.Direct);
var located = await Seeded(author, "a located post", PostVisibility.LocalGeo);
var removed = await Publish(author, "a removed post");
using (var scope = _host.Services.CreateScope())
Assert.True((await scope.ServiceProvider.GetRequiredService<IStatusService>().Remove(author, removed.ID, token)).Ok);
var (shown, body) = await Page($"/@{author.UserName}/{open.ID}");
Assert.Equal(HttpStatusCode.OK, shown.StatusCode);
Hardened(shown);
Assert.Contains("an open post", body);
Assert.Contains($"href=\"{author.PostUri(open.ID)}\"", body);
Assert.Equal(HttpStatusCode.OK, (await Page($"/@{author.UserName}/{unlisted.ID}")).Response.StatusCode);
foreach (var path in new[]
{
$"/@{author.UserName}/{followersOnly.ID}",
$"/@{author.UserName}/{direct.ID}",
$"/@{author.UserName}/{located.ID}",
$"/@{author.UserName}/{removed.ID}",
$"/@{other.UserName}/{open.ID}",
$"/@{author.UserName}/not-a-post-id",
$"/@{author.UserName}/ffffffffffffffffffffffff",
$"/@nobody{Guid.NewGuid():N}"[..20] + $"/{open.ID}"
})
{
var (response, missing) = await Page(path);
Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"{path} answered {(int)response.StatusCode}");
Assert.DoesNotContain("a followers post", missing);
Assert.DoesNotContain("a direct post", missing);
}
}
[Fact]
public async Task A_circle_has_no_page_a_community_has_one_and_the_instance_actor_has_none()
{
var token = TestContext.Current.CancellationToken;
var member = await Author("member");
GroupEntity Group(GroupKind kind) => new()
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), Name = $"The {kind}", Kind = kind, PostingPolicy = PostingPolicy.Followers,
Members = new() { new GroupMember { AvatarId = member.Id, Role = GroupRole.Owner } }
};
var circle = Group(GroupKind.Circle);
var community = Group(GroupKind.Community);
await DB.Default.SaveAsync(new[] { circle, community }, token);
var inCommunity = await Publish(member, "said in the community", groupId: community.ID);
var inCircle = await Seeded(member, "said in the circle", PostVisibility.Circle);
await DB.Default.Update<Post>().MatchID(inCircle.ID).Modify(p => p.GroupId, circle.ID).ExecuteAsync(token);
var (circlePage, circleBody) = await Page($"/@{circle.UserName}");
var (communityPage, communityBody) = await Page($"/@{community.UserName}");
Assert.Equal(HttpStatusCode.NotFound, circlePage.StatusCode);
Assert.DoesNotContain("The Circle", circleBody);
Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}/{inCircle.ID}")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}", "application/activity+json")).Response.StatusCode);
Assert.Equal(HttpStatusCode.OK, communityPage.StatusCode);
Hardened(communityPage);
Assert.Contains("The Community", communityBody);
Assert.Contains("said in the community", communityBody);
Assert.Contains($"href=\"{member.PostHtmlUrl(inCommunity.ID)}\"", communityBody);
Assert.Equal(HttpStatusCode.NotFound, (await Page("/@privapub")).Response.StatusCode);
}
[Fact]
public async Task An_activitypub_request_for_a_page_is_sent_to_the_actor_or_the_object()
{
var author = await Author();
var open = await Publish(author, "fetch me as json");
var followersOnly = await Publish(author, "not for strangers", PostVisibility.FollowersOnly);
var (profile, _) = await Page($"/@{author.UserName}", "application/activity+json");
var (post, _) = await Page($"/@{author.UserName}/{open.ID}", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");
var (hidden, _) = await Page($"/@{author.UserName}/{followersOnly.ID}", "application/activity+json");
Assert.Equal(HttpStatusCode.Redirect, profile.StatusCode);
Assert.Equal(author.Uri, profile.Headers.Location!.ToString());
Assert.Equal(HttpStatusCode.Redirect, post.StatusCode);
Assert.Equal(author.PostUri(open.ID), post.Headers.Location!.ToString());
Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode);
Assert.False(profile.Headers.Contains("Content-Security-Policy"));
}
[Fact]
public async Task Markup_in_a_post_a_title_or_a_bio_is_escaped_never_run()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
await DB.Default.Update<Avatar>().MatchID(author.Id).Modify(a => a.Biography, "<script>alert(bio)</script> hi").ExecuteAsync(token);
var rendered = await Publish(author, "<script>alert(post)</script> **bold** <img src=x onerror=alert(1)>");
var legacy = await Seeded(author, "<script>alert(legacy)</script>", PostVisibility.Public);
await DB.Default.Update<Post>().MatchID(legacy.ID).Modify(p => p.Title, "<script>alert(title)</script>").ExecuteAsync(token);
var (profile, profileBody) = await Page($"/@{author.UserName}");
var (post, postBody) = await Page($"/@{author.UserName}/{rendered.ID}");
var (old, oldBody) = await Page($"/@{author.UserName}/{legacy.ID}");
Assert.All(new[] { profile, post, old }, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode));
foreach (var body in new[] { profileBody, postBody, oldBody })
{
Assert.DoesNotContain("<script", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", body, StringComparison.OrdinalIgnoreCase);
}
Assert.Contains("&lt;script&gt;alert(bio)&lt;/script&gt;", profileBody);
Assert.Contains("&lt;script&gt;alert(post)&lt;/script&gt;", postBody);
Assert.Contains("<strong>bold</strong>", postBody);
Assert.Contains("&lt;script&gt;alert(legacy)&lt;/script&gt;", oldBody);
Assert.Contains("&lt;script&gt;alert(title)&lt;/script&gt;", oldBody);
}
}
}
+10 -4
View File
@@ -23,10 +23,7 @@ namespace PrivaPub.Api.Mastodon.Auth
try try
{ {
await Task.Delay(Interval, stoppingToken); await Task.Delay(Interval, stoppingToken);
using var scope = _services.CreateScope(); var (tokens, authorizations) = await Prune(DateTimeOffset.UtcNow - Threshold, stoppingToken);
var threshold = DateTimeOffset.UtcNow - Threshold;
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, stoppingToken);
if (tokens + authorizations > 0) if (tokens + authorizations > 0)
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations); _logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
} }
@@ -40,5 +37,14 @@ namespace PrivaPub.Api.Mastodon.Auth
} }
} }
} }
//one pass: tokens and authorizations created before the threshold that are no longer valid
public async Task<(long Tokens, long Authorizations)> Prune(DateTimeOffset threshold, CancellationToken token)
{
using var scope = _services.CreateScope();
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, token);
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, token);
return (tokens, authorizations);
}
} }
} }
@@ -155,15 +155,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (local == default) if (local == default)
return Json(Array.Empty<Status>()); return Json(Array.Empty<Status>());
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList(); var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token); var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token)); return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
} }
var query = local != default var query = local is { Kind: LocalActorKind.Group }
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue) ? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy && !p.DeletedAt.HasValue); : local != default
var viewerFollows = MyId != default && local != default ? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token); : _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
var viewerFollows = MyId != default && (local != default
? await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
if (MyId != id) if (MyId != id)
query.Match(viewerFollows query.Match(viewerFollows
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly ? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
@@ -171,7 +174,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
else else
query.Match(p => p.Visibility != PostVisibility.LocalGeo); query.Match(p => p.Visibility != PostVisibility.LocalGeo);
if (Params.Bool("exclude_replies") == true) if (Params.Bool("exclude_replies") == true)
query.Match(p => p.InReplyToURI == null); {
var self = local?.Id ?? remote.ID;
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
}
if (Params.Bool("exclude_reblogs") == true) if (Params.Bool("exclude_reblogs") == true)
query.Match(p => p.ReblogOfPostId == null); query.Match(p => p.ReblogOfPostId == null);
if (Params.Bool("only_media") == true) if (Params.Bool("only_media") == true)
@@ -235,9 +241,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var uri = local?.Uri ?? remote?.ActorURI; var uri = local?.Uri ?? remote?.ActorURI;
if (uri == default) if (uri == default)
return NotFoundError(); return NotFoundError();
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri); await _relationships.RemoveFollower(Me, uri, id, token);
if (local != default)
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
return Json(await Relationship(id, token)); return Json(await Relationship(id, token));
} }
@@ -1,3 +1,4 @@
using PrivaPub.Infrastructure.Statistics;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,10 +19,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IMediaService _media; readonly IMediaService _media;
readonly IMediaProxy _proxy; readonly IMediaProxy _proxy;
public MediaController(IMediaService media, IMediaProxy proxy) readonly IInteractionLedger _ledger;
public MediaController(IMediaService media, IMediaProxy proxy, IInteractionLedger ledger = default)
{ {
_media = media; _media = media;
_proxy = proxy; _proxy = proxy;
_ledger = ledger;
} }
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit), [HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit),
@@ -67,7 +71,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox"; Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
Response.Headers["Cache-Control"] = "public, max-age=604800"; Response.Headers["Cache-Control"] = "public, max-age=604800";
if (_proxy.Cached(url) is { Path: not null } cached) if (_proxy.Cached(url) is { Path: not null } cached)
{
_ledger?.Count(Interactions.HostOf(url), "media:hit");
return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true); return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true);
}
if (Request.Headers.Range.Count == 0) if (Request.Headers.Range.Count == 0)
{ {
var (path, contentType) = await _proxy.Fetch(signature, encoded, token); var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
@@ -26,9 +26,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous] [HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Status(string id, CancellationToken token) public async Task<IActionResult> Status(string id, CancellationToken token)
{ {
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ID == id).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
if (post?.ReblogOfPostId != default) if (post?.ReblogOfPostId != default)
post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token)) if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token))
return NotFoundError(); return NotFoundError();
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
@@ -64,15 +64,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
}, },
Inbox = record?.Inbox, Inbox = record?.Inbox,
Signature = record?.KeyId == default ? default : new ProvenanceSignature FetchedBy = record?.Path == ObjectPath.Fetched ? "instance-actor" : default,
Signature = record?.KeyId == default || record.Path == ObjectPath.Fetched ? default : new ProvenanceSignature
{ {
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
}, },
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt), ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
Published = record?.Published is { } published ? MastodonJson.Time(published) : default, Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default, Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
Extensions = ObjectFeatures.Detect(raw), Extensions = record?.Extensions ?? ObjectFeatures.Detect(raw),
ContextNamespaces = ObjectFeatures.Namespaces(raw, record?.ActivityContext), ContextNamespaces = record?.ContextNamespaces ?? ObjectFeatures.Namespaces(raw, record?.ActivityContext),
Raw = raw, Raw = raw,
RawBytes = record?.RawBytes ?? 0, RawBytes = record?.RawBytes ?? 0,
RawHash = record?.RawHash, RawHash = record?.RawHash,
@@ -118,6 +119,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public bool Refetched { get; set; } public bool Refetched { get; set; }
public ProvenanceActivity Activity { get; set; } public ProvenanceActivity Activity { get; set; }
public string Inbox { get; set; } public string Inbox { get; set; }
public string FetchedBy { get; set; }
public ProvenanceSignature Signature { get; set; } public ProvenanceSignature Signature { get; set; }
public string ReceivedAt { get; set; } public string ReceivedAt { get; set; }
public string Published { get; set; } public string Published { get; set; }
@@ -231,7 +231,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{ {
if (await Visible(id, token) == default) if (await Visible(id, token) == default)
return NotFoundError(); return NotFoundError();
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token); var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
} }
@@ -326,7 +326,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (post == default) if (post == default)
return NotFoundError(); return await Visible(id, token) is { } other
? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null
? "Validation failed: Someone else's post cannot be pinned"
: "Validation failed: A boost cannot be pinned")
: NotFoundError();
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned"); return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins) if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
@@ -32,7 +32,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{ {
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token); var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
var ids = entries.Select(e => e.PostId).ToList(); var ids = entries.Select(e => e.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token); var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId); Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(statuses); return Json(statuses);
@@ -41,7 +41,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] [HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Public(CancellationToken token) public async Task<IActionResult> Public(CancellationToken token)
{ {
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
if (Params.Bool("local") == true) if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy); query.Match(p => !p.IsFederatedCopy);
if (Params.Bool("remote") == true) if (Params.Bool("remote") == true)
@@ -55,7 +55,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Tag(string hashtag, CancellationToken token) public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
{ {
var tag = hashtag.TrimStart('#').ToLowerInvariant(); var tag = hashtag.TrimStart('#').ToLowerInvariant();
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
if (Params.Bool("local") == true) if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy); query.Match(p => !p.IsFederatedCopy);
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
@@ -86,7 +86,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var conversations = new List<Conversation>(); var conversations = new List<Conversation>();
foreach (var group in groups) foreach (var group in groups)
{ {
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
if (last == default) if (last == default)
continue; continue;
var others = new List<string>(); var others = new List<string>();
@@ -111,7 +111,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token); var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
if (group == default) if (group == default)
return NotFoundError(); return NotFoundError();
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) }); return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
} }
@@ -198,7 +198,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Get(string id, CancellationToken token) public async Task<IActionResult> Get(string id, CancellationToken token)
{ {
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token); var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
return notification == default ? NotFoundError() : Json((await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault()); var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
return mapped == default ? NotFoundError() : Json(mapped);
} }
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")] [HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
@@ -225,7 +226,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList(); var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
var posts = postIds.Count == 0 var posts = postIds.Count == 0
? new List<PostEntity>() ? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id); var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
return notifications return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
@@ -0,0 +1,29 @@
using System.Text.Json;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public static class MastodonErrors
{
public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) =>
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages =>
{
var response = pages.HttpContext.Response;
response.ContentType = "application/json; charset=utf-8";
await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options));
}));
public static string Message(int status) => status switch
{
StatusCodes.Status401Unauthorized => "The access token is invalid",
StatusCodes.Status403Forbidden => "This action is outside the authorized scopes",
StatusCodes.Status404NotFound => "Record not found",
StatusCodes.Status405MethodNotAllowed => "Method not allowed",
StatusCodes.Status413PayloadTooLarge => "Payload too large",
StatusCodes.Status415UnsupportedMediaType => "Unsupported media type",
StatusCodes.Status422UnprocessableEntity => "Validation failed",
StatusCodes.Status429TooManyRequests => "Too many requests",
>= 500 => "Something went wrong.",
_ => "Bad request"
};
}
}
@@ -157,7 +157,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0 var originals = originalIds.Count == 0
? new Dictionary<string, PostEntity>() ? new Dictionary<string, PostEntity>()
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); : (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
var all = posts.Concat(originals.Values).ToList(); var all = posts.Concat(originals.Values).ToList();
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList(); var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
@@ -245,7 +245,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
: await FollowedLocalAuthors(viewerId, localAuthors, token); : await FollowedLocalAuthors(viewerId, localAuthors, token);
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList(); var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
var quotedPosts = new List<PostEntity>(); var quotedPosts = new List<PostEntity>();
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)) foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token)) if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
quotedPosts.Add(quotedPost); quotedPosts.Add(quotedPost);
var quotedStatuses = quotedPosts.Count == 0 var quotedStatuses = quotedPosts.Count == 0
@@ -270,7 +270,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var mapped = new List<Status>(); var mapped = new List<Status>();
foreach (var post in posts) foreach (var post in posts)
{ {
if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal) if (!VisibilityPolicy.Shown(post) || hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
&& hidden.Contains(hiddenOriginal.ActorURI)) && hidden.Contains(hiddenOriginal.ActorURI))
continue; continue;
var status = Map(post); var status = Map(post);
@@ -435,10 +435,25 @@ namespace PrivaPub.Controllers.ClientToServer
#region Auth refresh #region Auth refresh
[HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)] [HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> SniffAgain() public async Task<IActionResult> SniffAgain([FromServices] DbEntities dbEntities, CancellationToken token)
{ {
var result = new WebResult();
try
{
var user = await dbEntities.RootUsers.MatchID(User.GetUserId()).ExecuteFirstAsync(token);
if (user is not { IsBanned: false, DeletedAt: null })
return Unauthorized(result.Invalidate(Localizer["Unauthorized."], StatusCodes.Status401Unauthorized));
result = await UsersService.GetUserSettingsAsync(user.ID);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
return Ok(); return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data));
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(User)}.{nameof(SniffAgain)}()");
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
}
} }
#endregion #endregion
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels;
using PrivaPub.Domain.Statistics;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using System.Globalization;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/admin/statistics"),
Authorize(Policy = Policies.IsAdmin)]
public class StatisticsController : ControllerBase
{
readonly StatisticsQueries _queries;
readonly IJobQueue _queue;
readonly Microsoft.Extensions.Options.IOptionsMonitor<StatisticsOptions> _options;
public StatisticsController(StatisticsQueries queries, IJobQueue queue, Microsoft.Extensions.Options.IOptionsMonitor<StatisticsOptions> options)
{
_queries = queries;
_queue = queue;
_options = options;
}
[HttpGet, Route("/clientapi/admin/statistics/crawler")]
public async Task<IActionResult> Crawler(CancellationToken token) =>
Ok(await _queries.Crawler(_options.CurrentValue.Crawler.Enabled, _options.CurrentValue.Crawler.RevisitDays, token));
[HttpGet, Route("/clientapi/admin/statistics/overview")]
public async Task<IActionResult> Overview([FromQuery] int days = 30, CancellationToken token = default) =>
Ok(await _queries.Overview(days, token));
[HttpGet, Route("/clientapi/admin/statistics/hosts")]
public async Task<IActionResult> Hosts([FromQuery] int days = 30, [FromQuery] string sort = default, [FromQuery] string software = default,
[FromQuery] int page = 1, [FromQuery] int limit = 50, CancellationToken token = default) =>
Ok(await _queries.Hosts(days, sort, software, page, limit, token));
[HttpGet, Route("/clientapi/admin/statistics/hosts/{host}")]
public async Task<IActionResult> Host(string host, [FromQuery] int days = 90, CancellationToken token = default) =>
await _queries.Host(host, days, token) is { } found ? Ok(found) : NotFound();
[HttpGet, Route("/clientapi/admin/statistics/events")]
public async Task<IActionResult> Events([FromQuery] string host = default, [FromQuery] string channel = default, [FromQuery] string outcome = default,
[FromQuery] string reason = default, [FromQuery] DateTime? before = default, [FromQuery] int limit = 100, CancellationToken token = default) =>
Ok(await _queries.Events(host, channel, outcome, reason, before, limit, token));
[HttpGet, Route("/clientapi/admin/statistics/server")]
public async Task<IActionResult> Server([FromQuery] int days = 30, CancellationToken token = default) =>
Ok(await _queries.Server(days, token));
[HttpPost, Route("/clientapi/admin/statistics/rollups/{day}")]
public async Task<IActionResult> Rollup(string day, CancellationToken token)
{
if (!DateTime.TryParseExact(day, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|| parsed.Date >= DateTime.UtcNow.Date)
return BadRequest();
var job = RollupJob.For(parsed.Date, $"rollup|{day}|rerun|{DateTime.UtcNow.Ticks}");
job.RunAt = DateTime.UtcNow;
await _queue.EnqueueMany(new[] { job }, token);
return Accepted();
}
[HttpPost, Route("/clientapi/admin/statistics/hosts/{host}/describe")]
public async Task<IActionResult> Describe(string host, CancellationToken token)
{
host = Interactions.Host(host);
if (host == Interactions.Unknown)
return BadRequest();
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|admin|{DateTime.UtcNow.Ticks}", token);
return Accepted();
}
}
}
+24 -4
View File
@@ -4,6 +4,9 @@ using Microsoft.Extensions.Options;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -46,14 +49,17 @@ namespace PrivaPub.Domain.Content
readonly IJobQueue _queue; readonly IJobQueue _queue;
readonly IOptionsMonitor<FederationOptions> _options; readonly IOptionsMonitor<FederationOptions> _options;
readonly IInteractionLedger _ledger;
public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue, public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue,
IOptionsMonitor<FederationOptions> options) IOptionsMonitor<FederationOptions> options, IInteractionLedger ledger = default)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_http = http; _http = http;
_queue = queue; _queue = queue;
_options = options; _options = options;
_ledger = ledger;
} }
public JobKind Kind => JobKind.FetchPreview; public JobKind Kind => JobKind.FetchPreview;
@@ -63,8 +69,7 @@ namespace PrivaPub.Domain.Content
public async Task Wanted(PostEntity post, CancellationToken token) public async Task Wanted(PostEntity post, CancellationToken token)
{ {
if (!_options.CurrentValue.FetchLinkPreviews || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || post.IsLocalOnly if (!_options.CurrentValue.FetchLinkPreviews || !Previewable(post) || post.Link == default && post.Media.Count > 0)
|| post.ReblogOfPostId != default || post.Link?.Title != default || post.Link == default && post.Media.Count > 0)
return; return;
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress); var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
if (url == default) if (url == default)
@@ -82,7 +87,7 @@ namespace PrivaPub.Domain.Content
public async Task<JobOutcome> Handle(Job job, CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{ {
var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue || post.Link?.Title != default) if (!VisibilityPolicy.Shown(post) || !Previewable(post))
return JobOutcome.Done; return JobOutcome.Done;
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress); var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
if (url == default) if (url == default)
@@ -104,8 +109,19 @@ namespace PrivaPub.Domain.Content
async Task<LinkPreview> Fetch(string url, CancellationToken token) async Task<LinkPreview> Fetch(string url, CancellationToken token)
{ {
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var (finalUri, html) = await _http.GetPage(url, token); var (finalUri, html) = await _http.GetPage(url, token);
var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html); var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html);
_ledger?.Record(new InteractionEvent
{
Channel = Interactions.Preview,
Host = Interactions.HostOf(url),
Outcome = html == default ? Interactions.Failed : Interactions.Ok,
Reason = html == default ? default : preview.Title == default && preview.ImageURL == default ? "no-card" : "card",
LatencyMs = (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds,
Bytes = html == default ? default : System.Text.Encoding.UTF8.GetByteCount(html),
Redirects = finalUri == default || finalUri.AbsoluteUri == url ? 0 : 1
});
await DB.Default.Update<LinkPreview>() await DB.Default.Update<LinkPreview>()
.Match(p => p.Url == url) .Match(p => p.Url == url)
.Modify(p => p.Title, preview.Title) .Modify(p => p.Title, preview.Title)
@@ -119,6 +135,10 @@ namespace PrivaPub.Domain.Content
return preview; return preview;
} }
//only public posts (owner decision 1), checked again when the job runs; never a boost or a post whose card is complete
static bool Previewable(PostEntity post) => post.Visibility is (PostVisibility.Public or PostVisibility.Unlisted) && !post.IsLocalOnly
&& post.ReblogOfPostId == default && post.Link?.Title == default;
public static LinkPreview Read(string url, Uri finalUri, string html) public static LinkPreview Read(string url, Uri finalUri, string html)
{ {
var document = Parser.ParseDocument(html); var document = Parser.ParseDocument(html);
+10 -4
View File
@@ -144,10 +144,7 @@ namespace PrivaPub.Domain.Media
try try
{ {
await Task.Delay(Interval, stoppingToken); await Task.Delay(Interval, stoppingToken);
var cutoff = DateTime.UtcNow - UnattachedLifetime; await Sweep(stoppingToken);
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(stoppingToken))
await _media.Delete(stale);
TrimProxyCache();
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -160,6 +157,15 @@ namespace PrivaPub.Domain.Media
} }
} }
//one pass: uploads left unattached for a day go, then the proxy cache is trimmed to its size, oldest first
public async Task Sweep(CancellationToken token)
{
var cutoff = DateTime.UtcNow - UnattachedLifetime;
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
await _media.Delete(stale);
TrimProxyCache();
}
void TrimProxyCache() void TrimProxyCache()
{ {
var directory = new DirectoryInfo(_media.ProxyRoot); var directory = new DirectoryInfo(_media.ProxyRoot);
+22 -3
View File
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using System.Linq.Expressions; using System.Linq.Expressions;
@@ -11,14 +12,20 @@ namespace PrivaPub.Domain.Privacy
{ {
public static class VisibilityPolicy public static class VisibilityPolicy
{ {
public static readonly Expression<Func<Post, bool>> IsPublic = p => //neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03)
!p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted); public static readonly Expression<Func<Post, bool>> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone;
public static readonly Expression<Func<Post, bool>> IsPublic = p =>
!p.DeletedAt.HasValue && !p.AuthorGone && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
static readonly Func<Post, bool> IsShownCompiled = IsShown.Compile();
static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile(); static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile();
public static bool Shown(Post post) => post != default && IsShownCompiled(post);
public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token) public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token)
{ {
if (post == default || post.DeletedAt.HasValue) if (!Shown(post))
return false; return false;
if (IsPublicCompiled(post)) if (IsPublicCompiled(post))
return true; return true;
@@ -35,8 +42,20 @@ namespace PrivaPub.Domain.Privacy
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>() PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
.Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId)) .Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
.ExecuteAnyAsync(token), .ExecuteAnyAsync(token),
PostVisibility.FollowersOnly => await FollowsAuthor(viewerAvatarId, post, token),
_ => false _ => false
}; };
} }
static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token)
{
if (!string.IsNullOrEmpty(post.ActorURI))
return await DB.Default.Find<Following>()
.Match(f => f.AvatarId == viewerAvatarId && f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
return !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == viewerAvatarId && f.TargetIsLocal && f.TargetAccountId == post.GroupUserId && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
}
} }
} }
@@ -17,6 +17,7 @@ namespace PrivaPub.Domain.Relationships
{ {
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token); Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token); Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token);
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token); Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token); Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
Task BlockDomain(LocalActor me, string domain, CancellationToken token); Task BlockDomain(LocalActor me, string domain, CancellationToken token);
@@ -49,30 +50,34 @@ namespace PrivaPub.Domain.Relationships
} }
await _follows.UnfollowAs(me, targetActorUri, token); await _follows.UnfollowAs(me, targetActorUri, token);
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token); await RemoveFollower(me, targetActorUri, targetAccountId, token);
if (follower != default)
{
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
{
var reject = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = "Reject",
["actor"] = me.Uri,
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri }
};
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
}
else
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
}
await Forget(me, targetAccountId, token); await Forget(me, targetAccountId, token);
if (block != default) if (block != default)
await Tell(me, targetActorUri, BlockActivity(me, block), token); await Tell(me, targetActorUri, BlockActivity(me, block), token);
} }
public async Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token)
{
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == followerActorUri).ExecuteFirstAsync(token);
if (follower == default)
return;
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (!followerActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
{
var reject = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = "Reject",
["actor"] = me.Uri,
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = followerActorUri, ["object"] = me.Uri }
};
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
}
else
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == followerAccountId);
}
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
{ {
var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token); var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
+6
View File
@@ -2,6 +2,7 @@ using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -43,7 +44,10 @@ namespace PrivaPub.Domain.Social
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token) public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{ {
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji) if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
{
Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(new Reaction await DB.Default.SaveAsync(new Reaction
@@ -53,8 +57,10 @@ namespace PrivaPub.Domain.Social
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("reaction");
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url); await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
} }
+23
View File
@@ -0,0 +1,23 @@
using PrivaPub.Models.Jobs;
namespace PrivaPub.Domain.Statistics
{
public sealed record PublicLocation(string Country, string City, double? Latitude, double? Longitude, string Network, string Cdn);
public static class PublicGeo
{
//owner decision: a CDN-fronted server shows only its CDN (the address is an edge), a server reporting at least
//minUsers users its city, coordinates and network, any other only its country
public static PublicLocation Project(RemoteInstance instance, int minUsers)
{
var geo = instance?.Geo;
if (geo == default)
return default;
if (geo.Cdn != default)
return new PublicLocation(default, default, default, default, default, geo.Cdn);
if (instance.UsersTotal >= minUsers)
return new PublicLocation(geo.Country, geo.City, geo.Latitude, geo.Longitude, geo.AsnOrg, default);
return geo.Country == default ? default : new PublicLocation(geo.Country, default, default, default, default, default);
}
}
}
@@ -0,0 +1,214 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
namespace PrivaPub.Domain.Statistics
{
public sealed record HostDay(DateTime Day, string Host, IReadOnlyDictionary<string, long> Counters, IReadOnlyDictionary<string, long> Latency,
IReadOnlyDictionary<string, long> Bytes, IReadOnlyDictionary<string, long> Reads, int Accounts, bool Live);
public class StatisticsQueries
{
public const string Attribution = "IP geolocation by DB-IP (https://db-ip.com), CC BY 4.0";
static readonly string[] Channels = { Interactions.Receive, Interactions.In, Interactions.Out, Interactions.Http, Interactions.Preview, Interactions.Crawl };
static readonly int[] LatencyLimits = { 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000 };
public async Task<List<HostDay>> Days(int days, string host, CancellationToken token)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(1 - Math.Clamp(days, 1, 3660));
var rows = await DB.Default.Find<InstanceDay>()
.Match(d => d.Day >= since)
.Match(d => host == null || d.Host == host)
.ExecuteAsync(token);
var result = rows.Where(r => r.RolledUpAt != null)
.Select(r => new HostDay(r.Day, r.Host, r.Counters, r.Latency, r.Bytes, r.Reads, r.Accounts, false))
.ToList();
var rolledDays = rows.Where(r => r.RolledUpAt != null).Select(r => r.Day).ToHashSet();
for (var day = since; day <= today; day = day.AddDays(1))
{
if (rolledDays.Contains(day))
continue;
var end = day.AddDays(1);
var start = day;
var events = await DB.Default.Find<InteractionEvent>()
.Match(e => e.At >= start && e.At < end)
.Match(e => host == null || e.Host == host)
.ExecuteAsync(token);
var reads = rows.Where(r => r.Day == day).ToDictionary(r => r.Host, r => r.Reads);
foreach (var (name, fold) in Rollup.Fold(events).Hosts)
result.Add(new HostDay(day, name, fold.Counters, fold.Latency, fold.Bytes, reads.GetValueOrDefault(name) ?? new(), fold.Accounts.Count, true));
foreach (var (name, read) in reads.Where(r => result.All(h => h.Day != day || h.Host != r.Key)))
result.Add(new HostDay(day, name, new Dictionary<string, long>(), new Dictionary<string, long>(), new Dictionary<string, long>(), read, 0, true));
}
return result.OrderBy(d => d.Day).ThenBy(d => d.Host, StringComparer.Ordinal).ToList();
}
public async Task<object> Overview(int days, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var counters = Sum(hostDays.Select(d => d.Counters));
var latency = Sum(hostDays.Select(d => d.Latency));
var server = await DB.Default.Find<ServerDay>().Match(d => d.Day >= DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660))).ExecuteAsync(token);
var delivered = Outcomes(counters, Interactions.Out);
var attempts = delivered.Where(o => o.Key != Interactions.Deferred).Sum(o => o.Value);
return new
{
Days = days,
Channels = Channels.ToDictionary(c => c, c => counters.Where(k => k.Key.StartsWith(c + ":", StringComparison.Ordinal)).Sum(k => k.Value)),
Inbound = Outcomes(counters, Interactions.In),
Outbound = delivered,
DeliverySuccess = attempts == 0 ? (double?)null : Math.Round(delivered.GetValueOrDefault(Interactions.Ok) / (double)attempts, 4),
DeliveryLatencyMs = new { P50 = Percentile(latency, Interactions.Out, 0.5), P95 = Percentile(latency, Interactions.Out, 0.95) },
Hosts = new
{
Active = hostDays.Where(d => d.Host != Interactions.Unknown && d.Counters.Count > 0).Select(d => d.Host).Distinct().Count(),
Known = await DB.Default.CountAsync<RemoteInstance>(cancellation: token)
},
Accounts = server.Sum(d => d.Accounts) + hostDays.Where(d => d.Live).Sum(d => d.Accounts),
Ledger = new
{
Written = server.Sum(d => d.LedgerWritten),
Dropped = server.Sum(d => d.LedgerDropped),
Failed = server.Sum(d => d.LedgerFailed)
}
};
}
public async Task<object> Hosts(int days, string sort, string software, int page, int limit, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var instances = (await DB.Default.Find<RemoteInstance>().ExecuteAsync(token)).ToDictionary(i => i.Host, StringComparer.Ordinal);
var rows = hostDays.GroupBy(d => d.Host)
.Select(g =>
{
var counters = Sum(g.Select(d => d.Counters));
var outbound = Outcomes(counters, Interactions.Out);
var instance = instances.GetValueOrDefault(g.Key);
return new
{
Host = g.Key,
instance?.Software,
Version = instance?.SoftwareVersion,
In = counters.Where(k => k.Key.StartsWith("in:", StringComparison.Ordinal)).Sum(k => k.Value),
Out = counters.Where(k => k.Key.StartsWith("out:", StringComparison.Ordinal)).Sum(k => k.Value),
Refused = counters.Where(k => k.Key.StartsWith("recv:", StringComparison.Ordinal) && !k.Key.Contains(":202:", StringComparison.Ordinal)).Sum(k => k.Value),
Failures = outbound.Where(o => o.Key is Interactions.Retry or Interactions.Dead or Interactions.Failed).Sum(o => o.Value),
LatencyP50 = Percentile(Sum(g.Select(d => d.Latency)), Interactions.Out, 0.5),
Accounts = g.Sum(d => d.Accounts),
instance?.DescribedAt,
Delivery = instance == default ? default : new { instance.ConsecutiveFailures, instance.UnavailableUntil, instance.LastSuccessAt, instance.LastFailureAt }
};
})
.Where(r => software == default || string.Equals(r.Software, software, StringComparison.OrdinalIgnoreCase));
rows = sort switch
{
"failures" => rows.OrderByDescending(r => r.Failures),
"latency" => rows.OrderByDescending(r => r.LatencyP50 ?? 0),
"host" => rows.OrderBy(r => r.Host, StringComparer.Ordinal),
_ => rows.OrderByDescending(r => r.In + r.Out)
};
var list = rows.ToList();
limit = Math.Clamp(limit, 1, 200);
return new { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit), Attribution };
}
public async Task<object> Host(string host, int days, CancellationToken token)
{
host = Interactions.Host(host);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
var hostDays = await Days(days, host, token);
var events = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == host).Sort(e => e.At, Order.Descending).Limit(100).ExecuteAsync(token);
if (instance == default && hostDays.Count == 0 && events.Count == 0)
return default;
return new
{
Host = host,
Instance = instance,
Days = hostDays.Select(d => new { d.Day, d.Counters, d.Latency, d.Bytes, d.Reads, d.Accounts, d.Live }),
Events = events,
Attribution
};
}
public async Task<List<InteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token)
{
var hostName = host == default ? default : Interactions.Host(host);
return await DB.Default.Find<InteractionEvent>()
.Match(e => hostName == null || e.Host == hostName)
.Match(e => channel == null || e.Channel == channel)
.Match(e => outcome == null || e.Outcome == outcome)
.Match(e => reason == null || e.Reason == reason)
.Match(e => before == null || e.At < before)
.Sort(e => e.At, Order.Descending)
.Limit(Math.Clamp(limit, 1, 200))
.ExecuteAsync(token);
}
public async Task<object> Crawler(bool enabled, int revisitDays, CancellationToken token)
{
var due = DateTime.UtcNow.AddDays(-Math.Max(revisitDays, 1));
var recent = await DB.Default.Find<RemoteInstance>()
.Match(i => i.LastCrawledAt != null)
.Sort(i => i.LastCrawledAt, Order.Descending)
.Limit(20)
.ExecuteAsync(token);
return new
{
Enabled = enabled,
Touched = await DB.Default.CountAsync<RemoteInstance>(i => i.Seen == "touched", token),
Crawled = await DB.Default.CountAsync<RemoteInstance>(i => i.Seen == "crawled", token),
Due = await DB.Default.CountAsync<RemoteInstance>(i => i.LastCrawledAt == null || i.LastCrawledAt < due, token),
RobotsDisallowed = await DB.Default.CountAsync<RemoteInstance>(i => i.RobotsDisallowed == true, token),
Recent = recent.Select(i => new { i.Host, i.Seen, i.LastCrawledAt, i.PeersCount, i.RobotsDisallowed, i.CrawlError, i.Software })
};
}
public async Task<List<ServerDay>> Server(int days, CancellationToken token)
{
var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660));
return await DB.Default.Find<ServerDay>().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token);
}
public static Dictionary<string, long> Outcomes(IReadOnlyDictionary<string, long> counters, string channel)
{
var outcomes = new Dictionary<string, long>();
foreach (var (key, value) in counters)
{
var parts = key.Split(':');
if (parts.Length >= 4 && parts[0] == channel)
outcomes[parts[3]] = outcomes.GetValueOrDefault(parts[3]) + value;
}
return outcomes;
}
public static int? Percentile(IReadOnlyDictionary<string, long> latency, string prefix, double share)
{
var buckets = LatencyLimits.Select(limit => (Limit: (int?)limit, Count: latency.GetValueOrDefault($"{prefix}:le{limit}ms")))
.Append((Limit: (int?)null, Count: latency.GetValueOrDefault($"{prefix}:inf")))
.ToList();
var total = buckets.Sum(b => b.Count);
if (total == 0)
return default;
long seen = 0;
foreach (var (limit, count) in buckets)
{
seen += count;
if (seen >= share * total)
return limit ?? LatencyLimits[^1] + 1;
}
return default;
}
static Dictionary<string, long> Sum(IEnumerable<IReadOnlyDictionary<string, long>> dictionaries)
{
var sum = new Dictionary<string, long>();
foreach (var dictionary in dictionaries)
foreach (var (key, value) in dictionary ?? new Dictionary<string, long>())
sum[key] = sum.GetValueOrDefault(key) + value;
return sum;
}
}
}
+9
View File
@@ -107,11 +107,18 @@ namespace PrivaPub.Domain.Statuses
var quotingUri = Id(instrument); var quotingUri = Id(instrument);
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI) if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI) || instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
{
Arrival.Drop("misattributed");
return; return;
}
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token); var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default || string.IsNullOrEmpty(actor.InboxURL)) if (author == default || string.IsNullOrEmpty(actor.InboxURL))
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Note", quoted.Visibility, quoted.CreationDate);
var answer = new JsonObject var answer = new JsonObject
{ {
["@context"] = ActivityPubRenderer.Context(), ["@context"] = ActivityPubRenderer.Context(),
@@ -121,12 +128,14 @@ namespace PrivaPub.Domain.Statuses
}; };
if (await MayQuote(quoted, actor.ActorURI, token)) if (await MayQuote(quoted, actor.ActorURI, token))
{ {
Arrival.Accept("quote-granted");
answer["type"] = "Accept"; answer["type"] = "Accept";
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token); answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
} }
else else
{ {
Arrival.Reject("quote-refused");
answer["type"] = "Reject"; answer["type"] = "Reject";
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
} }
+2 -1
View File
@@ -5,6 +5,7 @@ using MongoDB.Entities;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social; using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Resources; using PrivaPub.Resources;
@@ -46,7 +47,7 @@ namespace PrivaPub.Domain.Timelines
var ids = entries.Select(e => e.PostId).Concat(entries.Where(e => e.ReblogOfPostId != default).Select(e => e.ReblogOfPostId)).Distinct().ToList(); var ids = entries.Select(e => e.PostId).Concat(entries.Where(e => e.ReblogOfPostId != default).Select(e => e.ReblogOfPostId)).Distinct().ToList();
var posts = ids.Count == 0 var posts = ids.Count == 0
? new Dictionary<string, Post>() ? new Dictionary<string, Post>()
: (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); : (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
result.Data = entries result.Data = entries
.Where(e => posts.ContainsKey(e.PostId) && (e.ReblogOfPostId == default || posts.ContainsKey(e.ReblogOfPostId))) .Where(e => posts.ContainsKey(e.PostId) && (e.ReblogOfPostId == default || posts.ContainsKey(e.ReblogOfPostId)))
@@ -37,6 +37,7 @@ namespace PrivaPub.Federation.Actors
public List<CustomEmoji> Emojis { get; init; } = new(); public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new(); public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>(); public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public List<string> Features { get; init; } = new();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId); public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -54,6 +55,7 @@ namespace PrivaPub.Federation.Actors
{ {
Id = id, Id = id,
Type = type, Type = type,
Features = ActorFeatures.Detect(root),
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"), PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"), Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"), Summary = RemoteActorService.Text(root, "summary"),
@@ -0,0 +1,72 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace PrivaPub.Federation.Actors
{
public static class ActorFeatures
{
public static List<string> Detect(JsonElement root)
{
var found = new List<string>();
if (root.ValueKind != JsonValueKind.Object)
return found;
void When(bool condition, string feature)
{
if (condition)
found.Add(feature);
}
bool Has(string name) => root.TryGetProperty(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined);
bool True(string name) => root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True;
IEnumerable<JsonElement> Items(string name) => !root.TryGetProperty(name, out var value)
? Enumerable.Empty<JsonElement>()
: value.ValueKind == JsonValueKind.Array ? value.EnumerateArray() : new[] { value };
bool Typed(JsonElement item, string type) =>
item.ValueKind == JsonValueKind.Object && item.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String && t.GetString() == type;
When(Has("featured"), "featured");
When(Has("featuredTags"), "featured-tags");
When(Items("alsoKnownAs").Any(), "also-known-as");
When(Has("movedTo"), "moved-to");
When(root.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object && endpoints.TryGetProperty("sharedInbox", out _), "shared-inbox");
When(Has("assertionMethod"), "fep-521a-assertion-method");
When(Has("proof"), "fep-8b32-proof");
When(Items("attachment").Any(a => Typed(a, "PropertyValue")), "property-value");
When(Items("attachment").Any(a => Typed(a, "VerifiableIdentityStatement") || Typed(a, "IdentityProof")), "fep-c390-identity-proof");
When(Items("tag").Any(t => Typed(t, "Emoji")), "custom-emoji");
When(root.EnumerateObject().Any(p => p.Name.StartsWith("_misskey", StringComparison.Ordinal)), "misskey");
When(True("isCat"), "is-cat");
When(Has("webfinger"), "fep-2c59-webfinger");
When(Has("attributionDomains"), "attribution-domains");
When(Has("searchableBy"), "fep-268d-searchable-by");
When(True("indexable"), "indexable");
When(root.TryGetProperty("discoverable", out var discoverable) && discoverable.ValueKind == JsonValueKind.False, "undiscoverable");
When(True("manuallyApprovesFollowers"), "locked");
When(True("memorial"), "memorial");
When(Has("generator"), "generator");
When(root.TryGetProperty("publicKey", out var keys) && keys.ValueKind == JsonValueKind.Array, "public-key-array");
if (KeyBits(root) is { } bits)
found.Add($"key:rsa-{bits}");
return found;
}
static int? KeyBits(JsonElement root)
{
if (!root.TryGetProperty("publicKey", out var key))
return default;
if (key.ValueKind == JsonValueKind.Array)
key = key.EnumerateArray().FirstOrDefault(k => k.ValueKind == JsonValueKind.Object);
if (key.ValueKind != JsonValueKind.Object || !key.TryGetProperty("publicKeyPem", out var pem) || pem.ValueKind != JsonValueKind.String)
return default;
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(pem.GetString());
return rsa.KeySize;
}
catch (Exception ex) when (ex is ArgumentException or CryptographicException)
{
return default;
}
}
}
}
@@ -48,6 +48,7 @@ namespace PrivaPub.Federation.Actors
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
{ {
using var scope = HttpScope.Default("object");
var signer = await _localActors.GetInstanceActor(token); var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token); var fetched = await Get(uri, signer, token);
if (fetched == default) if (fetched == default)
@@ -81,6 +82,7 @@ namespace PrivaPub.Federation.Actors
if (!MayFetch(actorUri)) if (!MayFetch(actorUri))
return cached; return cached;
using var scope = HttpScope.For("actor");
using var fetched = await FetchObject(actorUri, token); using var fetched = await FetchObject(actorUri, token);
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root); var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
if (actor == default) if (actor == default)
@@ -101,6 +103,7 @@ namespace PrivaPub.Federation.Actors
if (!MayFetch(keyId)) if (!MayFetch(keyId))
return cached; return cached;
using var scope = HttpScope.For("key");
using var fetched = await FetchObject(StripFragment(keyId), token); using var fetched = await FetchObject(StripFragment(keyId), token);
if (fetched == default) if (fetched == default)
return cached; return cached;
@@ -129,6 +132,7 @@ namespace PrivaPub.Federation.Actors
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default; return default;
using var scope = HttpScope.For("webfinger");
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}"; var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token) using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
?? (_options?.CurrentValue.AllowPlainHttp == true ?? (_options?.CurrentValue.AllowPlainHttp == true
@@ -188,6 +192,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.Published, actor.Published) .Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis) .Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields) .Modify(a => a.Fields, actor.Fields)
.Modify(a => a.Features, actor.Features)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person) .Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id) .Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem) .Modify(a => a.PublicKey, key?.Pem)
@@ -110,6 +110,9 @@ namespace PrivaPub.Federation.Controllers
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token),
await _fetches.Requester(Request, token)))
return NotFound();
var count = await DB.Default.CountAsync<Follower>( var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token); f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default)); return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
@@ -248,7 +251,7 @@ namespace PrivaPub.Federation.Controllers
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return Answer(_inbox.NoSuchRecipient(Request));
return Answer(await _inbox.Receive(Request, local, token)); return Answer(await _inbox.Receive(Request, local, token));
} }
@@ -311,7 +314,7 @@ namespace PrivaPub.Federation.Controllers
async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token) async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token)
{ {
var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token); var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token);
if (original == default || original.DeletedAt.HasValue) if (!VisibilityPolicy.Shown(original))
return default; return default;
return new JsonObject return new JsonObject
{ {
@@ -359,7 +362,7 @@ namespace PrivaPub.Federation.Controllers
IActionResult Answer(InboxResult result) IActionResult Answer(InboxResult result)
{ {
if (result.Error != default) if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); _logger.LogInformation("Inbox refused with {Status} ({Reason}): {Error}", result.StatusCode, result.Reason, result.Error);
if (result.RetryAfterSeconds is { } seconds) if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
@@ -34,9 +34,13 @@ namespace PrivaPub.Federation.Controllers
return BadRequest(); return BadRequest();
LocalActor actor; LocalActor actor;
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase)) // Mastodon, GoToSocial and Pleroma also take a bare user@domain or @user@domain, so we do too.
var acct = resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase) ? resource[5..]
: !resource.Contains("://", StringComparison.Ordinal) && resource.Contains('@') ? resource
: default;
if (acct != default)
{ {
var parts = resource[5..].TrimStart('@').Split('@'); var parts = acct.TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority; var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase)) if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound(); return NotFound();
+208
View File
@@ -0,0 +1,208 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
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.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.StaticServices;
namespace PrivaPub.Federation.Crawler
{
public static class Stargazer
{
public const string Token = "PrivaPub-Stargazer";
public const string Page = "/stargazing";
public static string UserAgent(string baseAddress) => $"{Token}/{BuildInfo.Ref} (+{baseAddress?.TrimEnd('/')}{Page})";
}
//each hour, the servers due for a visit, spread across the hour so the crawler reaches one at a time
public class CrawlPlanner : IJobHandler
{
readonly IJobQueue _queue;
readonly IDomainBlocks _blocks;
readonly IOptionsMonitor<StatisticsOptions> _options;
readonly IOptionsMonitor<FederationOptions> _federation;
public CrawlPlanner(IJobQueue queue, IDomainBlocks blocks, IOptionsMonitor<StatisticsOptions> options, IOptionsMonitor<FederationOptions> federation = default)
{
_queue = queue;
_blocks = blocks;
_options = options;
_federation = federation;
}
public JobKind Kind => JobKind.CrawlPlan;
public int Concurrency => 1;
public int MaxAttempts => 1;
public int PerHostLimit => 1;
public static Job For(DateTime hour) => new()
{
Kind = JobKind.CrawlPlan,
Payload = hour.ToString("yyyy-MM-ddTHH", System.Globalization.CultureInfo.InvariantCulture),
DedupeKey = "crawl-plan|" + hour.ToString("yyyy-MM-ddTHH", System.Globalization.CultureInfo.InvariantCulture),
RunAt = hour
};
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var crawler = _options.CurrentValue.Crawler;
if (!crawler.Enabled)
return JobOutcome.Done;
var now = DateTime.UtcNow;
foreach (var seed in PeerHosts.Filter(crawler.Seeds ?? new(), host => !_blocks.IsSuspended(host), _federation?.CurrentValue.AllowPrivateNetworks == true))
await DB.Default.Update<RemoteInstance>()
.Match(i => i.Host == seed)
.Modify(b => b.SetOnInsert(i => i.Seen, "crawled"))
.Modify(b => b.SetOnInsert(i => i.FirstSeenAt, now))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
var due = now.AddDays(-Math.Max(crawler.RevisitDays, 1));
var perHour = Math.Clamp(crawler.HostsPerHour, 1, 600);
var candidates = await DB.Default.Find<RemoteInstance>()
.Match(i => (i.LastCrawledAt == null || i.LastCrawledAt < due) && (i.UnavailableUntil == null || i.UnavailableUntil < now))
.Sort(i => i.LastCrawledAt, Order.Ascending)
.Limit(perHour * 3)
.ExecuteAsync(token);
var week = InstanceDescriber.Week(now);
var jobs = candidates.Where(i => _blocks.Find(i.Host) == default && PeerHosts.Normalise(i.Host) != default)
.Take(perHour)
.Select((instance, index) => new Job
{
Kind = JobKind.CrawlInstance,
Payload = instance.Host,
Host = instance.Host,
DedupeKey = $"crawl|{instance.Host}|{week}",
RunAt = now.AddSeconds(index * 3600.0 / perHour)
});
await _queue.EnqueueMany(jobs, token);
return JobOutcome.Done;
}
}
//one server: its robots.txt, a description if only crawling ever found it, then the servers its peers list names
public class InstanceCrawler : IJobHandler
{
const int MaxPeers = 20_000;
const int MaxRobotsBytes = 64 * 1024;
readonly IFederationHttp _http;
readonly InstanceDescriber _describer;
readonly IDomainBlocks _blocks;
readonly IOptionsMonitor<StatisticsOptions> _options;
readonly IOptionsMonitor<AppConfiguration> _app;
readonly IOptionsMonitor<FederationOptions> _federation;
readonly IInteractionLedger _ledger;
public InstanceCrawler(IFederationHttp http, InstanceDescriber describer, IDomainBlocks blocks, IOptionsMonitor<StatisticsOptions> options,
IOptionsMonitor<AppConfiguration> app, IOptionsMonitor<FederationOptions> federation, IInteractionLedger ledger = default)
{
_http = http;
_describer = describer;
_blocks = blocks;
_options = options;
_app = app;
_federation = federation;
_ledger = ledger;
}
public JobKind Kind => JobKind.CrawlInstance;
public int Concurrency => 1;
public int MaxAttempts => 1;
public int PerHostLimit => 1;
protected virtual string Address(string url) => url;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var crawler = _options.CurrentValue.Crawler;
var host = job.Payload;
if (!crawler.Enabled || _blocks.Find(host) != default)
return JobOutcome.Done;
using var crawl = HttpScope.Crawl(Stargazer.UserAgent(_app.CurrentValue.BackendBaseAddress));
RobotsTxt robots;
using (HttpScope.For("robots"))
{
var (status, text) = await _http.GetText(Address($"https://{host}/robots.txt"), MaxRobotsBytes, token);
robots = RobotsTxt.From(status, text, Stargazer.Token, "PrivaPub");
}
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
var now = DateTime.UtcNow;
if (!robots.Allows("/.well-known/nodeinfo") && !robots.Allows("/api/v1/instance/peers"))
{
await Visited(host, robotsDisallowed: true, peers: default, "robots.txt keeps us out", token);
Record(host, Interactions.Refused, "robots-disallowed");
return JobOutcome.Done;
}
var reachable = true;
if (instance?.Seen != "touched")
reachable = await _describer.Describe(host, crawled: true, robots.Allows, token);
int? peersCount = default;
if (robots.Allows("/api/v1/instance/peers"))
{
IReadOnlyList<string> peers;
using (HttpScope.For("peers"))
peers = await _http.GetStringArray(Address($"https://{host}/api/v1/instance/peers"), MaxPeers, token);
peersCount = peers.Count;
await Discover(peers, crawler, token);
}
await Visited(host, robotsDisallowed: false, peersCount, reachable ? default : "no NodeInfo", token);
Record(host, reachable ? Interactions.Ok : Interactions.Failed, reachable ? default : "unreachable");
return JobOutcome.Done;
}
async Task Discover(IReadOnlyList<string> peers, CrawlerOptions crawler, CancellationToken token)
{
var room = Math.Min(crawler.MaxNewHostsPerCrawl, crawler.MaxHosts - (int)await DB.Default.CountAsync<RemoteInstance>(i => i.Seen == "crawled", token));
if (room <= 0)
return;
var candidates = PeerHosts.Filter(peers, host => _http.IsAllowed(new Uri($"https://{host}/")) && _blocks.Find(host) == default,
_federation.CurrentValue.AllowPrivateNetworks).ToList();
var now = DateTime.UtcNow;
foreach (var batch in candidates.Chunk(500))
{
if (room <= 0)
return;
var known = (await DB.Default.Find<RemoteInstance, string>().Match(i => batch.Contains(i.Host)).Project(i => i.Host).ExecuteAsync(token)).ToHashSet();
foreach (var host in batch.Where(h => !known.Contains(h)).Take(room))
{
await DB.Default.Update<RemoteInstance>()
.Match(i => i.Host == host)
.Modify(b => b.SetOnInsert(i => i.Seen, "crawled"))
.Modify(b => b.SetOnInsert(i => i.FirstSeenAt, now))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
room--;
}
}
}
static async Task Visited(string host, bool robotsDisallowed, int? peers, string error, CancellationToken token)
{
var update = DB.Default.Update<RemoteInstance>()
.Match(i => i.Host == host)
.Modify(i => i.LastCrawledAt, DateTime.UtcNow)
.Modify(i => i.RobotsDisallowed, robotsDisallowed)
.Modify(i => i.CrawlError, error)
.Modify(b => b.SetOnInsert(i => i.Seen, "crawled"))
.Option(o => o.IsUpsert = true);
if (peers is { } count)
update = update.Modify(i => i.PeersCount, count);
await update.ExecuteAsync(token);
}
void Record(string host, string outcome, string reason) =>
_ledger?.Record(new InteractionEvent { Channel = Interactions.Crawl, Host = host, Outcome = outcome, Reason = reason, Crawl = true });
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Globalization;
namespace PrivaPub.Federation.Crawler
{
public static class PeerHosts
{
static readonly IdnMapping Idn = new();
static readonly string[] Unreachable = { ".onion", ".i2p", ".local", ".localhost", ".internal" };
static readonly string[] Reserved = { ".test", ".invalid", ".example" };
//names from a peers list we could ever talk to: DNS names, ASCII (punycode), no addresses, ports or hidden services;
//the reserved test names only on a test network
public static IEnumerable<string> Filter(IEnumerable<string> names, Func<string, bool> allowed, bool testNetwork = false) =>
names
.Select(Normalise)
.Where(host => host != default && (testNetwork || !Reserved.Any(suffix => host.EndsWith(suffix, StringComparison.Ordinal))) && allowed(host))
.Distinct(StringComparer.Ordinal);
public static string Normalise(string name)
{
var host = name?.Trim().TrimEnd('.').ToLowerInvariant();
if (string.IsNullOrEmpty(host) || host.Length > 253 || !host.Contains('.') || host.Contains(':') || host.Contains('/'))
return default;
try
{
host = Idn.GetAscii(host);
}
catch (ArgumentException)
{
return default;
}
if (Uri.CheckHostName(host) != UriHostNameType.Dns || Unreachable.Any(suffix => host.EndsWith(suffix, StringComparison.Ordinal)))
return default;
return host;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Crawler
{
//RFC 9309: the most specific group naming the crawler (or "*"), the longest matching rule, Allow winning a tie.
//A 4xx means no rules at all; a 5xx or no answer means keep out.
public sealed class RobotsTxt
{
sealed record Rule(bool Allow, string Pattern, Regex Match);
readonly List<Rule> _rules;
readonly bool _everythingDisallowed;
RobotsTxt(List<Rule> rules, bool everythingDisallowed)
{
_rules = rules;
_everythingDisallowed = everythingDisallowed;
}
public static readonly RobotsTxt AllowAll = new(new List<Rule>(), false);
public static readonly RobotsTxt DisallowAll = new(new List<Rule>(), true);
public static RobotsTxt From(int status, string text, params string[] agents) => status switch
{
>= 200 and < 300 => Parse(text, agents),
>= 400 and < 500 => AllowAll,
_ => DisallowAll
};
public static RobotsTxt Parse(string text, params string[] agents)
{
var groups = new List<(List<string> Agents, List<Rule> Rules)>();
(List<string> Agents, List<Rule> Rules) current = default;
var collectingAgents = false;
foreach (var raw in (text ?? string.Empty).Split('\n'))
{
var line = raw.Split('#')[0].Trim();
var colon = line.IndexOf(':');
if (colon <= 0)
continue;
var field = line[..colon].Trim().ToLowerInvariant();
var value = line[(colon + 1)..].Trim();
if (field == "user-agent")
{
if (!collectingAgents)
{
current = (new List<string>(), new List<Rule>());
groups.Add(current);
collectingAgents = true;
}
current.Agents.Add(value.ToLowerInvariant());
continue;
}
if (field is not ("allow" or "disallow") || current.Agents == default)
continue;
collectingAgents = false;
if (value.Length == 0)
continue;
current.Rules.Add(new Rule(field == "allow", value, Pattern(value)));
}
foreach (var agent in agents.Select(a => a.ToLowerInvariant()))
{
var named = groups.Where(g => g.Agents.Contains(agent)).SelectMany(g => g.Rules).ToList();
if (groups.Any(g => g.Agents.Contains(agent)))
return new RobotsTxt(named, false);
}
return new RobotsTxt(groups.Where(g => g.Agents.Contains("*")).SelectMany(g => g.Rules).ToList(), false);
}
public bool Allows(string path)
{
if (_everythingDisallowed)
return false;
path = string.IsNullOrEmpty(path) ? "/" : path;
Rule best = default;
foreach (var rule in _rules.Where(r => r.Match.IsMatch(path)))
if (best == default || rule.Pattern.Length > best.Pattern.Length || rule.Pattern.Length == best.Pattern.Length && rule.Allow)
best = rule;
return best?.Allow != false;
}
static Regex Pattern(string value)
{
var anchored = value.EndsWith('$');
var body = Regex.Escape(anchored ? value[..^1] : value).Replace("\\*", ".*");
return new Regex("^" + body + (anchored ? "$" : string.Empty), RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
}
}
}
+64
View File
@@ -1,3 +1,8 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
@@ -10,5 +15,64 @@ namespace PrivaPub.Federation.Inbox
get => current.Value; get => current.Value;
set => current.Value = value; set => current.Value = value;
} }
public ArrivalVerdict Verdict { get; } = new();
public static void Drop(string reason) => Current?.Verdict.Drop(reason);
public static void Reject(string reason) => Current?.Verdict.Reject(reason);
public static void Accept(string reason) => Current?.Verdict.Accept(reason);
public static void About(string objectType = default, PostVisibility? visibility = default, DateTime? created = default, LocalActor local = default) =>
Current?.Verdict.About(objectType, visibility, created, local);
}
public sealed class ArrivalVerdict
{
public string Outcome { get; private set; }
public string Reason { get; private set; }
public string ObjectType { get; private set; }
public string Audience { get; private set; }
public string LocalKind { get; private set; }
public long? AgeSeconds { get; private set; }
public void Drop(string reason) => Decide(Interactions.Dropped, reason);
public void Reject(string reason) => Decide(Interactions.Rejected, reason);
public void Accept(string reason) => Decide(Interactions.Accepted, reason);
void Decide(string outcome, string reason)
{
if (Outcome != default)
return;
Outcome = outcome;
Reason = reason;
}
public void About(string objectType, PostVisibility? visibility, DateTime? created, LocalActor local)
{
ObjectType = objectType ?? ObjectType;
if (visibility is { } known)
Audience = AudienceOf(known);
if (created is { } at)
AgeSeconds = Math.Max(0, (long)(DateTime.UtcNow - at).TotalSeconds);
if (local != default)
LocalKind = local.Kind switch
{
LocalActorKind.Person => "person",
LocalActorKind.Group when !local.IsCircle => "group",
LocalActorKind.Application => "application",
_ => default
};
}
public static string AudienceOf(PostVisibility visibility) => visibility switch
{
PostVisibility.Public => Interactions.Public,
PostVisibility.Unlisted => Interactions.Unlisted,
_ => Interactions.Private
};
} }
} }
@@ -30,10 +30,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token)) if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("quote-answer");
return; return;
}
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Follow");
Arrival.Accept("follow-answer");
await Apply(following, token); await Apply(following, token);
} }
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Social; using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines; using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
@@ -28,8 +29,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors) readonly IObjectRecords _records;
readonly IQuoteService _quotes;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
IObjectRecords records, IQuoteService quotes)
{ {
_records = records;
_quotes = quotes;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -50,19 +57,37 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(inner); var objectUri = Id(inner);
var announceId = Id(activity); var announceId = Id(activity);
if (objectUri == default || announceId == default) if (objectUri == default || announceId == default)
{
Arrival.Drop("unparseable");
return; return;
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token); var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (original is not { IsFederatedCopy: false } && !followed) if (original is not { IsFederatedCopy: false } && !followed)
{
Arrival.Drop("not-followed");
return; return;
}
if (original == default && !isLocal) if (original == default && !isLocal)
original = await _remotePosts.StoreContext(objectUri, 0, token); original = await _remotePosts.StoreContext(objectUri, 0, token);
if (original == default || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) if (original == default)
{
Arrival.Drop("fetch-failed");
return; return;
}
Arrival.About(original.ObjectType ?? "Note", original.Visibility, original.CreationDate);
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
{
Arrival.Drop("not-public");
return;
}
var to = Strings(activity["to"]); var to = Strings(activity["to"]);
var cc = Strings(activity["cc"]); var cc = Strings(activity["cc"]);
@@ -85,15 +110,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
UpdateDate = published UpdateDate = published
}; };
if (reblog.Visibility == PostVisibility.Direct) if (reblog.Visibility == PostVisibility.Direct)
{
Arrival.Drop("not-public");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(reblog, token); await DB.Default.SaveAsync(reblog, token);
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: original.IsFederatedCopy ? default : await _localActors.FindById(Models.Federation.LocalActorKind.Person, original.GroupUserId, token));
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy) if (!original.IsFederatedCopy)
@@ -103,18 +134,30 @@ namespace PrivaPub.Federation.Inbox.Handlers
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{ {
Arrival.About(Value(inner, "type"));
if (group.AvatarType != AvatarType.Group if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
{
Arrival.Drop("not-followed");
return; return;
}
var objectUri = Id(inner["object"]); var objectUri = Id(inner["object"]);
switch (Value(inner, "type")) switch (Value(inner, "type"))
{ {
case "Create" when objectUri != default: case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var post = await _remotePosts.StoreContext(objectUri, 0, token); var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default) if (post == default)
{
Arrival.Drop("fetch-failed");
return; return;
}
Arrival.Accept("stored");
Arrival.About(visibility: post.Visibility);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI; post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
@@ -123,40 +166,41 @@ namespace PrivaPub.Federation.Inbox.Handlers
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue) var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (stored == default) if (stored == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: stored.Visibility, created: stored.CreationDate);
using (var fetched = await _remoteActors.FetchObject(objectUri, token)) using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{ {
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText())); var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI) if (note == default || note.AttributedTo != stored.ActorURI)
return;
stored.Revisions.Add(new PostRevision
{ {
Title = stored.Title, Arrival.Drop(note == default ? "fetch-failed" : "misattributed");
SpoilerText = stored.SpoilerText, return;
ContentHtml = stored.ContentHtml, }
HasContentWarning = stored.HasContentWarning, Arrival.Accept(await RemoteEdits.Apply(stored, note, Id(inner), _localActors, _records, _quotes, token) ? "edit" : "refresh");
EditedAt = stored.EditedAt ?? stored.CreationDate
});
stored.Title = note.Title;
stored.SpoilerText = note.SpoilerText;
stored.HasContentWarning = note.Sensitive;
stored.Text = note.ContentHtml;
stored.ContentHtml = note.ContentHtml;
stored.Tags = note.Tags.ToList();
stored.Media = note.Attachments.ToList();
stored.EditedAt = note.Updated ?? DateTime.UtcNow;
await DB.Default.SaveAsync(stored, token);
} }
break; break;
case "Delete" when objectUri != default: case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default) if (deleted == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate);
using (var check = await _remoteActors.FetchObject(objectUri, token)) using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
{
Arrival.Drop("not-deleted");
return; return;
await DB.Default.DeleteAsync<PostEntity>(deleted.ID); }
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID); Arrival.Accept("removed");
await RemoteDeletes.Remove(deleted, objectUri, token);
break;
default:
Arrival.Drop("unsupported");
break; break;
} }
} }
@@ -68,26 +68,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
using var fetched = await _remoteActors.FetchObject(Id(node), token); using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
if (node == default)
Arrival.Drop("fetch-failed");
} }
var note = NoteParser.Parse(node); var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI) if (note == default || note.AttributedTo != author.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
return; return;
}
Arrival.About(note.Type);
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default) if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
{ {
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token); var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default) if (question != default)
{ {
await _polls.Receive(question, author, note.Title, Id(activity), token); await _polls.Receive(question, author, note.Title, Id(activity), token);
Arrival.Accept("poll-vote");
Arrival.About(visibility: question.Visibility);
return; return;
} }
} }
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token) if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) {
Arrival.Drop("duplicate");
return; return;
}
if (await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
{
Arrival.Drop("deleted");
return;
}
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList(); var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
var visibility = Addressing.Classify(to, cc, author.FollowersURL); var visibility = Addressing.Classify(to, cc, author.FollowersURL);
Arrival.About(visibility: visibility);
var addressed = to.Concat(cc) var addressed = to.Concat(cc)
.Concat(Addresses(activity)) .Concat(Addresses(activity))
@@ -111,9 +127,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true }); var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default) if (circle != default)
{ {
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle; visibility = PostVisibility.Circle;
Arrival.About(visibility: visibility);
if (!await IsCircleMember(circle, author.ActorURI, token))
{
Arrival.Drop("not-addressed");
return;
}
} }
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) ? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
@@ -126,7 +146,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted) .Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token); .ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed) if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
{
Arrival.Drop("not-addressed");
return; return;
}
if (parent == default && visibility != PostVisibility.Direct) if (parent == default && visibility != PostVisibility.Direct)
parent = await _remotePosts.Parent(note.InReplyTo, 1, token); parent = await _remotePosts.Parent(note.InReplyTo, 1, token);
@@ -150,8 +173,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: group ?? persons.FirstOrDefault());
await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
await _previews.Wanted(post, token); await _previews.Wanted(post, token);
await _quotes.Resolve(post, note, token); await _quotes.Resolve(post, note, token);
@@ -49,10 +49,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(delete["object"]); var objectUri = Id(delete["object"]);
if (!Origin.Same(objectUri, actor.ActorURI)) if (!Origin.Same(objectUri, actor.ActorURI))
{
Arrival.Drop("cross-origin");
return; return;
}
if (objectUri == actor.ActorURI) if (objectUri == actor.ActorURI)
{ {
Arrival.Accept("actor-delete");
Arrival.About(actor.AvatarType.ToString(), created: actor.Published);
actor.DeletionAt = DateTime.UtcNow; actor.DeletionAt = DateTime.UtcNow;
actor.AccountState = AvatarAccountState.Deleted; actor.AccountState = AvatarAccountState.Deleted;
await DB.Default.SaveAsync(actor, token); await DB.Default.SaveAsync(actor, token);
@@ -65,28 +70,23 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.ActorURI); await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.ActorURI);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AuthorAccountId == actor.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.AuthorAccountId == actor.ID);
await DB.Default.Update<PostEntity>().Match(p => p.ActorURI == actor.ActorURI).Modify(p => p.AuthorGone, true).ExecuteAsync(token);
return; return;
} }
await DB.Default.Update<DeletedObject>() await RemoteDeletes.Tombstone(objectUri, token);
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
{ {
Arrival.Accept("tombstone-only");
await _quotes.Revoke(objectUri, token); await _quotes.Revoke(objectUri, token);
return; return;
} }
await DB.Default.DeleteAsync<PostEntity>(post.ID); Arrival.Accept("removed");
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri); Arrival.About(post.ObjectType, post.Visibility, post.CreationDate);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await RemoteDeletes.Remove(post, objectUri, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
} }
} }
} }
@@ -31,7 +31,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor)) if (post == default || !LikeHandler.MaySee(post, actor))
{
Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
try try
{ {
@@ -39,8 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
} }
} }
@@ -27,8 +27,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = objectUri == default var post = objectUri == default
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post != default) if (post == default)
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token); {
Arrival.Drop("unknown-object");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token);
} }
} }
} }
@@ -50,7 +50,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (target == default && posts.Count > 0) if (target == default && posts.Count > 0)
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token); target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
if (target == default) if (target == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.Accept("reported");
await DB.Default.SaveAsync(new Report await DB.Default.SaveAsync(new Report
{ {
@@ -45,7 +45,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
var target = await _localActors.FindByUri(Id(follow["object"]), token); var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
{
Arrival.Drop("unknown-recipient");
return; return;
}
if (target.Kind == LocalActorKind.Person if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) && await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{ {
@@ -58,6 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
["object"] = follow.DeepClone() ["object"] = follow.DeepClone()
}; };
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token); await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
Arrival.Reject("blocked");
return; return;
} }
@@ -75,6 +79,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
record.FollowActivityURI = Id(follow); record.FollowActivityURI = Id(follow);
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token); await DB.Default.SaveAsync(record, token);
Arrival.Accept(record.IsAccepted ? "auto-accepted" : "pending");
if (target.Kind == LocalActorKind.Person) if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest, await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more