summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/activity/ir/topics_test.exs10
-rw-r--r--test/activity_expiration_test.exs27
-rw-r--r--test/activity_test.exs22
-rw-r--r--test/bbs/handler_test.exs8
-rw-r--r--test/bookmark_test.exs8
-rw-r--r--test/captcha_test.exs92
-rw-r--r--test/config/config_db_test.exs711
-rw-r--r--test/config/holder_test.exs34
-rw-r--r--test/config/loader_test.exs29
-rw-r--r--test/config/transfer_task_test.exs173
-rw-r--r--test/config_test.exs2
-rw-r--r--test/conversation/participation_test.exs196
-rw-r--r--test/conversation_test.exs28
-rw-r--r--test/daemons/activity_expiration_daemon_test.exs17
-rw-r--r--test/daemons/digest_email_daemon_test.exs35
-rw-r--r--test/daemons/scheduled_activity_daemon_test.exs19
-rw-r--r--test/docs/generator_test.exs230
-rw-r--r--test/earmark_renderer_test.exs79
-rw-r--r--test/emails/admin_email_test.exs6
-rw-r--r--test/emails/mailer_test.exs5
-rw-r--r--test/emails/user_email_test.exs4
-rw-r--r--test/emoji/formatter_test.exs33
-rw-r--r--test/emoji/loader_test.exs2
-rw-r--r--test/emoji_test.exs10
-rw-r--r--test/federation/federation_test.exs47
-rw-r--r--test/filter_test.exs12
-rw-r--r--test/fixtures/config/temp.secret.exs11
-rw-r--r--test/fixtures/emoji-reaction-no-emoji.json30
-rw-r--r--test/fixtures/emoji-reaction-too-long.json30
-rw-r--r--test/fixtures/emoji-reaction.json30
-rw-r--r--test/fixtures/emoji/packs/blank.png.zipbin0 -> 284 bytes
-rw-r--r--test/fixtures/emoji/packs/default-manifest.json10
-rw-r--r--test/fixtures/emoji/packs/finmoji.json3
-rw-r--r--test/fixtures/emoji/packs/manifest.json10
-rw-r--r--test/fixtures/margaret-corbin-grave-west-point.html2895
-rw-r--r--test/fixtures/mastodon-post-activity.json13
-rw-r--r--test/fixtures/misskey-like.json14
-rw-r--r--test/fixtures/modules/runtime_module.ex9
-rw-r--r--test/fixtures/nypd-facial-recognition-children-teenagers4.html228
-rw-r--r--test/fixtures/relay/accept-follow.json15
-rw-r--r--test/fixtures/relay/relay.json20
-rw-r--r--test/fixtures/tesla_mock/admin@mastdon.example.org.json9
-rw-r--r--test/fixtures/tesla_mock/craigmaloney.json112
-rw-r--r--test/fixtures/tesla_mock/funkwhale_audio.json44
-rw-r--r--test/fixtures/tesla_mock/funkwhale_channel.json44
-rw-r--r--test/fixtures/tesla_mock/mobilizon.org-event.json1
-rw-r--r--test/fixtures/tesla_mock/mobilizon.org-user.json1
-rw-r--r--test/fixtures/tesla_mock/peertube-social.json234
-rw-r--r--test/fixtures/users_mock/friendica_followers.json19
-rw-r--r--test/fixtures/users_mock/friendica_following.json19
-rw-r--r--test/fixtures/users_mock/localhost.json41
-rw-r--r--test/fixtures/warnings/otp_version/21.11
-rw-r--r--test/fixtures/warnings/otp_version/22.11
-rw-r--r--test/fixtures/warnings/otp_version/22.41
-rw-r--r--test/fixtures/warnings/otp_version/23.01
-rw-r--r--test/following_relationship_test.exs47
-rw-r--r--test/formatter_test.exs47
-rw-r--r--test/healthcheck_test.exs2
-rw-r--r--test/html_test.exs67
-rw-r--r--test/http/adapter_helper/gun_test.exs258
-rw-r--r--test/http/adapter_helper/hackney_test.exs47
-rw-r--r--test/http/adapter_helper_test.exs28
-rw-r--r--test/http/connection_test.exs135
-rw-r--r--test/http/request_builder_test.exs42
-rw-r--r--test/http_test.exs12
-rw-r--r--test/instance_static/add/shortcode.pngbin0 -> 95 bytes
-rw-r--r--test/instance_static/emoji/pack_bad_sha/blank.pngbin0 -> 95 bytes
-rw-r--r--test/instance_static/emoji/pack_bad_sha/pack.json13
-rw-r--r--test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zipbin0 -> 256 bytes
-rw-r--r--test/instance_static/emoji/test_pack/pack.json14
-rw-r--r--test/instance_static/emoji/test_pack_nonshared/pack.json5
-rw-r--r--test/integration/mastodon_websocket_test.exs23
-rw-r--r--test/job_queue_monitor_test.exs2
-rw-r--r--test/keys_test.exs2
-rw-r--r--test/list_test.exs2
-rw-r--r--test/marker_test.exs31
-rw-r--r--test/mfa/backup_codes_test.exs11
-rw-r--r--test/mfa/totp_test.exs17
-rw-r--r--test/mfa_test.exs52
-rw-r--r--test/moderation_log_test.exs10
-rw-r--r--test/notification_test.exs533
-rw-r--r--test/object/containment_test.exs26
-rw-r--r--test/object/fetcher_test.exs37
-rw-r--r--test/object_test.exs205
-rw-r--r--test/otp_version_test.exs42
-rw-r--r--test/pagination_test.exs2
-rw-r--r--test/plugs/admin_secret_authentication_plug_test.exs46
-rw-r--r--test/plugs/authentication_plug_test.exs52
-rw-r--r--test/plugs/basic_auth_decoder_plug_test.exs2
-rw-r--r--test/plugs/cache_control_test.exs2
-rw-r--r--test/plugs/cache_test.exs2
-rw-r--r--test/plugs/ensure_authenticated_plug_test.exs81
-rw-r--r--test/plugs/ensure_public_or_authenticated_plug_test.exs8
-rw-r--r--test/plugs/ensure_user_key_plug_test.exs2
-rw-r--r--test/plugs/http_security_plug_test.exs7
-rw-r--r--test/plugs/http_signature_plug_test.exs62
-rw-r--r--test/plugs/idempotency_plug_test.exs2
-rw-r--r--test/plugs/instance_static_test.exs6
-rw-r--r--test/plugs/legacy_authentication_plug_test.exs8
-rw-r--r--test/plugs/mapped_identity_to_signature_plug_test.exs2
-rw-r--r--test/plugs/oauth_plug_test.exs4
-rw-r--r--test/plugs/oauth_scopes_plug_test.exs193
-rw-r--r--test/plugs/rate_limiter_test.exs311
-rw-r--r--test/plugs/remote_ip_test.exs5
-rw-r--r--test/plugs/session_authentication_plug_test.exs2
-rw-r--r--test/plugs/set_format_plug_test.exs2
-rw-r--r--test/plugs/set_locale_plug_test.exs2
-rw-r--r--test/plugs/set_user_session_id_plug_test.exs2
-rw-r--r--test/plugs/uploaded_media_plug_test.exs2
-rw-r--r--test/plugs/user_enabled_plug_test.exs20
-rw-r--r--test/plugs/user_fetcher_plug_test.exs2
-rw-r--r--test/plugs/user_is_admin_plug_test.exs122
-rw-r--r--test/pool/connections_test.exs760
-rw-r--r--test/registration_test.exs2
-rw-r--r--test/repo_test.exs37
-rw-r--r--test/reverse_proxy/reverse_proxy_test.exs (renamed from test/reverse_proxy_test.exs)128
-rw-r--r--test/runtime_test.exs11
-rw-r--r--test/scheduled_activity_test.exs43
-rw-r--r--test/signature_test.exs33
-rw-r--r--test/stats_test.exs80
-rw-r--r--test/support/api_spec_helpers.ex57
-rw-r--r--test/support/builders/activity_builder.ex10
-rw-r--r--test/support/builders/user_builder.ex6
-rw-r--r--test/support/captcha_mock.ex19
-rw-r--r--test/support/channel_case.ex3
-rw-r--r--test/support/cluster.ex218
-rw-r--r--test/support/conn_case.ex106
-rw-r--r--test/support/data_case.ex8
-rw-r--r--test/support/factory.ex80
-rw-r--r--test/support/helpers.ex68
-rw-r--r--test/support/http_request_mock.ex205
-rw-r--r--test/support/mrf_module_mock.ex2
-rw-r--r--test/support/oban_helpers.ex6
-rw-r--r--test/support/web_push_http_client_mock.ex2
-rw-r--r--test/support/websocket_client.ex2
-rw-r--r--test/tasks/app_test.exs65
-rw-r--r--test/tasks/config_test.exs197
-rw-r--r--test/tasks/count_statuses_test.exs24
-rw-r--r--test/tasks/database_test.exs26
-rw-r--r--test/tasks/digest_test.exs2
-rw-r--r--test/tasks/ecto/ecto_test.exs2
-rw-r--r--test/tasks/ecto/migrate_test.exs2
-rw-r--r--test/tasks/ecto/rollback_test.exs2
-rw-r--r--test/tasks/email_test.exs52
-rw-r--r--test/tasks/emoji_test.exs226
-rw-r--r--test/tasks/instance_test.exs8
-rw-r--r--test/tasks/pleroma_test.exs2
-rw-r--r--test/tasks/refresh_counter_cache_test.exs43
-rw-r--r--test/tasks/relay_test.exs29
-rw-r--r--test/tasks/robots_txt_test.exs4
-rw-r--r--test/tasks/uploads_test.exs2
-rw-r--r--test/tasks/user_test.exs88
-rw-r--r--test/test_helper.exs8
-rw-r--r--test/upload/filter/anonymize_filename_test.exs4
-rw-r--r--test/upload/filter/dedupe_test.exs2
-rw-r--r--test/upload/filter/mogrifun_test.exs2
-rw-r--r--test/upload/filter/mogrify_test.exs4
-rw-r--r--test/upload/filter_test.exs4
-rw-r--r--test/upload_test.exs6
-rw-r--r--test/uploaders/local_test.exs23
-rw-r--r--test/uploaders/mdii_test.exs50
-rw-r--r--test/uploaders/s3_test.exs22
-rw-r--r--test/user/notification_setting_test.exs21
-rw-r--r--test/user_info_test.exs24
-rw-r--r--test/user_invite_token_test.exs6
-rw-r--r--test/user_relationship_test.exs130
-rw-r--r--test/user_search_test.exs25
-rw-r--r--test/user_test.exs797
-rw-r--r--test/web/activity_pub/activity_pub_controller_test.exs534
-rw-r--r--test/web/activity_pub/activity_pub_test.exs1408
-rw-r--r--test/web/activity_pub/mrf/anti_followbot_policy_test.exs2
-rw-r--r--test/web/activity_pub/mrf/anti_link_spam_policy_test.exs33
-rw-r--r--test/web/activity_pub/mrf/ensure_re_prepended_test.exs2
-rw-r--r--test/web/activity_pub/mrf/hellthread_policy_test.exs4
-rw-r--r--test/web/activity_pub/mrf/keyword_policy_test.exs4
-rw-r--r--test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs2
-rw-r--r--test/web/activity_pub/mrf/mention_policy_test.exs4
-rw-r--r--test/web/activity_pub/mrf/mrf_test.exs2
-rw-r--r--test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs2
-rw-r--r--test/web/activity_pub/mrf/normalize_markup_test.exs12
-rw-r--r--test/web/activity_pub/mrf/object_age_policy_test.exs106
-rw-r--r--test/web/activity_pub/mrf/reject_non_public_test.exs4
-rw-r--r--test/web/activity_pub/mrf/simple_policy_test.exs93
-rw-r--r--test/web/activity_pub/mrf/subchain_policy_test.exs3
-rw-r--r--test/web/activity_pub/mrf/tag_policy_test.exs2
-rw-r--r--test/web/activity_pub/mrf/user_allowlist_policy_test.exs4
-rw-r--r--test/web/activity_pub/mrf/vocabulary_policy_test.exs6
-rw-r--r--test/web/activity_pub/object_validator_test.exs283
-rw-r--r--test/web/activity_pub/object_validators/note_validator_test.exs35
-rw-r--r--test/web/activity_pub/object_validators/types/date_time_test.exs32
-rw-r--r--test/web/activity_pub/object_validators/types/object_id_test.exs37
-rw-r--r--test/web/activity_pub/object_validators/types/recipients_test.exs27
-rw-r--r--test/web/activity_pub/pipeline_test.exs87
-rw-r--r--test/web/activity_pub/publisher_test.exs85
-rw-r--r--test/web/activity_pub/relay_test.exs13
-rw-r--r--test/web/activity_pub/side_effects_test.exs267
-rw-r--r--test/web/activity_pub/transmogrifier/delete_handling_test.exs114
-rw-r--r--test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs61
-rw-r--r--test/web/activity_pub/transmogrifier/follow_handling_test.exs10
-rw-r--r--test/web/activity_pub/transmogrifier/like_handling_test.exs78
-rw-r--r--test/web/activity_pub/transmogrifier/undo_handling_test.exs185
-rw-r--r--test/web/activity_pub/transmogrifier_test.exs701
-rw-r--r--test/web/activity_pub/utils_test.exs154
-rw-r--r--test/web/activity_pub/views/object_view_test.exs22
-rw-r--r--test/web/activity_pub/views/user_view_test.exs28
-rw-r--r--test/web/activity_pub/visibilty_test.exs17
-rw-r--r--test/web/admin_api/admin_api_controller_test.exs2437
-rw-r--r--test/web/admin_api/config_test.exs497
-rw-r--r--test/web/admin_api/search_test.exs14
-rw-r--r--test/web/admin_api/views/report_view_test.exs26
-rw-r--r--test/web/api_spec/schema_examples_test.exs43
-rw-r--r--test/web/auth/auth_test_controller_test.exs242
-rw-r--r--test/web/auth/authenticator_test.exs2
-rw-r--r--test/web/auth/basic_auth_test.exs46
-rw-r--r--test/web/auth/pleroma_authenticator_test.exs48
-rw-r--r--test/web/auth/totp_authenticator_test.exs51
-rw-r--r--test/web/chat_channel_test.exs37
-rw-r--r--test/web/common_api/common_api_test.exs486
-rw-r--r--test/web/common_api/common_api_utils_test.exs108
-rw-r--r--test/web/fallback_test.exs2
-rw-r--r--test/web/federator_test.exs30
-rw-r--r--test/web/feed/feed_controller_test.exs227
-rw-r--r--test/web/feed/tag_controller_test.exs184
-rw-r--r--test/web/feed/user_controller_test.exs214
-rw-r--r--test/web/instances/instance_test.exs6
-rw-r--r--test/web/instances/instances_test.exs6
-rw-r--r--test/web/masto_fe_controller_test.exs11
-rw-r--r--test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs326
-rw-r--r--test/web/mastodon_api/controllers/account_controller_test.exs1247
-rw-r--r--test/web/mastodon_api/controllers/app_controller_test.exs10
-rw-r--r--test/web/mastodon_api/controllers/auth_controller_test.exs33
-rw-r--r--test/web/mastodon_api/controllers/conversation_controller_test.exs162
-rw-r--r--test/web/mastodon_api/controllers/custom_emoji_controller_test.exs11
-rw-r--r--test/web/mastodon_api/controllers/domain_block_controller_test.exs38
-rw-r--r--test/web/mastodon_api/controllers/filter_controller_test.exs58
-rw-r--r--test/web/mastodon_api/controllers/follow_request_controller_test.exs43
-rw-r--r--test/web/mastodon_api/controllers/instance_controller_test.exs23
-rw-r--r--test/web/mastodon_api/controllers/list_controller_test.exs130
-rw-r--r--test/web/mastodon_api/controllers/marker_controller_test.exs27
-rw-r--r--test/web/mastodon_api/controllers/media_controller_test.exs106
-rw-r--r--test/web/mastodon_api/controllers/notification_controller_test.exs512
-rw-r--r--test/web/mastodon_api/controllers/poll_controller_test.exs105
-rw-r--r--test/web/mastodon_api/controllers/report_controller_test.exs47
-rw-r--r--test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs118
-rw-r--r--test/web/mastodon_api/controllers/search_controller_test.exs162
-rw-r--r--test/web/mastodon_api/controllers/status_controller_test.exs900
-rw-r--r--test/web/mastodon_api/controllers/subscription_controller_test.exs35
-rw-r--r--test/web/mastodon_api/controllers/suggestion_controller_test.exs82
-rw-r--r--test/web/mastodon_api/controllers/timeline_controller_test.exs314
-rw-r--r--test/web/mastodon_api/mastodon_api_controller_test.exs107
-rw-r--r--test/web/mastodon_api/mastodon_api_test.exs12
-rw-r--r--test/web/mastodon_api/views/account_view_test.exs342
-rw-r--r--test/web/mastodon_api/views/conversation_view_test.exs5
-rw-r--r--test/web/mastodon_api/views/list_view_test.exs2
-rw-r--r--test/web/mastodon_api/views/marker_view_test.exs10
-rw-r--r--test/web/mastodon_api/views/notification_view_test.exs117
-rw-r--r--test/web/mastodon_api/views/poll_view_test.exs54
-rw-r--r--test/web/mastodon_api/views/scheduled_activity_view_test.exs6
-rw-r--r--test/web/mastodon_api/views/status_view_test.exs200
-rw-r--r--test/web/mastodon_api/views/subscription_view_test.exs (renamed from test/web/mastodon_api/views/push_subscription_view_test.exs)8
-rw-r--r--test/web/media_proxy/media_proxy_controller_test.exs12
-rw-r--r--test/web/media_proxy/media_proxy_test.exs8
-rw-r--r--test/web/metadata/feed_test.exs2
-rw-r--r--test/web/metadata/metadata_test.exs25
-rw-r--r--test/web/metadata/opengraph_test.exs4
-rw-r--r--test/web/metadata/player_view_test.exs2
-rw-r--r--test/web/metadata/rel_me_test.exs2
-rw-r--r--test/web/metadata/restrict_indexing_test.exs21
-rw-r--r--test/web/metadata/twitter_card_test.exs37
-rw-r--r--test/web/metadata/utils_test.exs32
-rw-r--r--test/web/mongooseim/mongoose_im_controller_test.exs26
-rw-r--r--test/web/node_info_test.exs124
-rw-r--r--test/web/oauth/app_test.exs2
-rw-r--r--test/web/oauth/authorization_test.exs2
-rw-r--r--test/web/oauth/ldap_authorization_test.exs16
-rw-r--r--test/web/oauth/mfa_controller_test.exs306
-rw-r--r--test/web/oauth/oauth_controller_test.exs239
-rw-r--r--test/web/oauth/token/utils_test.exs2
-rw-r--r--test/web/oauth/token_test.exs2
-rw-r--r--test/web/ostatus/ostatus_controller_test.exs168
-rw-r--r--test/web/pleroma_api/controllers/account_controller_test.exs250
-rw-r--r--test/web/pleroma_api/controllers/emoji_api_controller_test.exs899
-rw-r--r--test/web/pleroma_api/controllers/mascot_controller_test.exs41
-rw-r--r--test/web/pleroma_api/controllers/pleroma_api_controller_test.exs197
-rw-r--r--test/web/pleroma_api/controllers/scrobble_controller_test.exs19
-rw-r--r--test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs260
-rw-r--r--test/web/plugs/federating_plug_test.exs5
-rw-r--r--test/web/plugs/plug_test.exs91
-rw-r--r--test/web/push/impl_test.exs136
-rw-r--r--test/web/rel_me_test.exs8
-rw-r--r--test/web/rich_media/aws_signed_url_test.exs2
-rw-r--r--test/web/rich_media/helpers_test.exs32
-rw-r--r--test/web/rich_media/parser_test.exs2
-rw-r--r--test/web/rich_media/parsers/twitter_card_test.exs54
-rw-r--r--test/web/static_fe/static_fe_controller_test.exs178
-rw-r--r--test/web/streamer/ping_test.exs36
-rw-r--r--test/web/streamer/state_test.exs54
-rw-r--r--test/web/streamer/streamer_test.exs677
-rw-r--r--test/web/twitter_api/password_controller_test.exs10
-rw-r--r--test/web/twitter_api/remote_follow_controller_test.exs350
-rw-r--r--test/web/twitter_api/twitter_api_controller_test.exs138
-rw-r--r--test/web/twitter_api/twitter_api_test.exs273
-rw-r--r--test/web/twitter_api/util_controller_test.exs517
-rw-r--r--test/web/uploader_controller_test.exs2
-rw-r--r--test/web/views/error_view_test.exs2
-rw-r--r--test/web/web_finger/web_finger_controller_test.exs6
-rw-r--r--test/web/web_finger/web_finger_test.exs4
-rw-r--r--test/workers/cron/clear_oauth_token_worker_test.exs22
-rw-r--r--test/workers/cron/digest_emails_worker_test.exs54
-rw-r--r--test/workers/cron/new_users_digest_worker_test.exs44
-rw-r--r--test/workers/cron/purge_expired_activities_worker_test.exs56
-rw-r--r--test/workers/scheduled_activity_worker_test.exs52
-rw-r--r--test/xml_builder_test.exs2
313 files changed, 24291 insertions, 7880 deletions
diff --git a/test/activity/ir/topics_test.exs b/test/activity/ir/topics_test.exs
index e75f83586..14a6e6b71 100644
--- a/test/activity/ir/topics_test.exs
+++ b/test/activity/ir/topics_test.exs
@@ -59,8 +59,8 @@ defmodule Pleroma.Activity.Ir.TopicsTest do
describe "public visibility create events" do
setup do
activity = %Activity{
- object: %Object{data: %{"type" => "Create", "attachment" => []}},
- data: %{"to" => [Pleroma.Constants.as_public()]}
+ object: %Object{data: %{"attachment" => []}},
+ data: %{"type" => "Create", "to" => [Pleroma.Constants.as_public()]}
}
{:ok, activity: activity}
@@ -83,7 +83,7 @@ defmodule Pleroma.Activity.Ir.TopicsTest do
assert Enum.member?(topics, "hashtag:bar")
end
- test "only converts strinngs to hash tags", %{
+ test "only converts strings to hash tags", %{
activity: %{object: %{data: data} = object} = activity
} do
tagged_data = Map.put(data, "tag", [2])
@@ -98,8 +98,8 @@ defmodule Pleroma.Activity.Ir.TopicsTest do
describe "public visibility create events with attachments" do
setup do
activity = %Activity{
- object: %Object{data: %{"type" => "Create", "attachment" => ["foo"]}},
- data: %{"to" => [Pleroma.Constants.as_public()]}
+ object: %Object{data: %{"attachment" => ["foo"]}},
+ data: %{"type" => "Create", "to" => [Pleroma.Constants.as_public()]}
}
{:ok, activity: activity}
diff --git a/test/activity_expiration_test.exs b/test/activity_expiration_test.exs
index 4948fae16..e899d4509 100644
--- a/test/activity_expiration_test.exs
+++ b/test/activity_expiration_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ActivityExpirationTest do
@@ -7,6 +7,8 @@ defmodule Pleroma.ActivityExpirationTest do
alias Pleroma.ActivityExpiration
import Pleroma.Factory
+ setup do: clear_config([ActivityExpiration, :enabled])
+
test "finds activities due to be deleted only" do
activity = insert(:note_activity)
expiration_due = insert(:expiration_in_the_past, %{activity_id: activity.id})
@@ -24,4 +26,27 @@ defmodule Pleroma.ActivityExpirationTest do
now = NaiveDateTime.utc_now()
assert {:error, _} = ActivityExpiration.create(activity, now)
end
+
+ test "deletes an expiration activity" do
+ Pleroma.Config.put([ActivityExpiration, :enabled], true)
+ activity = insert(:note_activity)
+
+ naive_datetime =
+ NaiveDateTime.add(
+ NaiveDateTime.utc_now(),
+ -:timer.minutes(2),
+ :millisecond
+ )
+
+ expiration =
+ insert(
+ :expiration_in_the_past,
+ %{activity_id: activity.id, scheduled_at: naive_datetime}
+ )
+
+ Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid)
+
+ refute Pleroma.Repo.get(Pleroma.Activity, activity.id)
+ refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id)
+ end
end
diff --git a/test/activity_test.exs b/test/activity_test.exs
index e7ea2bd5e..2a92327d1 100644
--- a/test/activity_test.exs
+++ b/test/activity_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ActivityTest do
@@ -11,6 +11,11 @@ defmodule Pleroma.ActivityTest do
alias Pleroma.ThreadMute
import Pleroma.Factory
+ setup_all do
+ Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
test "returns an activity by it's AP id" do
activity = insert(:note_activity)
found_activity = Activity.get_by_ap_id(activity.data["id"])
@@ -107,8 +112,6 @@ defmodule Pleroma.ActivityTest do
describe "search" do
setup do
- Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
-
user = insert(:user)
params = %{
@@ -125,8 +128,8 @@ defmodule Pleroma.ActivityTest do
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
- {:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "find me!"})
- {:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "更新情報"})
+ {:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "find me!"})
+ {:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "更新情報"})
{:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params)
{:ok, remote_activity} = ObanHelpers.perform(job)
@@ -138,6 +141,8 @@ defmodule Pleroma.ActivityTest do
}
end
+ setup do: clear_config([:instance, :limit_to_local_content])
+
test "finds utf8 text in statuses", %{
japanese_activity: japanese_activity,
user: user
@@ -165,7 +170,6 @@ defmodule Pleroma.ActivityTest do
%{local_activity: local_activity} do
Pleroma.Config.put([:instance, :limit_to_local_content], :all)
assert [^local_activity] = Activity.search(nil, "find me")
- Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated)
end
test "find all statuses for unauthenticated users when `limit_to_local_content` is `false`",
@@ -178,8 +182,6 @@ defmodule Pleroma.ActivityTest do
activities = Enum.sort_by(Activity.search(nil, "find me"), & &1.id)
assert [^local_activity, ^remote_activity] = activities
-
- Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated)
end
end
@@ -226,8 +228,8 @@ defmodule Pleroma.ActivityTest do
test "all_by_actor_and_id/2" do
user = insert(:user)
- {:ok, %{id: id1}} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"})
- {:ok, %{id: id2}} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofefe"})
+ {:ok, %{id: id1}} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"})
+ {:ok, %{id: id2}} = Pleroma.Web.CommonAPI.post(user, %{status: "cofefe"})
assert [] == Activity.all_by_actor_and_id(user, [])
diff --git a/test/bbs/handler_test.exs b/test/bbs/handler_test.exs
index 4f0c13417..eb716486e 100644
--- a/test/bbs/handler_test.exs
+++ b/test/bbs/handler_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.BBS.HandlerTest do
@@ -21,8 +21,8 @@ defmodule Pleroma.BBS.HandlerTest do
{:ok, user} = User.follow(user, followed)
- {:ok, _first} = CommonAPI.post(user, %{"status" => "hey"})
- {:ok, _second} = CommonAPI.post(followed, %{"status" => "hello"})
+ {:ok, _first} = CommonAPI.post(user, %{status: "hey"})
+ {:ok, _second} = CommonAPI.post(followed, %{status: "hello"})
output =
capture_io(fn ->
@@ -62,7 +62,7 @@ defmodule Pleroma.BBS.HandlerTest do
user = insert(:user)
another_user = insert(:user)
- {:ok, activity} = CommonAPI.post(another_user, %{"status" => "this is a test post"})
+ {:ok, activity} = CommonAPI.post(another_user, %{status: "this is a test post"})
activity_object = Object.normalize(activity)
output =
diff --git a/test/bookmark_test.exs b/test/bookmark_test.exs
index e54bd359c..2726fe7cd 100644
--- a/test/bookmark_test.exs
+++ b/test/bookmark_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.BookmarkTest do
@@ -11,7 +11,7 @@ defmodule Pleroma.BookmarkTest do
describe "create/2" do
test "with valid params" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Some cool information"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"})
{:ok, bookmark} = Bookmark.create(user.id, activity.id)
assert bookmark.user_id == user.id
assert bookmark.activity_id == activity.id
@@ -32,7 +32,7 @@ defmodule Pleroma.BookmarkTest do
test "with valid params" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Some cool information"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"})
{:ok, _bookmark} = Bookmark.create(user.id, activity.id)
{:ok, _deleted_bookmark} = Bookmark.destroy(user.id, activity.id)
@@ -45,7 +45,7 @@ defmodule Pleroma.BookmarkTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"Scientists Discover The Secret Behind Tenshi Eating A Corndog Being So Cute – Science Daily"
})
diff --git a/test/captcha_test.exs b/test/captcha_test.exs
index 9f395d6b4..1ab9019ab 100644
--- a/test/captcha_test.exs
+++ b/test/captcha_test.exs
@@ -1,15 +1,18 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.CaptchaTest do
- use ExUnit.Case
+ use Pleroma.DataCase
import Tesla.Mock
+ alias Pleroma.Captcha
alias Pleroma.Captcha.Kocaptcha
+ alias Pleroma.Captcha.Native
@ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}]
+ setup do: clear_config([Pleroma.Captcha, :enabled])
describe "Kocaptcha" do
setup do
@@ -30,17 +33,84 @@ defmodule Pleroma.CaptchaTest do
test "new and validate" do
new = Kocaptcha.new()
- assert new[:type] == :kocaptcha
- assert new[:token] == "afa1815e14e29355e6c8f6b143a39fa2"
- assert new[:url] ==
- "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
+ token = "afa1815e14e29355e6c8f6b143a39fa2"
+ url = "https://captcha.kotobank.ch/captchas/afa1815e14e29355e6c8f6b143a39fa2.png"
- assert Kocaptcha.validate(
- new[:token],
- "7oEy8c",
- new[:answer_data]
- ) == :ok
+ assert %{
+ answer_data: answer,
+ token: ^token,
+ url: ^url,
+ type: :kocaptcha
+ } = new
+
+ assert Kocaptcha.validate(token, "7oEy8c", answer) == :ok
+ end
+ end
+
+ describe "Native" do
+ test "new and validate" do
+ new = Native.new()
+
+ assert %{
+ answer_data: answer,
+ token: token,
+ type: :native,
+ url: "data:image/png;base64," <> _
+ } = new
+
+ assert is_binary(answer)
+ assert :ok = Native.validate(token, answer, answer)
+ assert {:error, :invalid} == Native.validate(token, answer, answer <> "foobar")
+ end
+ end
+
+ describe "Captcha Wrapper" do
+ test "validate" do
+ Pleroma.Config.put([Pleroma.Captcha, :enabled], true)
+
+ new = Captcha.new()
+
+ assert %{
+ answer_data: answer,
+ token: token
+ } = new
+
+ assert is_binary(answer)
+ assert :ok = Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", answer)
+ Cachex.del(:used_captcha_cache, token)
+ end
+
+ test "doesn't validate invalid answer" do
+ Pleroma.Config.put([Pleroma.Captcha, :enabled], true)
+
+ new = Captcha.new()
+
+ assert %{
+ answer_data: answer,
+ token: token
+ } = new
+
+ assert is_binary(answer)
+
+ assert {:error, :invalid_answer_data} =
+ Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", answer <> "foobar")
+ end
+
+ test "nil answer_data" do
+ Pleroma.Config.put([Pleroma.Captcha, :enabled], true)
+
+ new = Captcha.new()
+
+ assert %{
+ answer_data: answer,
+ token: token
+ } = new
+
+ assert is_binary(answer)
+
+ assert {:error, :invalid_answer_data} =
+ Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", nil)
end
end
end
diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs
new file mode 100644
index 000000000..336de7359
--- /dev/null
+++ b/test/config/config_db_test.exs
@@ -0,0 +1,711 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ConfigDBTest do
+ use Pleroma.DataCase, async: true
+ import Pleroma.Factory
+ alias Pleroma.ConfigDB
+
+ test "get_by_key/1" do
+ config = insert(:config)
+ insert(:config)
+
+ assert config == ConfigDB.get_by_params(%{group: config.group, key: config.key})
+ end
+
+ test "create/1" do
+ {:ok, config} = ConfigDB.create(%{group: ":pleroma", key: ":some_key", value: "some_value"})
+ assert config == ConfigDB.get_by_params(%{group: ":pleroma", key: ":some_key"})
+ end
+
+ test "update/1" do
+ config = insert(:config)
+ {:ok, updated} = ConfigDB.update(config, %{value: "some_value"})
+ loaded = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+ assert loaded == updated
+ end
+
+ test "get_all_as_keyword/0" do
+ saved = insert(:config)
+ insert(:config, group: ":quack", key: ":level", value: ConfigDB.to_binary(:info))
+ insert(:config, group: ":quack", key: ":meta", value: ConfigDB.to_binary([:none]))
+
+ insert(:config,
+ group: ":quack",
+ key: ":webhook_url",
+ value: ConfigDB.to_binary("https://hooks.slack.com/services/KEY/some_val")
+ )
+
+ config = ConfigDB.get_all_as_keyword()
+
+ assert config[:pleroma] == [
+ {ConfigDB.from_string(saved.key), ConfigDB.from_binary(saved.value)}
+ ]
+
+ assert config[:quack][:level] == :info
+ assert config[:quack][:meta] == [:none]
+ assert config[:quack][:webhook_url] == "https://hooks.slack.com/services/KEY/some_val"
+ end
+
+ describe "update_or_create/1" do
+ test "common" do
+ config = insert(:config)
+ key2 = "another_key"
+
+ params = [
+ %{group: "pleroma", key: key2, value: "another_value"},
+ %{group: config.group, key: config.key, value: "new_value"}
+ ]
+
+ assert Repo.all(ConfigDB) |> length() == 1
+
+ Enum.each(params, &ConfigDB.update_or_create(&1))
+
+ assert Repo.all(ConfigDB) |> length() == 2
+
+ config1 = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+ config2 = ConfigDB.get_by_params(%{group: "pleroma", key: key2})
+
+ assert config1.value == ConfigDB.transform("new_value")
+ assert config2.value == ConfigDB.transform("another_value")
+ end
+
+ test "partial update" do
+ config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: :val2))
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config.group,
+ key: config.key,
+ value: [key1: :val1, key3: :val3]
+ })
+
+ updated = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+
+ value = ConfigDB.from_binary(updated.value)
+ assert length(value) == 3
+ assert value[:key1] == :val1
+ assert value[:key2] == :val2
+ assert value[:key3] == :val3
+ end
+
+ test "deep merge" do
+ config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: [k1: :v1, k2: "v2"]))
+
+ {:ok, config} =
+ ConfigDB.update_or_create(%{
+ group: config.group,
+ key: config.key,
+ value: [key1: :val1, key2: [k2: :v2, k3: :v3], key3: :val3]
+ })
+
+ updated = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+
+ assert config.value == updated.value
+
+ value = ConfigDB.from_binary(updated.value)
+ assert value[:key1] == :val1
+ assert value[:key2] == [k1: :v1, k2: :v2, k3: :v3]
+ assert value[:key3] == :val3
+ end
+
+ test "only full update for some keys" do
+ config1 = insert(:config, key: ":ecto_repos", value: ConfigDB.to_binary(repo: Pleroma.Repo))
+
+ config2 =
+ insert(:config, group: ":cors_plug", key: ":max_age", value: ConfigDB.to_binary(18))
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config1.group,
+ key: config1.key,
+ value: [another_repo: [Pleroma.Repo]]
+ })
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config2.group,
+ key: config2.key,
+ value: 777
+ })
+
+ updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key})
+ updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key})
+
+ assert ConfigDB.from_binary(updated1.value) == [another_repo: [Pleroma.Repo]]
+ assert ConfigDB.from_binary(updated2.value) == 777
+ end
+
+ test "full update if value is not keyword" do
+ config =
+ insert(:config,
+ group: ":tesla",
+ key: ":adapter",
+ value: ConfigDB.to_binary(Tesla.Adapter.Hackney)
+ )
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config.group,
+ key: config.key,
+ value: Tesla.Adapter.Httpc
+ })
+
+ updated = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+
+ assert ConfigDB.from_binary(updated.value) == Tesla.Adapter.Httpc
+ end
+
+ test "only full update for some subkeys" do
+ config1 =
+ insert(:config,
+ key: ":emoji",
+ value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1])
+ )
+
+ config2 =
+ insert(:config,
+ key: ":assets",
+ value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1])
+ )
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config1.group,
+ key: config1.key,
+ value: [groups: [c: 3, d: 4], key: [b: 2]]
+ })
+
+ {:ok, _config} =
+ ConfigDB.update_or_create(%{
+ group: config2.group,
+ key: config2.key,
+ value: [mascots: [c: 3, d: 4], key: [b: 2]]
+ })
+
+ updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key})
+ updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key})
+
+ assert ConfigDB.from_binary(updated1.value) == [groups: [c: 3, d: 4], key: [a: 1, b: 2]]
+ assert ConfigDB.from_binary(updated2.value) == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]]
+ end
+ end
+
+ describe "delete/1" do
+ test "error on deleting non existing setting" do
+ {:error, error} = ConfigDB.delete(%{group: ":pleroma", key: ":key"})
+ assert error =~ "Config with params %{group: \":pleroma\", key: \":key\"} not found"
+ end
+
+ test "full delete" do
+ config = insert(:config)
+ {:ok, deleted} = ConfigDB.delete(%{group: config.group, key: config.key})
+ assert Ecto.get_meta(deleted, :state) == :deleted
+ refute ConfigDB.get_by_params(%{group: config.group, key: config.key})
+ end
+
+ test "partial subkeys delete" do
+ config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]))
+
+ {:ok, deleted} =
+ ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]})
+
+ assert Ecto.get_meta(deleted, :state) == :loaded
+
+ assert deleted.value == ConfigDB.to_binary(key: [a: 1])
+
+ updated = ConfigDB.get_by_params(%{group: config.group, key: config.key})
+
+ assert updated.value == deleted.value
+ end
+
+ test "full delete if remaining value after subkeys deletion is empty list" do
+ config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2]))
+
+ {:ok, deleted} =
+ ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]})
+
+ assert Ecto.get_meta(deleted, :state) == :deleted
+
+ refute ConfigDB.get_by_params(%{group: config.group, key: config.key})
+ end
+ end
+
+ describe "transform/1" do
+ test "string" do
+ binary = ConfigDB.transform("value as string")
+ assert binary == :erlang.term_to_binary("value as string")
+ assert ConfigDB.from_binary(binary) == "value as string"
+ end
+
+ test "boolean" do
+ binary = ConfigDB.transform(false)
+ assert binary == :erlang.term_to_binary(false)
+ assert ConfigDB.from_binary(binary) == false
+ end
+
+ test "nil" do
+ binary = ConfigDB.transform(nil)
+ assert binary == :erlang.term_to_binary(nil)
+ assert ConfigDB.from_binary(binary) == nil
+ end
+
+ test "integer" do
+ binary = ConfigDB.transform(150)
+ assert binary == :erlang.term_to_binary(150)
+ assert ConfigDB.from_binary(binary) == 150
+ end
+
+ test "atom" do
+ binary = ConfigDB.transform(":atom")
+ assert binary == :erlang.term_to_binary(:atom)
+ assert ConfigDB.from_binary(binary) == :atom
+ end
+
+ test "ssl options" do
+ binary = ConfigDB.transform([":tlsv1", ":tlsv1.1", ":tlsv1.2"])
+ assert binary == :erlang.term_to_binary([:tlsv1, :"tlsv1.1", :"tlsv1.2"])
+ assert ConfigDB.from_binary(binary) == [:tlsv1, :"tlsv1.1", :"tlsv1.2"]
+ end
+
+ test "pleroma module" do
+ binary = ConfigDB.transform("Pleroma.Bookmark")
+ assert binary == :erlang.term_to_binary(Pleroma.Bookmark)
+ assert ConfigDB.from_binary(binary) == Pleroma.Bookmark
+ end
+
+ test "pleroma string" do
+ binary = ConfigDB.transform("Pleroma")
+ assert binary == :erlang.term_to_binary("Pleroma")
+ assert ConfigDB.from_binary(binary) == "Pleroma"
+ end
+
+ test "phoenix module" do
+ binary = ConfigDB.transform("Phoenix.Socket.V1.JSONSerializer")
+ assert binary == :erlang.term_to_binary(Phoenix.Socket.V1.JSONSerializer)
+ assert ConfigDB.from_binary(binary) == Phoenix.Socket.V1.JSONSerializer
+ end
+
+ test "tesla module" do
+ binary = ConfigDB.transform("Tesla.Adapter.Hackney")
+ assert binary == :erlang.term_to_binary(Tesla.Adapter.Hackney)
+ assert ConfigDB.from_binary(binary) == Tesla.Adapter.Hackney
+ end
+
+ test "ExSyslogger module" do
+ binary = ConfigDB.transform("ExSyslogger")
+ assert binary == :erlang.term_to_binary(ExSyslogger)
+ assert ConfigDB.from_binary(binary) == ExSyslogger
+ end
+
+ test "Quack.Logger module" do
+ binary = ConfigDB.transform("Quack.Logger")
+ assert binary == :erlang.term_to_binary(Quack.Logger)
+ assert ConfigDB.from_binary(binary) == Quack.Logger
+ end
+
+ test "Swoosh.Adapters modules" do
+ binary = ConfigDB.transform("Swoosh.Adapters.SMTP")
+ assert binary == :erlang.term_to_binary(Swoosh.Adapters.SMTP)
+ assert ConfigDB.from_binary(binary) == Swoosh.Adapters.SMTP
+ binary = ConfigDB.transform("Swoosh.Adapters.AmazonSES")
+ assert binary == :erlang.term_to_binary(Swoosh.Adapters.AmazonSES)
+ assert ConfigDB.from_binary(binary) == Swoosh.Adapters.AmazonSES
+ end
+
+ test "sigil" do
+ binary = ConfigDB.transform("~r[comp[lL][aA][iI][nN]er]")
+ assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/)
+ assert ConfigDB.from_binary(binary) == ~r/comp[lL][aA][iI][nN]er/
+ end
+
+ test "link sigil" do
+ binary = ConfigDB.transform("~r/https:\/\/example.com/")
+ assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/)
+ assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/
+ end
+
+ test "link sigil with um modifiers" do
+ binary = ConfigDB.transform("~r/https:\/\/example.com/um")
+ assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/um)
+ assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/um
+ end
+
+ test "link sigil with i modifier" do
+ binary = ConfigDB.transform("~r/https:\/\/example.com/i")
+ assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/i)
+ assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/i
+ end
+
+ test "link sigil with s modifier" do
+ binary = ConfigDB.transform("~r/https:\/\/example.com/s")
+ assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/s)
+ assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/s
+ end
+
+ test "raise if valid delimiter not found" do
+ assert_raise ArgumentError, "valid delimiter for Regex expression not found", fn ->
+ ConfigDB.transform("~r/https://[]{}<>\"'()|example.com/s")
+ end
+ end
+
+ test "2 child tuple" do
+ binary = ConfigDB.transform(%{"tuple" => ["v1", ":v2"]})
+ assert binary == :erlang.term_to_binary({"v1", :v2})
+ assert ConfigDB.from_binary(binary) == {"v1", :v2}
+ end
+
+ test "proxy tuple with localhost" do
+ binary =
+ ConfigDB.transform(%{
+ "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]
+ })
+
+ assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, :localhost, 1234}})
+ assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, :localhost, 1234}}
+ end
+
+ test "proxy tuple with domain" do
+ binary =
+ ConfigDB.transform(%{
+ "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]
+ })
+
+ assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, 'domain.com', 1234}})
+ assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, 'domain.com', 1234}}
+ end
+
+ test "proxy tuple with ip" do
+ binary =
+ ConfigDB.transform(%{
+ "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]
+ })
+
+ assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}})
+ assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}}
+ end
+
+ test "tuple with n childs" do
+ binary =
+ ConfigDB.transform(%{
+ "tuple" => [
+ "v1",
+ ":v2",
+ "Pleroma.Bookmark",
+ 150,
+ false,
+ "Phoenix.Socket.V1.JSONSerializer"
+ ]
+ })
+
+ assert binary ==
+ :erlang.term_to_binary(
+ {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer}
+ )
+
+ assert ConfigDB.from_binary(binary) ==
+ {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer}
+ end
+
+ test "map with string key" do
+ binary = ConfigDB.transform(%{"key" => "value"})
+ assert binary == :erlang.term_to_binary(%{"key" => "value"})
+ assert ConfigDB.from_binary(binary) == %{"key" => "value"}
+ end
+
+ test "map with atom key" do
+ binary = ConfigDB.transform(%{":key" => "value"})
+ assert binary == :erlang.term_to_binary(%{key: "value"})
+ assert ConfigDB.from_binary(binary) == %{key: "value"}
+ end
+
+ test "list of strings" do
+ binary = ConfigDB.transform(["v1", "v2", "v3"])
+ assert binary == :erlang.term_to_binary(["v1", "v2", "v3"])
+ assert ConfigDB.from_binary(binary) == ["v1", "v2", "v3"]
+ end
+
+ test "list of modules" do
+ binary = ConfigDB.transform(["Pleroma.Repo", "Pleroma.Activity"])
+ assert binary == :erlang.term_to_binary([Pleroma.Repo, Pleroma.Activity])
+ assert ConfigDB.from_binary(binary) == [Pleroma.Repo, Pleroma.Activity]
+ end
+
+ test "list of atoms" do
+ binary = ConfigDB.transform([":v1", ":v2", ":v3"])
+ assert binary == :erlang.term_to_binary([:v1, :v2, :v3])
+ assert ConfigDB.from_binary(binary) == [:v1, :v2, :v3]
+ end
+
+ test "list of mixed values" do
+ binary =
+ ConfigDB.transform([
+ "v1",
+ ":v2",
+ "Pleroma.Repo",
+ "Phoenix.Socket.V1.JSONSerializer",
+ 15,
+ false
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary([
+ "v1",
+ :v2,
+ Pleroma.Repo,
+ Phoenix.Socket.V1.JSONSerializer,
+ 15,
+ false
+ ])
+
+ assert ConfigDB.from_binary(binary) == [
+ "v1",
+ :v2,
+ Pleroma.Repo,
+ Phoenix.Socket.V1.JSONSerializer,
+ 15,
+ false
+ ]
+ end
+
+ test "simple keyword" do
+ binary = ConfigDB.transform([%{"tuple" => [":key", "value"]}])
+ assert binary == :erlang.term_to_binary([{:key, "value"}])
+ assert ConfigDB.from_binary(binary) == [{:key, "value"}]
+ assert ConfigDB.from_binary(binary) == [key: "value"]
+ end
+
+ test "keyword with partial_chain key" do
+ binary =
+ ConfigDB.transform([%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}])
+
+ assert binary == :erlang.term_to_binary(partial_chain: &:hackney_connect.partial_chain/1)
+ assert ConfigDB.from_binary(binary) == [partial_chain: &:hackney_connect.partial_chain/1]
+ end
+
+ test "keyword" do
+ binary =
+ ConfigDB.transform([
+ %{"tuple" => [":types", "Pleroma.PostgresTypes"]},
+ %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]},
+ %{"tuple" => [":migration_lock", nil]},
+ %{"tuple" => [":key1", 150]},
+ %{"tuple" => [":key2", "string"]}
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary(
+ types: Pleroma.PostgresTypes,
+ telemetry_event: [Pleroma.Repo.Instrumenter],
+ migration_lock: nil,
+ key1: 150,
+ key2: "string"
+ )
+
+ assert ConfigDB.from_binary(binary) == [
+ types: Pleroma.PostgresTypes,
+ telemetry_event: [Pleroma.Repo.Instrumenter],
+ migration_lock: nil,
+ key1: 150,
+ key2: "string"
+ ]
+ end
+
+ test "complex keyword with nested mixed childs" do
+ binary =
+ ConfigDB.transform([
+ %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]},
+ %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]},
+ %{"tuple" => [":link_name", true]},
+ %{"tuple" => [":proxy_remote", false]},
+ %{"tuple" => [":common_map", %{":key" => "value"}]},
+ %{
+ "tuple" => [
+ ":proxy_opts",
+ [
+ %{"tuple" => [":redirect_on_failure", false]},
+ %{"tuple" => [":max_body_length", 1_048_576]},
+ %{
+ "tuple" => [
+ ":http",
+ [%{"tuple" => [":follow_redirect", true]}, %{"tuple" => [":pool", ":upload"]}]
+ ]
+ }
+ ]
+ ]
+ }
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary(
+ uploader: Pleroma.Uploaders.Local,
+ filters: [Pleroma.Upload.Filter.Dedupe],
+ link_name: true,
+ proxy_remote: false,
+ common_map: %{key: "value"},
+ proxy_opts: [
+ redirect_on_failure: false,
+ max_body_length: 1_048_576,
+ http: [
+ follow_redirect: true,
+ pool: :upload
+ ]
+ ]
+ )
+
+ assert ConfigDB.from_binary(binary) ==
+ [
+ uploader: Pleroma.Uploaders.Local,
+ filters: [Pleroma.Upload.Filter.Dedupe],
+ link_name: true,
+ proxy_remote: false,
+ common_map: %{key: "value"},
+ proxy_opts: [
+ redirect_on_failure: false,
+ max_body_length: 1_048_576,
+ http: [
+ follow_redirect: true,
+ pool: :upload
+ ]
+ ]
+ ]
+ end
+
+ test "common keyword" do
+ binary =
+ ConfigDB.transform([
+ %{"tuple" => [":level", ":warn"]},
+ %{"tuple" => [":meta", [":all"]]},
+ %{"tuple" => [":path", ""]},
+ %{"tuple" => [":val", nil]},
+ %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]}
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary(
+ level: :warn,
+ meta: [:all],
+ path: "",
+ val: nil,
+ webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE"
+ )
+
+ assert ConfigDB.from_binary(binary) == [
+ level: :warn,
+ meta: [:all],
+ path: "",
+ val: nil,
+ webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE"
+ ]
+ end
+
+ test "complex keyword with sigil" do
+ binary =
+ ConfigDB.transform([
+ %{"tuple" => [":federated_timeline_removal", []]},
+ %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]},
+ %{"tuple" => [":replace", []]}
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary(
+ federated_timeline_removal: [],
+ reject: [~r/comp[lL][aA][iI][nN]er/],
+ replace: []
+ )
+
+ assert ConfigDB.from_binary(binary) ==
+ [federated_timeline_removal: [], reject: [~r/comp[lL][aA][iI][nN]er/], replace: []]
+ end
+
+ test "complex keyword with tuples with more than 2 values" do
+ binary =
+ ConfigDB.transform([
+ %{
+ "tuple" => [
+ ":http",
+ [
+ %{
+ "tuple" => [
+ ":key1",
+ [
+ %{
+ "tuple" => [
+ ":_",
+ [
+ %{
+ "tuple" => [
+ "/api/v1/streaming",
+ "Pleroma.Web.MastodonAPI.WebsocketHandler",
+ []
+ ]
+ },
+ %{
+ "tuple" => [
+ "/websocket",
+ "Phoenix.Endpoint.CowboyWebSocket",
+ %{
+ "tuple" => [
+ "Phoenix.Transports.WebSocket",
+ %{
+ "tuple" => [
+ "Pleroma.Web.Endpoint",
+ "Pleroma.Web.UserSocket",
+ []
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ %{
+ "tuple" => [
+ ":_",
+ "Phoenix.Endpoint.Cowboy2Handler",
+ %{"tuple" => ["Pleroma.Web.Endpoint", []]}
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ])
+
+ assert binary ==
+ :erlang.term_to_binary(
+ http: [
+ key1: [
+ _: [
+ {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
+ {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
+ {Phoenix.Transports.WebSocket,
+ {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}},
+ {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
+ ]
+ ]
+ ]
+ )
+
+ assert ConfigDB.from_binary(binary) == [
+ http: [
+ key1: [
+ {:_,
+ [
+ {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
+ {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
+ {Phoenix.Transports.WebSocket,
+ {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}},
+ {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
+ ]}
+ ]
+ ]
+ ]
+ end
+ end
+end
diff --git a/test/config/holder_test.exs b/test/config/holder_test.exs
new file mode 100644
index 000000000..15d48b5c7
--- /dev/null
+++ b/test/config/holder_test.exs
@@ -0,0 +1,34 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Config.HolderTest do
+ use ExUnit.Case, async: true
+
+ alias Pleroma.Config.Holder
+
+ test "default_config/0" do
+ config = Holder.default_config()
+ assert config[:pleroma][Pleroma.Uploaders.Local][:uploads] == "test/uploads"
+ assert config[:tesla][:adapter] == Tesla.Mock
+
+ refute config[:pleroma][Pleroma.Repo]
+ refute config[:pleroma][Pleroma.Web.Endpoint]
+ refute config[:pleroma][:env]
+ refute config[:pleroma][:configurable_from_database]
+ refute config[:pleroma][:database]
+ refute config[:phoenix][:serve_endpoints]
+ end
+
+ test "default_config/1" do
+ pleroma_config = Holder.default_config(:pleroma)
+ assert pleroma_config[Pleroma.Uploaders.Local][:uploads] == "test/uploads"
+ tesla_config = Holder.default_config(:tesla)
+ assert tesla_config[:adapter] == Tesla.Mock
+ end
+
+ test "default_config/2" do
+ assert Holder.default_config(:pleroma, Pleroma.Uploaders.Local) == [uploads: "test/uploads"]
+ assert Holder.default_config(:tesla, :adapter) == Tesla.Mock
+ end
+end
diff --git a/test/config/loader_test.exs b/test/config/loader_test.exs
new file mode 100644
index 000000000..607572f4e
--- /dev/null
+++ b/test/config/loader_test.exs
@@ -0,0 +1,29 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Config.LoaderTest do
+ use ExUnit.Case, async: true
+
+ alias Pleroma.Config.Loader
+
+ test "read/1" do
+ config = Loader.read("test/fixtures/config/temp.secret.exs")
+ assert config[:pleroma][:first_setting][:key] == "value"
+ assert config[:pleroma][:first_setting][:key2] == [Pleroma.Repo]
+ assert config[:quack][:level] == :info
+ end
+
+ test "filter_group/2" do
+ assert Loader.filter_group(:pleroma,
+ pleroma: [
+ {Pleroma.Repo, [a: 1, b: 2]},
+ {Pleroma.Upload, [a: 1, b: 2]},
+ {Pleroma.Web.Endpoint, []},
+ env: :test,
+ configurable_from_database: true,
+ database: []
+ ]
+ ) == [{Pleroma.Upload, [a: 1, b: 2]}]
+ end
+end
diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs
index 9074f3b97..473899d1d 100644
--- a/test/config/transfer_task_test.exs
+++ b/test/config/transfer_task_test.exs
@@ -1,51 +1,184 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Config.TransferTaskTest do
use Pleroma.DataCase
- clear_config([:instance, :dynamic_configuration]) do
- Pleroma.Config.put([:instance, :dynamic_configuration], true)
- end
+ import ExUnit.CaptureLog
+
+ alias Pleroma.Config.TransferTask
+ alias Pleroma.ConfigDB
+
+ setup do: clear_config(:configurable_from_database, true)
test "transfer config values from db to env" do
refute Application.get_env(:pleroma, :test_key)
refute Application.get_env(:idna, :test_key)
+ refute Application.get_env(:quack, :test_key)
+ refute Application.get_env(:postgrex, :test_key)
+ initial = Application.get_env(:logger, :level)
- Pleroma.Web.AdminAPI.Config.create(%{
- group: "pleroma",
- key: "test_key",
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":test_key",
value: [live: 2, com: 3]
})
- Pleroma.Web.AdminAPI.Config.create(%{
- group: "idna",
- key: "test_key",
+ ConfigDB.create(%{
+ group: ":idna",
+ key: ":test_key",
value: [live: 15, com: 35]
})
- Pleroma.Config.TransferTask.start_link([])
+ ConfigDB.create(%{
+ group: ":quack",
+ key: ":test_key",
+ value: [:test_value1, :test_value2]
+ })
+
+ ConfigDB.create(%{
+ group: ":postgrex",
+ key: ":test_key",
+ value: :value
+ })
+
+ ConfigDB.create(%{group: ":logger", key: ":level", value: :debug})
+
+ TransferTask.start_link([])
assert Application.get_env(:pleroma, :test_key) == [live: 2, com: 3]
assert Application.get_env(:idna, :test_key) == [live: 15, com: 35]
+ assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2]
+ assert Application.get_env(:logger, :level) == :debug
+ assert Application.get_env(:postgrex, :test_key) == :value
on_exit(fn ->
Application.delete_env(:pleroma, :test_key)
Application.delete_env(:idna, :test_key)
+ Application.delete_env(:quack, :test_key)
+ Application.delete_env(:postgrex, :test_key)
+ Application.put_env(:logger, :level, initial)
end)
end
- test "non existing atom" do
- Pleroma.Web.AdminAPI.Config.create(%{
- group: "pleroma",
- key: "undefined_atom_key",
- value: [live: 2, com: 3]
+ test "transfer config values for 1 group and some keys" do
+ level = Application.get_env(:quack, :level)
+ meta = Application.get_env(:quack, :meta)
+
+ ConfigDB.create(%{
+ group: ":quack",
+ key: ":level",
+ value: :info
})
- assert ExUnit.CaptureLog.capture_log(fn ->
- Pleroma.Config.TransferTask.start_link([])
- end) =~
- "updating env causes error, key: \"undefined_atom_key\", error: %ArgumentError{message: \"argument error\"}"
+ ConfigDB.create(%{
+ group: ":quack",
+ key: ":meta",
+ value: [:none]
+ })
+
+ TransferTask.start_link([])
+
+ assert Application.get_env(:quack, :level) == :info
+ assert Application.get_env(:quack, :meta) == [:none]
+ default = Pleroma.Config.Holder.default_config(:quack, :webhook_url)
+ assert Application.get_env(:quack, :webhook_url) == default
+
+ on_exit(fn ->
+ Application.put_env(:quack, :level, level)
+ Application.put_env(:quack, :meta, meta)
+ end)
+ end
+
+ test "transfer config values with full subkey update" do
+ clear_config(:emoji)
+ clear_config(:assets)
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":emoji",
+ value: [groups: [a: 1, b: 2]]
+ })
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":assets",
+ value: [mascots: [a: 1, b: 2]]
+ })
+
+ TransferTask.start_link([])
+
+ emoji_env = Application.get_env(:pleroma, :emoji)
+ assert emoji_env[:groups] == [a: 1, b: 2]
+ assets_env = Application.get_env(:pleroma, :assets)
+ assert assets_env[:mascots] == [a: 1, b: 2]
+ end
+
+ describe "pleroma restart" do
+ setup do
+ on_exit(fn -> Restarter.Pleroma.refresh() end)
+ end
+
+ test "don't restart if no reboot time settings were changed" do
+ clear_config(:emoji)
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":emoji",
+ value: [groups: [a: 1, b: 2]]
+ })
+
+ refute String.contains?(
+ capture_log(fn -> TransferTask.start_link([]) end),
+ "pleroma restarted"
+ )
+ end
+
+ test "on reboot time key" do
+ clear_config(:chat)
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":chat",
+ value: [enabled: false]
+ })
+
+ assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted"
+ end
+
+ test "on reboot time subkey" do
+ clear_config(Pleroma.Captcha)
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: "Pleroma.Captcha",
+ value: [seconds_valid: 60]
+ })
+
+ assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted"
+ end
+
+ test "don't restart pleroma on reboot time key and subkey if there is false flag" do
+ clear_config(:chat)
+ clear_config(Pleroma.Captcha)
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":chat",
+ value: [enabled: false]
+ })
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: "Pleroma.Captcha",
+ value: [seconds_valid: 60]
+ })
+
+ refute String.contains?(
+ capture_log(fn -> TransferTask.load_and_update_env([], false) end),
+ "pleroma restarted"
+ )
+ end
end
end
diff --git a/test/config_test.exs b/test/config_test.exs
index 438fe62ee..a46ab4302 100644
--- a/test/config_test.exs
+++ b/test/config_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ConfigTest do
diff --git a/test/conversation/participation_test.exs b/test/conversation/participation_test.exs
index 64c350904..59a1b6492 100644
--- a/test/conversation/participation_test.exs
+++ b/test/conversation/participation_test.exs
@@ -1,11 +1,13 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Conversation.ParticipationTest do
use Pleroma.DataCase
import Pleroma.Factory
+ alias Pleroma.Conversation
alias Pleroma.Conversation.Participation
+ alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.CommonAPI
@@ -14,7 +16,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
other_user = insert(:user)
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"})
[participation] = Participation.for_user(user)
@@ -28,7 +30,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
other_user = insert(:user)
{:ok, _} =
- CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"})
user = User.get_cached_by_id(user.id)
other_user = User.get_cached_by_id(other_user.id)
@@ -36,14 +38,14 @@ defmodule Pleroma.Conversation.ParticipationTest do
[%{read: true}] = Participation.for_user(user)
[%{read: false} = participation] = Participation.for_user(other_user)
- assert User.get_cached_by_id(user.id).info.unread_conversation_count == 0
- assert User.get_cached_by_id(other_user.id).info.unread_conversation_count == 1
+ assert User.get_cached_by_id(user.id).unread_conversation_count == 0
+ assert User.get_cached_by_id(other_user.id).unread_conversation_count == 1
{:ok, _} =
CommonAPI.post(other_user, %{
- "status" => "Hey @#{user.nickname}.",
- "visibility" => "direct",
- "in_reply_to_conversation_id" => participation.id
+ status: "Hey @#{user.nickname}.",
+ visibility: "direct",
+ in_reply_to_conversation_id: participation.id
})
user = User.get_cached_by_id(user.id)
@@ -52,8 +54,8 @@ defmodule Pleroma.Conversation.ParticipationTest do
[%{read: false}] = Participation.for_user(user)
[%{read: true}] = Participation.for_user(other_user)
- assert User.get_cached_by_id(user.id).info.unread_conversation_count == 1
- assert User.get_cached_by_id(other_user.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user.id).unread_conversation_count == 1
+ assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0
end
test "for a new conversation, it sets the recipents of the participation" do
@@ -62,7 +64,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
third_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"})
user = User.get_cached_by_id(user.id)
other_user = User.get_cached_by_id(other_user.id)
@@ -77,9 +79,9 @@ defmodule Pleroma.Conversation.ParticipationTest do
{:ok, _activity} =
CommonAPI.post(user, %{
- "in_reply_to_status_id" => activity.id,
- "status" => "Hey @#{third_user.nickname}.",
- "visibility" => "direct"
+ in_reply_to_status_id: activity.id,
+ status: "Hey @#{third_user.nickname}.",
+ visibility: "direct"
})
[participation] = Participation.for_user(user)
@@ -98,7 +100,9 @@ defmodule Pleroma.Conversation.ParticipationTest do
assert participation.user_id == user.id
assert participation.conversation_id == conversation.id
+ # Needed because updated_at is accurate down to a second
:timer.sleep(1000)
+
# Creating again returns the same participation
{:ok, %Participation{} = participation_two} =
Participation.create_for_user_and_conversation(user, conversation)
@@ -121,9 +125,10 @@ defmodule Pleroma.Conversation.ParticipationTest do
test "it marks a participation as read" do
participation = insert(:participation, %{read: false})
- {:ok, participation} = Participation.mark_as_read(participation)
+ {:ok, updated_participation} = Participation.mark_as_read(participation)
- assert participation.read
+ assert updated_participation.read
+ assert updated_participation.updated_at == participation.updated_at
end
test "it marks a participation as unread" do
@@ -140,7 +145,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
participation2 = insert(:participation, %{read: false, user: user})
participation3 = insert(:participation, %{read: false, user: other_user})
- {:ok, [%{read: true}, %{read: true}]} = Participation.mark_all_as_read(user)
+ {:ok, _, [%{read: true}, %{read: true}]} = Participation.mark_all_as_read(user)
assert Participation.get(participation1.id).read == true
assert Participation.get(participation2.id).read == true
@@ -149,18 +154,27 @@ defmodule Pleroma.Conversation.ParticipationTest do
test "gets all the participations for a user, ordered by updated at descending" do
user = insert(:user)
- {:ok, activity_one} = CommonAPI.post(user, %{"status" => "x", "visibility" => "direct"})
- :timer.sleep(1000)
- {:ok, activity_two} = CommonAPI.post(user, %{"status" => "x", "visibility" => "direct"})
- :timer.sleep(1000)
+ {:ok, activity_one} = CommonAPI.post(user, %{status: "x", visibility: "direct"})
+ {:ok, activity_two} = CommonAPI.post(user, %{status: "x", visibility: "direct"})
{:ok, activity_three} =
CommonAPI.post(user, %{
- "status" => "x",
- "visibility" => "direct",
- "in_reply_to_status_id" => activity_one.id
+ status: "x",
+ visibility: "direct",
+ in_reply_to_status_id: activity_one.id
})
+ # Offset participations because the accuracy of updated_at is down to a second
+
+ for {activity, offset} <- [{activity_two, 1}, {activity_three, 2}] do
+ conversation = Conversation.get_for_ap_id(activity.data["context"])
+ participation = Participation.for_user_and_conversation(user, conversation)
+ updated_at = NaiveDateTime.add(Map.get(participation, :updated_at), offset)
+
+ Ecto.Changeset.change(participation, %{updated_at: updated_at})
+ |> Repo.update!()
+ end
+
assert [participation_one, participation_two] = Participation.for_user(user)
object2 = Pleroma.Object.normalize(activity_two)
@@ -187,7 +201,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
test "Doesn't die when the conversation gets empty" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
[participation] = Participation.for_user_with_last_activity_id(user)
assert participation.last_activity_id == activity.id
@@ -201,7 +215,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, _activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
[participation] = Participation.for_user_with_last_activity_id(user)
participation = Repo.preload(participation, :recipients)
@@ -216,4 +230,134 @@ defmodule Pleroma.Conversation.ParticipationTest do
assert user in participation.recipients
assert other_user in participation.recipients
end
+
+ describe "blocking" do
+ test "when the user blocks a recipient, the existing conversations with them are marked as read" do
+ blocker = insert(:user)
+ blocked = insert(:user)
+ third_user = insert(:user)
+
+ {:ok, _direct1} =
+ CommonAPI.post(third_user, %{
+ status: "Hi @#{blocker.nickname}",
+ visibility: "direct"
+ })
+
+ {:ok, _direct2} =
+ CommonAPI.post(third_user, %{
+ status: "Hi @#{blocker.nickname}, @#{blocked.nickname}",
+ visibility: "direct"
+ })
+
+ {:ok, _direct3} =
+ CommonAPI.post(blocked, %{
+ status: "Hi @#{blocker.nickname}",
+ visibility: "direct"
+ })
+
+ {:ok, _direct4} =
+ CommonAPI.post(blocked, %{
+ status: "Hi @#{blocker.nickname}, @#{third_user.nickname}",
+ visibility: "direct"
+ })
+
+ assert [%{read: false}, %{read: false}, %{read: false}, %{read: false}] =
+ Participation.for_user(blocker)
+
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 4
+
+ {:ok, _user_relationship} = User.block(blocker, blocked)
+
+ # The conversations with the blocked user are marked as read
+ assert [%{read: true}, %{read: true}, %{read: true}, %{read: false}] =
+ Participation.for_user(blocker)
+
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 1
+
+ # The conversation is not marked as read for the blocked user
+ assert [_, _, %{read: false}] = Participation.for_user(blocked)
+ assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1
+
+ # The conversation is not marked as read for the third user
+ assert [%{read: false}, _, _] = Participation.for_user(third_user)
+ assert User.get_cached_by_id(third_user.id).unread_conversation_count == 1
+ end
+
+ test "the new conversation with the blocked user is not marked as unread " do
+ blocker = insert(:user)
+ blocked = insert(:user)
+ third_user = insert(:user)
+
+ {:ok, _user_relationship} = User.block(blocker, blocked)
+
+ # When the blocked user is the author
+ {:ok, _direct1} =
+ CommonAPI.post(blocked, %{
+ status: "Hi @#{blocker.nickname}",
+ visibility: "direct"
+ })
+
+ assert [%{read: true}] = Participation.for_user(blocker)
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0
+
+ # When the blocked user is a recipient
+ {:ok, _direct2} =
+ CommonAPI.post(third_user, %{
+ status: "Hi @#{blocker.nickname}, @#{blocked.nickname}",
+ visibility: "direct"
+ })
+
+ assert [%{read: true}, %{read: true}] = Participation.for_user(blocker)
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0
+
+ assert [%{read: false}, _] = Participation.for_user(blocked)
+ assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1
+ end
+
+ test "the conversation with the blocked user is not marked as unread on a reply" do
+ blocker = insert(:user)
+ blocked = insert(:user)
+ third_user = insert(:user)
+
+ {:ok, _direct1} =
+ CommonAPI.post(blocker, %{
+ status: "Hi @#{third_user.nickname}, @#{blocked.nickname}",
+ visibility: "direct"
+ })
+
+ {:ok, _user_relationship} = User.block(blocker, blocked)
+ assert [%{read: true}] = Participation.for_user(blocker)
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0
+
+ assert [blocked_participation] = Participation.for_user(blocked)
+
+ # When it's a reply from the blocked user
+ {:ok, _direct2} =
+ CommonAPI.post(blocked, %{
+ status: "reply",
+ visibility: "direct",
+ in_reply_to_conversation_id: blocked_participation.id
+ })
+
+ assert [%{read: true}] = Participation.for_user(blocker)
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0
+
+ assert [third_user_participation] = Participation.for_user(third_user)
+
+ # When it's a reply from the third user
+ {:ok, _direct3} =
+ CommonAPI.post(third_user, %{
+ status: "reply",
+ visibility: "direct",
+ in_reply_to_conversation_id: third_user_participation.id
+ })
+
+ assert [%{read: true}] = Participation.for_user(blocker)
+ assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0
+
+ # Marked as unread for the blocked user
+ assert [%{read: false}] = Participation.for_user(blocked)
+ assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1
+ end
+ end
end
diff --git a/test/conversation_test.exs b/test/conversation_test.exs
index 693427d80..359aa6840 100644
--- a/test/conversation_test.exs
+++ b/test/conversation_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ConversationTest do
@@ -11,16 +11,14 @@ defmodule Pleroma.ConversationTest do
import Pleroma.Factory
- clear_config_all([:instance, :federating]) do
- Pleroma.Config.put([:instance, :federating], true)
- end
+ setup_all do: clear_config([:instance, :federating], true)
test "it goes through old direct conversations" do
user = insert(:user)
other_user = insert(:user)
{:ok, _activity} =
- CommonAPI.post(user, %{"visibility" => "direct", "status" => "hey @#{other_user.nickname}"})
+ CommonAPI.post(user, %{visibility: "direct", status: "hey @#{other_user.nickname}"})
Pleroma.Tests.ObanHelpers.perform_all()
@@ -48,7 +46,7 @@ defmodule Pleroma.ConversationTest do
test "public posts don't create conversations" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Hey"})
object = Pleroma.Object.normalize(activity)
context = object.data["context"]
@@ -64,7 +62,7 @@ defmodule Pleroma.ConversationTest do
tridi = insert(:user)
{:ok, activity} =
- CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "direct"})
+ CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"})
object = Pleroma.Object.normalize(activity)
context = object.data["context"]
@@ -83,9 +81,9 @@ defmodule Pleroma.ConversationTest do
{:ok, activity} =
CommonAPI.post(jafnhar, %{
- "status" => "Hey @#{har.nickname}",
- "visibility" => "direct",
- "in_reply_to_status_id" => activity.id
+ status: "Hey @#{har.nickname}",
+ visibility: "direct",
+ in_reply_to_status_id: activity.id
})
object = Pleroma.Object.normalize(activity)
@@ -107,9 +105,9 @@ defmodule Pleroma.ConversationTest do
{:ok, activity} =
CommonAPI.post(tridi, %{
- "status" => "Hey @#{har.nickname}",
- "visibility" => "direct",
- "in_reply_to_status_id" => activity.id
+ status: "Hey @#{har.nickname}",
+ visibility: "direct",
+ in_reply_to_status_id: activity.id
})
object = Pleroma.Object.normalize(activity)
@@ -151,14 +149,14 @@ defmodule Pleroma.ConversationTest do
jafnhar = insert(:user, local: false)
{:ok, activity} =
- CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "direct"})
+ CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"})
{:ok, conversation} = Conversation.create_or_bump_for(activity)
assert length(conversation.participations) == 2
{:ok, activity} =
- CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "public"})
+ CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "public"})
assert {:error, _} = Conversation.create_or_bump_for(activity)
end
diff --git a/test/daemons/activity_expiration_daemon_test.exs b/test/daemons/activity_expiration_daemon_test.exs
deleted file mode 100644
index b51132fb0..000000000
--- a/test/daemons/activity_expiration_daemon_test.exs
+++ /dev/null
@@ -1,17 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.ActivityExpirationWorkerTest do
- use Pleroma.DataCase
- alias Pleroma.Activity
- import Pleroma.Factory
-
- test "deletes an activity" do
- activity = insert(:note_activity)
- expiration = insert(:expiration_in_the_past, %{activity_id: activity.id})
- Pleroma.Daemons.ActivityExpirationDaemon.perform(:execute, expiration.id)
-
- refute Repo.get(Activity, activity.id)
- end
-end
diff --git a/test/daemons/digest_email_daemon_test.exs b/test/daemons/digest_email_daemon_test.exs
deleted file mode 100644
index 3168f3b9a..000000000
--- a/test/daemons/digest_email_daemon_test.exs
+++ /dev/null
@@ -1,35 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.DigestEmailDaemonTest do
- use Pleroma.DataCase
- import Pleroma.Factory
-
- alias Pleroma.Daemons.DigestEmailDaemon
- alias Pleroma.Tests.ObanHelpers
- alias Pleroma.User
- alias Pleroma.Web.CommonAPI
-
- test "it sends digest emails" do
- user = insert(:user)
-
- date =
- Timex.now()
- |> Timex.shift(days: -10)
- |> Timex.to_naive_datetime()
-
- user2 = insert(:user, last_digest_emailed_at: date)
- User.switch_email_notifications(user2, "digest", true)
- CommonAPI.post(user, %{"status" => "hey @#{user2.nickname}!"})
-
- DigestEmailDaemon.perform()
- ObanHelpers.perform_all()
- # Performing job(s) enqueued at previous step
- ObanHelpers.perform_all()
-
- assert_received {:email, email}
- assert email.to == [{user2.name, user2.email}]
- assert email.subject == "Your digest from #{Pleroma.Config.get(:instance)[:name]}"
- end
-end
diff --git a/test/daemons/scheduled_activity_daemon_test.exs b/test/daemons/scheduled_activity_daemon_test.exs
deleted file mode 100644
index c8e464491..000000000
--- a/test/daemons/scheduled_activity_daemon_test.exs
+++ /dev/null
@@ -1,19 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.ScheduledActivityDaemonTest do
- use Pleroma.DataCase
- alias Pleroma.ScheduledActivity
- import Pleroma.Factory
-
- test "creates a status from the scheduled activity" do
- user = insert(:user)
- scheduled_activity = insert(:scheduled_activity, user: user, params: %{status: "hi"})
- Pleroma.Daemons.ScheduledActivityDaemon.perform(:execute, scheduled_activity.id)
-
- refute Repo.get(ScheduledActivity, scheduled_activity.id)
- activity = Repo.all(Pleroma.Activity) |> Enum.find(&(&1.actor == user.ap_id))
- assert Pleroma.Object.normalize(activity).data["content"] == "hi"
- end
-end
diff --git a/test/docs/generator_test.exs b/test/docs/generator_test.exs
new file mode 100644
index 000000000..9c9f4357b
--- /dev/null
+++ b/test/docs/generator_test.exs
@@ -0,0 +1,230 @@
+defmodule Pleroma.Docs.GeneratorTest do
+ use ExUnit.Case, async: true
+ alias Pleroma.Docs.Generator
+
+ @descriptions [
+ %{
+ group: :pleroma,
+ key: Pleroma.Upload,
+ type: :group,
+ description: "",
+ children: [
+ %{
+ key: :uploader,
+ type: :module,
+ description: "",
+ suggestions:
+ Generator.list_modules_in_dir(
+ "lib/pleroma/upload/filter",
+ "Elixir.Pleroma.Upload.Filter."
+ )
+ },
+ %{
+ key: :filters,
+ type: {:list, :module},
+ description: "",
+ suggestions:
+ Generator.list_modules_in_dir(
+ "lib/pleroma/web/activity_pub/mrf",
+ "Elixir.Pleroma.Web.ActivityPub.MRF."
+ )
+ },
+ %{
+ key: Pleroma.Upload,
+ type: :string,
+ description: "",
+ suggestions: [""]
+ },
+ %{
+ key: :some_key,
+ type: :keyword,
+ description: "",
+ suggestions: [],
+ children: [
+ %{
+ key: :another_key,
+ type: :integer,
+ description: "",
+ suggestions: [5]
+ },
+ %{
+ key: :another_key_with_label,
+ label: "Another label",
+ type: :integer,
+ description: "",
+ suggestions: [7]
+ }
+ ]
+ },
+ %{
+ key: :key1,
+ type: :atom,
+ description: "",
+ suggestions: [
+ :atom,
+ Pleroma.Upload,
+ {:tuple, "string", 8080},
+ [:atom, Pleroma.Upload, {:atom, Pleroma.Upload}]
+ ]
+ },
+ %{
+ key: Pleroma.Upload,
+ label: "Special Label",
+ type: :string,
+ description: "",
+ suggestions: [""]
+ },
+ %{
+ group: {:subgroup, Swoosh.Adapters.SMTP},
+ key: :auth,
+ type: :atom,
+ description: "`Swoosh.Adapters.SMTP` adapter specific setting",
+ suggestions: [:always, :never, :if_available]
+ },
+ %{
+ key: "application/xml",
+ type: {:list, :string},
+ suggestions: ["xml"]
+ },
+ %{
+ key: :versions,
+ type: {:list, :atom},
+ description: "List of TLS version to use",
+ suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2"]
+ }
+ ]
+ },
+ %{
+ group: :tesla,
+ key: :adapter,
+ type: :group,
+ description: ""
+ },
+ %{
+ group: :cors_plug,
+ type: :group,
+ children: [%{key: :key1, type: :string, suggestions: [""]}]
+ },
+ %{group: "Some string group", key: "Some string key", type: :group}
+ ]
+
+ describe "convert_to_strings/1" do
+ test "group, key, label" do
+ [desc1, desc2 | _] = Generator.convert_to_strings(@descriptions)
+
+ assert desc1[:group] == ":pleroma"
+ assert desc1[:key] == "Pleroma.Upload"
+ assert desc1[:label] == "Pleroma.Upload"
+
+ assert desc2[:group] == ":tesla"
+ assert desc2[:key] == ":adapter"
+ assert desc2[:label] == "Adapter"
+ end
+
+ test "group without key" do
+ descriptions = Generator.convert_to_strings(@descriptions)
+ desc = Enum.at(descriptions, 2)
+
+ assert desc[:group] == ":cors_plug"
+ refute desc[:key]
+ assert desc[:label] == "Cors plug"
+ end
+
+ test "children key, label, type" do
+ [%{children: [child1, child2, child3, child4 | _]} | _] =
+ Generator.convert_to_strings(@descriptions)
+
+ assert child1[:key] == ":uploader"
+ assert child1[:label] == "Uploader"
+ assert child1[:type] == :module
+
+ assert child2[:key] == ":filters"
+ assert child2[:label] == "Filters"
+ assert child2[:type] == {:list, :module}
+
+ assert child3[:key] == "Pleroma.Upload"
+ assert child3[:label] == "Pleroma.Upload"
+ assert child3[:type] == :string
+
+ assert child4[:key] == ":some_key"
+ assert child4[:label] == "Some key"
+ assert child4[:type] == :keyword
+ end
+
+ test "child with predefined label" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ child = Enum.at(children, 5)
+ assert child[:key] == "Pleroma.Upload"
+ assert child[:label] == "Special Label"
+ end
+
+ test "subchild" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ child = Enum.at(children, 3)
+ %{children: [subchild | _]} = child
+
+ assert subchild[:key] == ":another_key"
+ assert subchild[:label] == "Another key"
+ assert subchild[:type] == :integer
+ end
+
+ test "subchild with predefined label" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ child = Enum.at(children, 3)
+ %{children: subchildren} = child
+ subchild = Enum.at(subchildren, 1)
+
+ assert subchild[:key] == ":another_key_with_label"
+ assert subchild[:label] == "Another label"
+ end
+
+ test "module suggestions" do
+ [%{children: [%{suggestions: suggestions} | _]} | _] =
+ Generator.convert_to_strings(@descriptions)
+
+ Enum.each(suggestions, fn suggestion ->
+ assert String.starts_with?(suggestion, "Pleroma.")
+ end)
+ end
+
+ test "atoms in suggestions with leading `:`" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ %{suggestions: suggestions} = Enum.at(children, 4)
+ assert Enum.at(suggestions, 0) == ":atom"
+ assert Enum.at(suggestions, 1) == "Pleroma.Upload"
+ assert Enum.at(suggestions, 2) == {":tuple", "string", 8080}
+ assert Enum.at(suggestions, 3) == [":atom", "Pleroma.Upload", {":atom", "Pleroma.Upload"}]
+
+ %{suggestions: suggestions} = Enum.at(children, 6)
+ assert Enum.at(suggestions, 0) == ":always"
+ assert Enum.at(suggestions, 1) == ":never"
+ assert Enum.at(suggestions, 2) == ":if_available"
+ end
+
+ test "group, key as string in main desc" do
+ descriptions = Generator.convert_to_strings(@descriptions)
+ desc = Enum.at(descriptions, 3)
+ assert desc[:group] == "Some string group"
+ assert desc[:key] == "Some string key"
+ end
+
+ test "key as string subchild" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ child = Enum.at(children, 7)
+ assert child[:key] == "application/xml"
+ end
+
+ test "suggestion for tls versions" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+ child = Enum.at(children, 8)
+ assert child[:suggestions] == [":tlsv1", ":tlsv1.1", ":tlsv1.2"]
+ end
+
+ test "subgroup with module name" do
+ [%{children: children} | _] = Generator.convert_to_strings(@descriptions)
+
+ %{group: subgroup} = Enum.at(children, 6)
+ assert subgroup == {":subgroup", "Swoosh.Adapters.SMTP"}
+ end
+ end
+end
diff --git a/test/earmark_renderer_test.exs b/test/earmark_renderer_test.exs
new file mode 100644
index 000000000..220d97d16
--- /dev/null
+++ b/test/earmark_renderer_test.exs
@@ -0,0 +1,79 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.EarmarkRendererTest do
+ use ExUnit.Case
+
+ test "Paragraph" do
+ code = ~s[Hello\n\nWorld!]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<p>Hello</p><p>World!</p>"
+ end
+
+ test "raw HTML" do
+ code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<p>#{code}</p>"
+ end
+
+ test "rulers" do
+ code = ~s[before\n\n-----\n\nafter]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<p>before</p><hr /><p>after</p>"
+ end
+
+ test "headings" do
+ code = ~s[# h1\n## h2\n### h3\n]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<h1>h1</h1><h2>h2</h2><h3>h3</h3>]
+ end
+
+ test "blockquote" do
+ code = ~s[> whoms't are you quoting?]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<blockquote><p>whoms’t are you quoting?</p></blockquote>"
+ end
+
+ test "code" do
+ code = ~s[`mix`]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<p><code class="inline">mix</code></p>]
+
+ code = ~s[``mix``]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<p><code class="inline">mix</code></p>]
+
+ code = ~s[```\nputs "Hello World"\n```]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<pre><code class="">puts &quot;Hello World&quot;</code></pre>]
+ end
+
+ test "lists" do
+ code = ~s[- one\n- two\n- three\n- four]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>"
+
+ code = ~s[1. one\n2. two\n3. three\n4. four\n]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<ol><li>one</li><li>two</li><li>three</li><li>four</li></ol>"
+ end
+
+ test "delegated renderers" do
+ code = ~s[a<br/>b]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == "<p>#{code}</p>"
+
+ code = ~s[*aaaa~*]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<p><em>aaaa~</em></p>]
+
+ code = ~s[**aaaa~**]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<p><strong>aaaa~</strong></p>]
+
+ # strikethrought
+ code = ~s[<del>aaaa~</del>]
+ result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer})
+ assert result == ~s[<p><del>aaaa~</del></p>]
+ end
+end
diff --git a/test/emails/admin_email_test.exs b/test/emails/admin_email_test.exs
index ad89f9213..bc871a0a9 100644
--- a/test/emails/admin_email_test.exs
+++ b/test/emails/admin_email_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.AdminEmailTest do
@@ -19,8 +19,8 @@ defmodule Pleroma.Emails.AdminEmailTest do
AdminEmail.report(to_user, reporter, account, [%{name: "Test", id: "12"}], "Test comment")
status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, "12")
- reporter_url = Helpers.feed_url(Pleroma.Web.Endpoint, :feed_redirect, reporter.id)
- account_url = Helpers.feed_url(Pleroma.Web.Endpoint, :feed_redirect, account.id)
+ reporter_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, reporter.id)
+ account_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, account.id)
assert res.to == [{to_user.name, to_user.email}]
assert res.from == {config[:name], config[:notify_email]}
diff --git a/test/emails/mailer_test.exs b/test/emails/mailer_test.exs
index 2425c85dd..e6e34cba8 100644
--- a/test/emails/mailer_test.exs
+++ b/test/emails/mailer_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.MailerTest do
@@ -14,8 +14,7 @@ defmodule Pleroma.Emails.MailerTest do
subject: "Pleroma test email",
to: [{"Test User", "user1@example.com"}]
}
-
- clear_config([Pleroma.Emails.Mailer, :enabled])
+ setup do: clear_config([Pleroma.Emails.Mailer, :enabled])
test "not send email when mailer is disabled" do
Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false)
diff --git a/test/emails/user_email_test.exs b/test/emails/user_email_test.exs
index 963565f7c..a75623bb4 100644
--- a/test/emails/user_email_test.exs
+++ b/test/emails/user_email_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.UserEmailTest do
@@ -36,7 +36,7 @@ defmodule Pleroma.Emails.UserEmailTest do
test "build account confirmation email" do
config = Pleroma.Config.get(:instance)
- user = insert(:user, info: %Pleroma.User.Info{confirmation_token: "conf-token"})
+ user = insert(:user, confirmation_token: "conf-token")
email = UserEmail.account_confirmation_email(user)
assert email.from == {config[:name], config[:notify_email]}
assert email.to == [{user.name, user.email}]
diff --git a/test/emoji/formatter_test.exs b/test/emoji/formatter_test.exs
index 6d25fc453..12af6cd8b 100644
--- a/test/emoji/formatter_test.exs
+++ b/test/emoji/formatter_test.exs
@@ -1,9 +1,8 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emoji.FormatterTest do
- alias Pleroma.Emoji
alias Pleroma.Emoji.Formatter
use Pleroma.DataCase
@@ -12,7 +11,7 @@ defmodule Pleroma.Emoji.FormatterTest do
text = "I love :firefox:"
expected_result =
- "I love <img class=\"emoji\" alt=\"firefox\" title=\"firefox\" src=\"/emoji/Firefox.gif\" />"
+ "I love <img class=\"emoji\" alt=\"firefox\" title=\"firefox\" src=\"/emoji/Firefox.gif\"/>"
assert Formatter.emojify(text) == expected_result
end
@@ -28,37 +27,23 @@ defmodule Pleroma.Emoji.FormatterTest do
}
|> Pleroma.Emoji.build()
- expected_result =
- "I love <img class=\"emoji\" alt=\"\" title=\"\" src=\"https://placehold.it/1x1\" />"
-
- assert Formatter.emojify(text, [{custom_emoji.code, custom_emoji}]) == expected_result
+ refute Formatter.emojify(text, [{custom_emoji.code, custom_emoji}]) =~ text
end
end
- describe "get_emoji" do
+ describe "get_emoji_map" do
test "it returns the emoji used in the text" do
- text = "I love :firefox:"
-
- assert Formatter.get_emoji(text) == [
- {"firefox",
- %Emoji{
- code: "firefox",
- file: "/emoji/Firefox.gif",
- tags: ["Gif", "Fun"],
- safe_code: "firefox",
- safe_file: "/emoji/Firefox.gif"
- }}
- ]
+ assert Formatter.get_emoji_map("I love :firefox:") == %{
+ "firefox" => "http://localhost:4001/emoji/Firefox.gif"
+ }
end
test "it returns a nice empty result when no emojis are present" do
- text = "I love moominamma"
- assert Formatter.get_emoji(text) == []
+ assert Formatter.get_emoji_map("I love moominamma") == %{}
end
test "it doesn't die when text is absent" do
- text = nil
- assert Formatter.get_emoji(text) == []
+ assert Formatter.get_emoji_map(nil) == %{}
end
end
end
diff --git a/test/emoji/loader_test.exs b/test/emoji/loader_test.exs
index 045eef150..804039e7e 100644
--- a/test/emoji/loader_test.exs
+++ b/test/emoji/loader_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emoji.LoaderTest do
diff --git a/test/emoji_test.exs b/test/emoji_test.exs
index 1fdbd0fdf..b36047578 100644
--- a/test/emoji_test.exs
+++ b/test/emoji_test.exs
@@ -1,11 +1,19 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.EmojiTest do
use ExUnit.Case, async: true
alias Pleroma.Emoji
+ describe "is_unicode_emoji?/1" do
+ test "tells if a string is an unicode emoji" do
+ refute Emoji.is_unicode_emoji?("X")
+ assert Emoji.is_unicode_emoji?("☂")
+ assert Emoji.is_unicode_emoji?("🥺")
+ end
+ end
+
describe "get_all/0" do
setup do
emoji_list = Emoji.get_all()
diff --git a/test/federation/federation_test.exs b/test/federation/federation_test.exs
new file mode 100644
index 000000000..10d71fb88
--- /dev/null
+++ b/test/federation/federation_test.exs
@@ -0,0 +1,47 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Integration.FederationTest do
+ use Pleroma.DataCase
+ @moduletag :federated
+ import Pleroma.Cluster
+
+ setup_all do
+ Pleroma.Cluster.spawn_default_cluster()
+ :ok
+ end
+
+ @federated1 :"federated1@127.0.0.1"
+ describe "federated cluster primitives" do
+ test "within/2 captures local bindings and executes block on remote node" do
+ captured_binding = :captured
+
+ result =
+ within @federated1 do
+ user = Pleroma.Factory.insert(:user)
+ {captured_binding, node(), user}
+ end
+
+ assert {:captured, @federated1, user} = result
+ refute Pleroma.User.get_by_id(user.id)
+ assert user.id == within(@federated1, do: Pleroma.User.get_by_id(user.id)).id
+ end
+
+ test "runs webserver on customized port" do
+ {nickname, url, url_404} =
+ within @federated1 do
+ import Pleroma.Web.Router.Helpers
+ user = Pleroma.Factory.insert(:user)
+ user_url = account_url(Pleroma.Web.Endpoint, :show, user)
+ url_404 = account_url(Pleroma.Web.Endpoint, :show, "not-exists")
+
+ {user.nickname, user_url, url_404}
+ end
+
+ assert {:ok, {{_, 200, _}, _headers, body}} = :httpc.request(~c"#{url}")
+ assert %{"acct" => ^nickname} = Jason.decode!(body)
+ assert {:ok, {{_, 404, _}, _headers, _body}} = :httpc.request(~c"#{url_404}")
+ end
+ end
+end
diff --git a/test/filter_test.exs b/test/filter_test.exs
index b31c68efd..63a30c736 100644
--- a/test/filter_test.exs
+++ b/test/filter_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.FilterTest do
@@ -141,17 +141,15 @@ defmodule Pleroma.FilterTest do
context: ["home"]
}
- query_two = %Pleroma.Filter{
- user_id: user.id,
- filter_id: 1,
+ changes = %{
phrase: "who",
context: ["home", "timeline"]
}
{:ok, filter_one} = Pleroma.Filter.create(query_one)
- {:ok, filter_two} = Pleroma.Filter.update(query_two)
+ {:ok, filter_two} = Pleroma.Filter.update(filter_one, changes)
assert filter_one != filter_two
- assert filter_two.phrase == query_two.phrase
- assert filter_two.context == query_two.context
+ assert filter_two.phrase == changes.phrase
+ assert filter_two.context == changes.context
end
end
diff --git a/test/fixtures/config/temp.secret.exs b/test/fixtures/config/temp.secret.exs
new file mode 100644
index 000000000..dc950ca30
--- /dev/null
+++ b/test/fixtures/config/temp.secret.exs
@@ -0,0 +1,11 @@
+use Mix.Config
+
+config :pleroma, :first_setting, key: "value", key2: [Pleroma.Repo]
+
+config :pleroma, :second_setting, key: "value2", key2: ["Activity"]
+
+config :quack, level: :info
+
+config :pleroma, Pleroma.Repo, pool: Ecto.Adapters.SQL.Sandbox
+
+config :postgrex, :json_library, Poison
diff --git a/test/fixtures/emoji-reaction-no-emoji.json b/test/fixtures/emoji-reaction-no-emoji.json
new file mode 100644
index 000000000..ef3bbe55c
--- /dev/null
+++ b/test/fixtures/emoji-reaction-no-emoji.json
@@ -0,0 +1,30 @@
+{
+ "type": "EmojiReact",
+ "signature": {
+ "type": "RsaSignature2017",
+ "signatureValue": "fdxMfQSMwbC6wP6sh6neS/vM5879K67yQkHTbiT5Npr5wAac0y6+o3Ij+41tN3rL6wfuGTosSBTHOtta6R4GCOOhCaCSLMZKypnp1VltCzLDoyrZELnYQIC8gpUXVmIycZbREk22qWUe/w7DAFaKK4UscBlHDzeDVcA0K3Se5Sluqi9/Zh+ldAnEzj/rSEPDjrtvf5wGNf3fHxbKSRKFt90JvKK6hS+vxKUhlRFDf6/SMETw+EhwJSNW4d10yMUakqUWsFv4Acq5LW7l+HpYMvlYY1FZhNde1+uonnCyuQDyvzkff8zwtEJmAXC4RivO/VVLa17SmqheJZfI8oluVg==",
+ "creator": "http://mastodon.example.org/users/admin#main-key",
+ "created": "2018-02-17T18:57:49Z"
+ },
+ "object": "http://localtesting.pleroma.lol/objects/eb92579d-3417-42a8-8652-2492c2d4f454",
+ "content": "~",
+ "nickname": "lain",
+ "id": "http://mastodon.example.org/users/admin#reactions/2",
+ "actor": "http://mastodon.example.org/users/admin",
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "toot": "http://joinmastodon.org/ns#",
+ "sensitive": "as:sensitive",
+ "ostatus": "http://ostatus.org#",
+ "movedTo": "as:movedTo",
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "atomUri": "ostatus:atomUri",
+ "Hashtag": "as:Hashtag",
+ "Emoji": "toot:Emoji"
+ }
+ ]
+}
diff --git a/test/fixtures/emoji-reaction-too-long.json b/test/fixtures/emoji-reaction-too-long.json
new file mode 100644
index 000000000..e917c9a68
--- /dev/null
+++ b/test/fixtures/emoji-reaction-too-long.json
@@ -0,0 +1,30 @@
+{
+ "type": "EmojiReact",
+ "signature": {
+ "type": "RsaSignature2017",
+ "signatureValue": "fdxMfQSMwbC6wP6sh6neS/vM5879K67yQkHTbiT5Npr5wAac0y6+o3Ij+41tN3rL6wfuGTosSBTHOtta6R4GCOOhCaCSLMZKypnp1VltCzLDoyrZELnYQIC8gpUXVmIycZbREk22qWUe/w7DAFaKK4UscBlHDzeDVcA0K3Se5Sluqi9/Zh+ldAnEzj/rSEPDjrtvf5wGNf3fHxbKSRKFt90JvKK6hS+vxKUhlRFDf6/SMETw+EhwJSNW4d10yMUakqUWsFv4Acq5LW7l+HpYMvlYY1FZhNde1+uonnCyuQDyvzkff8zwtEJmAXC4RivO/VVLa17SmqheJZfI8oluVg==",
+ "creator": "http://mastodon.example.org/users/admin#main-key",
+ "created": "2018-02-17T18:57:49Z"
+ },
+ "object": "http://localtesting.pleroma.lol/objects/eb92579d-3417-42a8-8652-2492c2d4f454",
+ "content": "👌👌",
+ "nickname": "lain",
+ "id": "http://mastodon.example.org/users/admin#reactions/2",
+ "actor": "http://mastodon.example.org/users/admin",
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "toot": "http://joinmastodon.org/ns#",
+ "sensitive": "as:sensitive",
+ "ostatus": "http://ostatus.org#",
+ "movedTo": "as:movedTo",
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "atomUri": "ostatus:atomUri",
+ "Hashtag": "as:Hashtag",
+ "Emoji": "toot:Emoji"
+ }
+ ]
+}
diff --git a/test/fixtures/emoji-reaction.json b/test/fixtures/emoji-reaction.json
new file mode 100644
index 000000000..fe1fecddb
--- /dev/null
+++ b/test/fixtures/emoji-reaction.json
@@ -0,0 +1,30 @@
+{
+ "type": "EmojiReact",
+ "signature": {
+ "type": "RsaSignature2017",
+ "signatureValue": "fdxMfQSMwbC6wP6sh6neS/vM5879K67yQkHTbiT5Npr5wAac0y6+o3Ij+41tN3rL6wfuGTosSBTHOtta6R4GCOOhCaCSLMZKypnp1VltCzLDoyrZELnYQIC8gpUXVmIycZbREk22qWUe/w7DAFaKK4UscBlHDzeDVcA0K3Se5Sluqi9/Zh+ldAnEzj/rSEPDjrtvf5wGNf3fHxbKSRKFt90JvKK6hS+vxKUhlRFDf6/SMETw+EhwJSNW4d10yMUakqUWsFv4Acq5LW7l+HpYMvlYY1FZhNde1+uonnCyuQDyvzkff8zwtEJmAXC4RivO/VVLa17SmqheJZfI8oluVg==",
+ "creator": "http://mastodon.example.org/users/admin#main-key",
+ "created": "2018-02-17T18:57:49Z"
+ },
+ "object": "http://localtesting.pleroma.lol/objects/eb92579d-3417-42a8-8652-2492c2d4f454",
+ "content": "👌",
+ "nickname": "lain",
+ "id": "http://mastodon.example.org/users/admin#reactions/2",
+ "actor": "http://mastodon.example.org/users/admin",
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "toot": "http://joinmastodon.org/ns#",
+ "sensitive": "as:sensitive",
+ "ostatus": "http://ostatus.org#",
+ "movedTo": "as:movedTo",
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "atomUri": "ostatus:atomUri",
+ "Hashtag": "as:Hashtag",
+ "Emoji": "toot:Emoji"
+ }
+ ]
+}
diff --git a/test/fixtures/emoji/packs/blank.png.zip b/test/fixtures/emoji/packs/blank.png.zip
new file mode 100644
index 000000000..651daf127
--- /dev/null
+++ b/test/fixtures/emoji/packs/blank.png.zip
Binary files differ
diff --git a/test/fixtures/emoji/packs/default-manifest.json b/test/fixtures/emoji/packs/default-manifest.json
new file mode 100644
index 000000000..c8433808d
--- /dev/null
+++ b/test/fixtures/emoji/packs/default-manifest.json
@@ -0,0 +1,10 @@
+{
+ "finmoji": {
+ "license": "CC BY-NC-ND 4.0",
+ "homepage": "https://finland.fi/emoji/",
+ "description": "Finland is the first country in the world to publish its own set of country themed emojis. The Finland emoji collection contains 56 tongue-in-cheek emotions, which were created to explain some hard-to-describe Finnish emotions, Finnish words and customs.",
+ "src": "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip",
+ "src_sha256": "384025A1AC6314473863A11AC7AB38A12C01B851A3F82359B89B4D4211D3291D",
+ "files": "finmoji.json"
+ }
+} \ No newline at end of file
diff --git a/test/fixtures/emoji/packs/finmoji.json b/test/fixtures/emoji/packs/finmoji.json
new file mode 100644
index 000000000..279770998
--- /dev/null
+++ b/test/fixtures/emoji/packs/finmoji.json
@@ -0,0 +1,3 @@
+{
+ "blank": "blank.png"
+} \ No newline at end of file
diff --git a/test/fixtures/emoji/packs/manifest.json b/test/fixtures/emoji/packs/manifest.json
new file mode 100644
index 000000000..2d51a459b
--- /dev/null
+++ b/test/fixtures/emoji/packs/manifest.json
@@ -0,0 +1,10 @@
+{
+ "blobs.gg": {
+ "src_sha256": "3a12f3a181678d5b3584a62095411b0d60a335118135910d879920f8ade5a57f",
+ "src": "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip",
+ "license": "Apache 2.0",
+ "homepage": "https://blobs.gg",
+ "files": "blobs_gg.json",
+ "description": "Blob Emoji from blobs.gg repacked as apng"
+ }
+} \ No newline at end of file
diff --git a/test/fixtures/margaret-corbin-grave-west-point.html b/test/fixtures/margaret-corbin-grave-west-point.html
new file mode 100644
index 000000000..f6d387cc8
--- /dev/null
+++ b/test/fixtures/margaret-corbin-grave-west-point.html
@@ -0,0 +1,2895 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <link rel="preload" href="https://fonts.atlasobscura.com/2/Platform-Regular-Web.woff2" as="font" type="font/woff2"
+ crossorigin>
+ <link rel="preload" href="https://fonts.atlasobscura.com/2/Platform-Medium-Web.woff2" as="font" type="font/woff2"
+ crossorigin>
+ <link rel="preload" href="https://fonts.atlasobscura.com/2/FreigTexProBookWeb.woff2" as="font" type="font/woff2"
+ crossorigin>
+ <link rel="preload" href="https://fonts.atlasobscura.com/2/FreigTexProBookItWeb.woff2" as="font" type="font/woff2"
+ crossorigin>
+ <link rel="preload" href="https://fonts.atlasobscura.com/icons2/atlasobscura.woff2?3sjg72" as="font" type="font/woff2"
+ crossorigin>
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+ <link rel="dns-prefetch" href="https://assets.atlasobscura.com">
+ <link rel="dns-prefetch" href="//b.scorecardresearch.com">
+ <link rel="dns-prefetch" href="https://maps.google.com">
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta property="fb:app_id" content="206394544492" />
+ <meta property="fb:admins" content="784963490" />
+ <meta property="fb:admins" content="514970725" />
+ <meta property="fb:pages" content="103921782727" />
+ <meta property="og:site_name" content="Atlas Obscura" />
+ <meta name="p:domain_verify" content="0f207004875a5511f774fc29f0a5a3f3" />
+ <meta name="pocket-site-verification" content="8353ea6cd97e141f193687e3013ce3" />
+ <link rel='alternate' type='application/rss+xml' title='Atlas Obscura - Latest Articles and Places'
+ href='https://www.atlasobscura.com/feeds/latest'>
+ <link rel="apple-touch-icon"
+ href='https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvYXBwbGUtdG91Y2gtaWNvbi5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/apple-touch-icon.png'>
+ <link rel="apple-touch-icon-precomposed" sizes='144x144'
+ href='https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvYXBwbGUtdG91Y2gtaWNvbi0xNDR4MTQ0LXByZWNvbXBvc2VkLnBuZyJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/apple-touch-icon-144x144-precomposed.png'>
+ <link rel="apple-touch-icon-precomposed" sizes='114x114'
+ href='https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvYXBwbGUtdG91Y2gtaWNvbi0xMTR4MTE0LXByZWNvbXBvc2VkLnBuZyJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/apple-touch-icon-114x114-precomposed.png'>
+ <link rel="apple-touch-icon-precomposed"
+ href='https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvYXBwbGUtdG91Y2gtaWNvbi1wcmVjb21wb3NlZC5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/apple-touch-icon-precomposed.png'>
+ <link rel="icon" type="image/png" sizes="32x32"
+ href="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvZmF2aWNvbi0zMngzMi5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16"
+ href="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvZmF2aWNvbi0xNngxNi5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/favicon-16x16.png">
+ <link rel="manifest" href="https://s3.amazonaws.com/atlas-dev/misc/icons/manifest.json">
+ <link rel="mask-icon" href="https://s3.amazonaws.com/atlas-dev/misc/icons/safari-pinned-tab.svg" color="#53b19f">
+ <link rel="shortcut icon" href="https://s3.amazonaws.com/atlas-dev/misc/icons/favicon.ico" sizes="48x48">
+ <meta name="msapplication-config" content="https://s3.amazonaws.com/atlas-dev/misc/icons/browserconfig.xml">
+ <meta name="theme-color" content="#ffffff">
+ <link rel="canonical" href="https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point">
+ <title>The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura</title>
+ <meta property="description"
+ content="She&#39;s the only woman veteran honored with a monument at West Point. But where was she buried?" />
+ <style>
+ .async-hide {
+ opacity: 1 !important
+ }
+ </style>
+
+ <link rel="stylesheet" media="all"
+ href="https://assets.atlasobscura.com/assets/application-b89b3d9664b00a9207c1e551a84c8d61278379549ee4ff231dd01555e21ac4ac.css" />
+ <meta property="og:title" content="The Missing Grave of Margaret Corbin, Revolutionary War Veteran" />
+ <meta property="og:url" content="http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" />
+ <meta property="og:image"
+ content="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzkwYzgyMzI4LThlMDUtNGRiNS05MDg3LTUzMGUxZTM5N2RmMmVkOTM5ZDM4MGM4OTIxYTQ5MF9EQVIgZXhodW1hdGlvbiBvZiBNYXJnYXJldCBDb3JiaW4gZ3JhdmUgMTkyNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweD4iXV0/DAR%20exhumation%20of%20Margaret%20Corbin%20grave%201926.jpg" />
+ <meta property="og:description"
+ content="She&#39;s the only woman veteran honored with a monument at West Point. But where was she buried?" />
+ <meta property="og:type" content="article" />
+ <meta property="article:published_time" content="2020-01-14 11:15:00 -0500" />
+ <meta property="article:modified_time" content="2020-01-14 16:31:46 -0500" />
+ <meta property="article:author" content="Shane Cashman">
+ <meta property="article:tag" content="history" />
+ <meta property="article:tag" content="monuments" />
+ <meta property="article:tag" content="military history" />
+ <meta property="article:tag" content="cemeteries" />
+ <meta property="article:tag" content="graveyards" />
+ <meta name="twitter:card" content="summary_large_image">
+ <meta name="twitter:site" content="@atlasobscura">
+ <meta name="twitter:image"
+ content="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzkwYzgyMzI4LThlMDUtNGRiNS05MDg3LTUzMGUxZTM5N2RmMmVkOTM5ZDM4MGM4OTIxYTQ5MF9EQVIgZXhodW1hdGlvbiBvZiBNYXJnYXJldCBDb3JiaW4gZ3JhdmUgMTkyNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweD4iXV0/DAR%20exhumation%20of%20Margaret%20Corbin%20grave%201926.jpg">
+ <link rel="amphtml" href="https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point.amp" />
+</head>
+
+<body class="articles show ArticleTemplate --">
+ <div id="fb-root"></div>
+
+ <div class="hidden-xs hidden-sm">
+ <div class="ad-background gastro-top-ad">
+ <div class='ad-wrapper hidden-print top-of-site-wrapper' style='position: relative; z-index: 4999;'>
+ <div class="htl-ad" data-unit="Site_Top_Full_Width" data-ref="Site_Top_Full_Width_div"
+ data-sizes="0x0:|668x0:728x90,940x385,970x250" data-prebid="Site_Top_Full_Width" data-eager></div>
+ </div>
+ </div>
+ </div>
+ <header class="page-header">
+ <div class="container-fluid no-pad ">
+ <nav class="navbar-main">
+ <div class="nav-header">
+ <div class="row">
+ <div class="mobile-logo-link">
+ <a class="logo-link" title="Atlas Obscura" href="/">
+ <i class="icon icon-atlas-icon"></i></i><i class="icon icon-atlas-logo-alt"></i>
+ </a>
+ </div>
+ <div
+ class="hidden-xs hidden-sm hidden-print nav-tools-right with-notification-spacer hide-spacer js-notification-spaceable">
+ </div>
+ <div class="nav-search hidden-md hidden-lg hidden-print">
+ <a id="m-search-dropdown-link" class="js-launch-search-link nav-header-search-link-m non-decorated-link"
+ aria-label="Open Site Search Form" data-search-dock="search-dock-m" data-category="Search Suggest"
+ data-action="Opened Search Form" data-label="Global Search" href="javascript:void(0)">
+ <i class="icon-search"></i>
+ <i class="icon-menu-close"></i>
+ </a>
+ <div class="sitewide-hero-search vue-js-nav-search-bar mobile-search">
+ <div class="hero-search-bar-bg"></div>
+ <div class="container hero-search-wrapper js-hero-search-wrapper">
+ <div class="row">
+ <div class="col-md-10 col-md-offset-1 hero-search-bar">
+ <form autocomplete="off" class="js-search-form-to-submit js-hero-search" action="/search"
+ accept-charset="UTF-8" method="get"><input name="utf8" type="hidden" value="&#x2713;" />
+ <search-input></search-input>
+ </form>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-10 col-md-offset-1">
+ <search-suggestions></search-suggestions>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="nav-toggle-container"></div>
+ <a class="icon-menu-open nav-toggle js-nav-toggle visible-xs visible-sm" href="javascript:void(0)"
+ aria-label="Open menu">
+ <span class="notifications-badge"></span>
+ </a>
+ </div>
+ </div>
+ <div class="nav-content js-nav-content is-slider-hidden-m">
+ <div class="nav-verticals">
+ <div class="nav-left-section">
+ <a class="logo-link" title="Atlas Obscura" href="/">
+ <i class="icon icon-atlas-icon"></i></i><i class="icon icon-atlas-logo-alt"></i>
+ </a>
+ </div>
+ <ul class="nav-center-section">
+ <li class="nav-vertical js-taphover nav-events nav-content-vertical nav-trips">
+ <a class="heading-sm nav-link" href="/unusual-trips">
+ <div class="heading-sm nav-link-heading">Trips</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="fake-bg"></div>
+ <div class="row table-display">
+ <div class="col-md-3 left-panel">
+ <ul class="nav-links-column links-column nav-hoverable-links-column">
+ <li>
+ <a class="tab selected js-nav-rollover-tab" data-target="trip-upcoming-panel"
+ href="">Upcoming Trips<span class="icon-arrow-down nav-arrow-right"></span></a>
+ </li>
+
+ <li>
+ <a href="/unusual-trips/all">All Trips</a>
+ <a href="https://blog.atlasobscura.com">Trips Blog</a>
+ </li>
+ </ul>
+ <div id="nav-shop-callout-wrap" style="padding-top: 50%;">
+ <a class="shop-callout non-decorated-link" target="" href="/unique-gifts/atlas-obscura-book">
+ <figure class="shop-callout-image-wrap">
+ <img class="img-responsive"
+ src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvYXBwLWltYWdlcy9ib29rLzJuZC1lZGl0aW9uLW9uLXNpdGUtcHJvbW8ucG5nIl0sWyJwIiwidGh1bWIiLCIyNTB4PiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/2nd-edition-on-site-promo.png"
+ alt="2nd edition on site promo" />
+ </figure>
+ <div class="shop-callout-text">
+ <div class="detail-md">Get the Atlas Obscura book</div>
+ <div class="cta-xs shop-action-text">Shop Now »</div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div id="trip-upcoming-panel" class="col-md-9 right-panel">
+ <div class="row">
+ <div class="col-md-9">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Upcoming Trips</h4>
+ </div>
+ <div class="col-md-3">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/unusual-trips/all">View All Trips
+ »</a>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-3">
+ <a class="nav-card" href="/unusual-trips/peru-amazon">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDQvMTMvMTkvNDgvNDYvZTdkNDNkODctZGQwMy00MzJlLTg5NjMtMzhiNjRjY2IwNGMwL01hY2F3IHBlcnUyLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/Macaw%20peru2.jpg"
+ alt="" data-width="2466" data-height="1504" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Expedition Amazon</span>
+ </h3>
+ </a>
+ </div>
+ <div class="col-md-3">
+ <a class="nav-card" href="/unusual-trips/new-orleans">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXZlbnRfaW1hZ2VzLzc4ZjIyYzE3LWQ2Y2UtNGM5Yi1hY2U2LWQ4NTZiYzUzMGExNmU4NmQ3NjJiYWU2MWFmOTA5N19OT0xBX1Bvc3RlcjEuSlBHIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/NOLA_Poster1.JPG"
+ alt="" data-width="6000" data-height="4006" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Shifting Tides: Art in New Orleans</span>
+ </h3>
+ </a>
+ </div>
+ <div class="col-md-3">
+ <a class="nav-card" href="/unusual-trips/galicia-culinary">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXZlbnRfaW1hZ2VzLzQ4M2M1Zjk2LWVmYjYtNGNmZC1hMzBkLWE4NDNiZWJjOGYzYTM1ZTJmZTcxNTdjZWU0ZDliZV9EU0NfMjI4OCAoMSkuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/DSC_2288%20%281%29.jpg"
+ alt="" data-width="3008" data-height="2000" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Barnacles, Bluffs, and Brine: A Galician Seafood
+ Pilgrimage</span>
+ </h3>
+ </a>
+ </div>
+ <div class="col-md-3">
+ <a class="nav-card" href="/unusual-trips/borrego-springs-photography">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXZlbnRfaW1hZ2VzLzlhY2Q0Yjk5LTM4NjQtNGJiNy04NTZlLWVjOTdjZTUzZjgyZjViZGRkNWM5ZjNjZTRlZmEyNV9EZXNlcnQgQmVhc3RzIEtlaW1pZy02LmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/Desert%20Beasts%20Keimig-6.jpg"
+ alt="" data-width="1800" data-height="1202" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Dark Skies, Desert Beasts: Night Photography in Borrego
+ Springs, California</span>
+ </h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="nav-vertical js-taphover nav-events nav-content-vertical">
+ <a class="heading-sm nav-link" href="/experiences">
+ <div class="heading-sm nav-link-heading">Experiences</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="fake-bg"></div>
+ <div class="row table-display">
+ <div class="col-md-3 left-panel">
+ <h3 class="nav-panel-title">Quick Links</h3>
+ <ul class="nav-links-column links-column">
+ <li><a href="/experiences">All Experiences</a></li>
+ </ul>
+ <h3 class="nav-panel-title">Experiences by City</h3>
+ <ul class="nav-links-column links-column">
+ <li><a href="/things-to-do/chicago-illinois#upcoming-experiences">Chicago</a></li>
+ <li><a href="/things-to-do/denver-colorado#upcoming-experiences">Denver</a></li>
+ <li><a href="/things-to-do/los-angeles-california#upcoming-experiences">Los Angeles</a></li>
+ <li><a href="/things-to-do/new-york#upcoming-experiences">New York</a></li>
+ <li><a href="/things-to-do/philadelphia-pennsylvania#upcoming-experiences">Philadelphia</a>
+ </li>
+ <li><a href="/things-to-do/seattle-washington#upcoming-experiences">Seattle</a></li>
+ <li><a href="/things-to-do/washington-dc#upcoming-experiences">Washington, D.C.</a></li>
+ </ul>
+ </div>
+ <div class="col-md-9 right-panel">
+ <div class="row">
+ <div class="col-md-9">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Upcoming Experiences</h4>
+ </div>
+ <div class="col-md-3">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/experiences">View All Experiences
+ »</a>
+ </div>
+ </div>
+ <div class="row">
+ <a class="col-md-3 nav-card" href="/experiences/walking-the-hidden-wonders-of-midtown">
+ <div class="event-image-date-ko">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXhwZXJpZW5jZV9zZXJpZXNfaW1hZ2VzLzIwNC81ZjdhYmNkNzc0NTNmZDkyNzgwMS9jMzJkMjlhZi1iNWJlLTRjOGUtYmYxYy1jZTMxMWMzZTVhZjEuanBlZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/c32d29af-b5be-4c8e-bf1c-ce311c3e5af1.jpeg"
+ alt="" data-width="1066" data-height="1600" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <div class="detail-sm nav-card-location">New York</div>
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Walking the Hidden Wonders of Midtown</span>
+ </h3>
+ </a> <a class="col-md-3 nav-card" href="/experiences/visit-nycs-oldest-magic-shop-after-dark">
+ <div class="event-image-date-ko">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXhwZXJpZW5jZV9zZXJpZXNfaW1hZ2VzLzcyLzNkMTFlODQwZDc1MGIyMDFkMzBiL2YzMDA3NTI0LTE0ODUtNDE1Yy1iY2M3LWEzNzJlOTZjMDNhYy5qcGVnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/f3007524-1485-415c-bcc7-a372e96c03ac.jpeg"
+ alt="" data-width="1440" data-height="960" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <div class="detail-sm nav-card-location">New York</div>
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Visit NYC&#39;s Oldest Magic Shop After Dark</span>
+ </h3>
+ </a> <a class="col-md-3 nav-card" href="/experiences/atlas-obscuras-wonders-of-fidi">
+ <div class="event-image-date-ko">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXhwZXJpZW5jZV9zZXJpZXNfaW1hZ2VzLzIwNS80MDYwNTY3N2IyZjk2ZWQyNTQ2ZC82OTY4OGRmMy1hNWY4LTRkMmYtYjk0OC0zZjk0MmFiNTAzOTMuanBlZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/69688df3-a5f8-4d2f-b948-3f942ab50393.jpeg"
+ alt="" data-width="1074" data-height="1600" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <div class="detail-sm nav-card-location">New York</div>
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">Atlas Obscura&#39;s Wonders of FiDi</span>
+ </h3>
+ </a> <a class="col-md-3 nav-card" href="/experiences/a-hoppin-good-time-at-the-bunny-museum">
+ <div class="event-image-date-ko">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvZXhwZXJpZW5jZV9zZXJpZXNfaW1hZ2VzLzIwNy9kOWI4ODczZDUzOTAxODQ2YmVhYy8xYTMyZWI2NS04YTg2LTQzOGYtOGM2YS0yMDgxMjQxMjVlMjMuanBlZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/1a32eb65-8a86-438f-8c6a-208124125e23.jpeg"
+ alt="" data-width="1067" data-height="1600" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <div class="detail-sm nav-card-location">Altadena</div>
+ <h3 class="title-sm nav-card-title">
+ <span class="title-underline">A Hoppin&#39; Good Time at The Bunny Museum</span>
+ </h3>
+ </a> </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="nav-vertical js-taphover nav-atlas nav-content-vertical">
+ <a class="heading-sm nav-link" href="/articles/all-places-in-the-atlas-on-one-map">
+ <div class="heading-sm nav-link-heading">Places</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="fake-bg"></div>
+ <div class="row table-display">
+ <div class="col-md-3 left-panel">
+ <ul class="nav-links-column links-column nav-hoverable-links-column">
+ <li>
+ <a class="tab selected js-nav-rollover-tab" data-target="destinations-panel" href="">
+ Top Destinations<span class="icon-arrow-down nav-arrow-right"></span>
+ </a>
+ </li>
+ <li>
+ <a class="tab js-nav-rollover-tab" data-target="recent-places-panel" href="">
+ Newly Added Places<span class="icon-arrow-down nav-arrow-right"></span>
+ </a>
+ </li>
+ <li>
+ <a href="/places?sort=likes_count">
+ Most Popular Places
+ </a>
+ </li>
+ <li>
+ <a class="js-dropdown-random" href="/random">
+ Random Place
+ </a>
+ </li>
+ <li>
+ <a href="/lists">
+ Lists
+ </a>
+ </li>
+ <li>
+ <a href="/itineraries">
+ Itineraries
+ </a>
+ </li>
+ <li>
+ <hr>
+ <a class="add-place js-user-required" data-cause-key="p_add" href=/places/new> <i
+ class="icon-add-place"></i>
+ Add a Place
+ </a>
+ </li>
+ <li id="nav-dropdown-forum-link-wrap">
+ <hr>
+ <a class="js-social-action-tracked nav-dropdown-forum-link" data-service="Forum"
+ data-action="Discuss" data-position="Places Desktop Nav"
+ href="https://community.atlasobscura.com"><i class='material-icons'>forum</i> Visit Our
+ Forums</a>
+ </li>
+ </ul>
+ </div>
+ <div id="recent-places-panel" class="col-md-9 right-panel" style="display:none">
+ <div class="row">
+ <div class="col-md-8">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Newly Added Places</h4>
+ </div>
+ <div class="col-md-4">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/places">View All Places »</a>
+ </div>
+ </div>
+ <div class="row">
+ <a class="col-md-3 nav-card" href="/places/captain-america-statue-brooklyn">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzLzQzMWYyZDFkOTdlZTkyNjk0OF9JTUdfMjExMi5KUEciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMjIyeDE0OCMiXV0/IMG_2112.JPG"
+ alt="" data-width="3264" data-height="2448" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm nav-card-location">Brooklyn, New York</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Captain America
+ Statue</span></h3>
+ <div class="lat-lng nav-card-details" aria-hidden="true">
+ 40.6592, -74.0046
+ </div>
+ </a> <a class="col-md-3 nav-card" href="/places/plimoth-plantation">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzLzBiZmM5NzAxLTI4MWQtNGI1Ny04YzlmLWQ1ZTFjNjJmM2Y4MTY2ZDI5NDJlYTU2NDNkNjc4Yl8xMjgwcHgtUGxpbW90aF9QbGFudGF0aW9uX0ZlbmNlLmpwZWciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMjIyeDE0OCMiXV0/1280px-Plimoth_Plantation_Fence.jpeg"
+ alt="" data-width="1280" data-height="853" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm nav-card-location">Plymouth, Massachusetts</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Plimoth Plantation</span>
+ </h3>
+ <div class="lat-lng nav-card-details" aria-hidden="true">
+ 41.9382, -70.6254
+ </div>
+ </a> <a class="col-md-3 nav-card" href="/places/the-churchill-arms-pub">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzLzMyMGZmNWVjMWJmZTEzYzA1MF8xODgzOTg1NDU0M18yYWExMWM5NzM2X28uanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/18839854543_2aa11c9736_o.jpg"
+ alt="The pub&#39;s exterior is covered in hundreds of flowers." data-width="1440"
+ data-height="1920" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm nav-card-location">London, England</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">The Churchill Arms</span>
+ </h3>
+ <div class="lat-lng nav-card-details" aria-hidden="true">
+ 51.5069, -0.1948
+ </div>
+ </a> <a class="col-md-3 nav-card" href="/places/barney-ford-house-museum">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzLzFlNjRmNTk2LWQ2NDgtNGRkYy1iNDMzLWFkMDhiZDRkZTk4Y2NlMTFjNDY3M2FkMDczOTU1NF9CYXJuZXlGb3JkMDEuMDIuMjBEaW5pbmdSb29tLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/BarneyFord01.02.20DiningRoom.jpg"
+ alt="Barney Ford House Museum" data-width="4032" data-height="1960"
+ class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm nav-card-location">Breckenridge, Colorado</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Barney Ford House
+ Museum</span></h3>
+ <div class="lat-lng nav-card-details" aria-hidden="true">
+ 39.4806, -106.0455
+ </div>
+ </a> </div>
+ </div>
+ <div id="destinations-panel" class="col-md-9 right-panel">
+ <div class="row">
+ <div class="col-md-8">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Top Destinations</h4>
+ </div>
+ <div class="col-md-4">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/destinations">View All Destinations
+ »</a>
+ </div>
+ </div>
+ <div class="row">
+ <section class="section-top-content">
+ <div class="col-md-3 section-top-content-col">
+ <div class="places-section-top-content-header">
+ <h4 class="section-top-content-title detail-sm">Countries</h4>
+ </div>
+ <ul id="top-destinations-list-nav"
+ class="top-content-list links-column top-content-list-col-sm-2">
+ <li>
+ <a href='/things-to-do/australia' title='Australia'>Australia</a>
+ </li>
+ <li>
+ <a href='/things-to-do/canada' title='Canada'>Canada</a>
+ </li>
+ <li>
+ <a href='/things-to-do/china' title='China'>China</a>
+ </li>
+ <li>
+ <a href='/things-to-do/france' title='France'>France</a>
+ </li>
+ <li>
+ <a href='/things-to-do/germany' title='Germany'>Germany</a>
+ </li>
+ <li>
+ <a href='/things-to-do/india' title='India'>India</a>
+ </li>
+ <li>
+ <a href='/things-to-do/italy' title='Italy'>Italy</a>
+ </li>
+ <li>
+ <a href='/things-to-do/japan' title='Japan'>Japan</a>
+ </li>
+ </ul>
+ </div>
+ <div class="col-md-9 section-top-content-col">
+ <div class="places-section-top-content-header">
+ <h4 class="section-top-content-title detail-sm">Cities</h4>
+ </div>
+ <ul class="top-content-list links-column top-content-list-col-sm-2">
+ <li>
+ <a href='/things-to-do/amsterdam-netherlands' title='Amsterdam'>Amsterdam</a>
+ </li>
+ <li>
+ <a href='/things-to-do/barcelona-spain' title='Barcelona'>Barcelona</a>
+ </li>
+ <li>
+ <a href='/things-to-do/beijing-china' title='Beijing'>Beijing</a>
+ </li>
+ <li>
+ <a href='/things-to-do/berlin-germany' title='Berlin'>Berlin</a>
+ </li>
+ <li>
+ <a href='/things-to-do/boston-massachusetts' title='Boston'>Boston</a>
+ </li>
+ <li>
+ <a href='/things-to-do/budapest-hungary' title='Budapest'>Budapest</a>
+ </li>
+ <li>
+ <a href='/things-to-do/chicago-illinois' title='Chicago'>Chicago</a>
+ </li>
+ <li>
+ <a href='/things-to-do/london-england' title='London'>London</a>
+ </li>
+ <li>
+ <a href='/things-to-do/los-angeles-california' title='Los Angeles'>Los Angeles</a>
+ </li>
+ <li>
+ <a href='/things-to-do/mexico-city-mexico' title='Mexico City'>Mexico City</a>
+ </li>
+ <li>
+ <a href='/things-to-do/montreal-quebec' title='Montreal'>Montreal</a>
+ </li>
+ <li>
+ <a href='/things-to-do/moscow-russia' title='Moscow'>Moscow</a>
+ </li>
+ <li>
+ <a href='/things-to-do/new-orleans-louisiana' title='New Orleans'>New Orleans</a>
+ </li>
+ <li>
+ <a href='/things-to-do/new-york' title='New York City'>New York City</a>
+ </li>
+ <li>
+ <a href='/things-to-do/paris-france' title='Paris'>Paris</a>
+ </li>
+ <li>
+ <a href='/things-to-do/philadelphia-pennsylvania'
+ title='Philadelphia'>Philadelphia</a>
+ </li>
+ <li>
+ <a href='/things-to-do/rome-italy' title='Rome'>Rome</a>
+ </li>
+ <li>
+ <a href='/things-to-do/san-francisco-california' title='San Francisco'>San
+ Francisco</a>
+ </li>
+ <li>
+ <a href='/things-to-do/seattle-washington' title='Seattle'>Seattle</a>
+ </li>
+ <li>
+ <a href='/things-to-do/stockholm-sweden' title='Stockholm'>Stockholm</a>
+ </li>
+ <li>
+ <a href='/things-to-do/tokyo-japan' title='Tokyo'>Tokyo</a>
+ </li>
+ <li>
+ <a href='/things-to-do/toronto-ontario' title='Toronto'>Toronto</a>
+ </li>
+ <li>
+ <a href='/things-to-do/vienna-austria' title='Vienna'>Vienna</a>
+ </li>
+ <li>
+ <a href='/things-to-do/washington-dc' title='Washington, D.C.'>Washington, D.C.</a>
+ </li>
+ </ul>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="nav-vertical js-taphover nav-foods nav-content-vertical">
+ <a class="heading-sm nav-link" href="/gastro">
+ <div class="heading-sm nav-link-heading">Foods</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="row table-display">
+ <div class="nav-full-panel col-md-12">
+ <div class="row">
+ <div class="col-md-8">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Newly Added Food & Drink</h4>
+ </div>
+ <div class="col-md-4">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/foods">View All Food &amp; Drink »</a>
+ </div>
+ </div>
+ <div class="row">
+ <a class="nav-card" href="/foods/neua-tune-45-year-soup-wattana-panich">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvdGhpbmdfaW1hZ2VzLzBhYzExMzJhLTg2YjItNDNlOS1hNWFiLTQzYmNkYTNlZDQ4ZWJjNzg5ZDc2ZTNkODUxZTg2Zl9uZXVhdHVuZV9hbGV4eXFqLmpwZWciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMjIyeDE0OCMiXV0/neuatune_alexyqj.jpeg"
+ alt="" data-width="3024" data-height="4032" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm food-card-supertag">Prepared Foods</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Long-Simmering Soup at
+ Wattana Panich</span></h3>
+ </a> <a class="nav-card" href="/foods/chitlins-american-south">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvdGhpbmdfaW1hZ2VzLzFlMTJiZjYwYTM1N2VhZWQzMl9jaGl0bGluczEuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/chitlins1.jpg"
+ alt="A bowl of chitlins." data-width="1312" data-height="1312" class="lazy img-responsive"
+ nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm food-card-supertag">Prepared Foods</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Chitlins</span></h3>
+ </a> <a class="nav-card" href="/foods/hoppin-john">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvdGhpbmdfaW1hZ2VzLzQzYTA2ODM0LWNiYWItNGY5OC05YTNkLWM5M2ZlZTM5NGViZDg3ZDMzYjNlNmM0ZWM1NjUxMF9Ib3BwaW4gSm9obl9DQyBCeSAyLjAuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/Hoppin%20John_CC%20By%202.0.jpg"
+ alt="" data-width="4288" data-height="2848" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm food-card-supertag">Prepared Foods</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Hoppin&#39; John</span>
+ </h3>
+ </a> <a class="nav-card" href="/foods/twelve-grapes-new-years-eve">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvdGhpbmdfaW1hZ2VzL2VlZTYxNmVmYzU4NmU0MzZmNl9SYWnMiG1fZGVfQ2FwX2QnQW55LmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/Rai%CC%88m_de_Cap_d%27Any.jpg"
+ alt="Are you feeling lucky?" data-width="945" data-height="633"
+ class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm food-card-supertag">Ritual &amp; Medicinal</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Twelve Grapes</span></h3>
+ </a> <a class="nav-card" href="/foods/reveillon-dinner">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvdGhpbmdfaW1hZ2VzLzU3ODNhYzVmMzcxZjYwNTc1NV9SZXZlaWxsb24yX0RhdmlkUmljaG1vbmQuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/Reveillon2_DavidRichmond.jpg"
+ alt="A modern spread of Reveillon delights." data-width="1170" data-height="1035"
+ class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <div class="detail-sm food-card-supertag">Ritual &amp; Medicinal</div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Réveillon Dinner</span>
+ </h3>
+ </a> </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="nav-vertical js-taphover nav-articles nav-content-vertical">
+ <a class="heading-sm nav-link" href="/articles">
+ <div class="heading-sm nav-link-heading">Stories</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="row table-display">
+ <div class="nav-full-panel">
+ <div class="row">
+ <div class="col-md-8">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Most Recent Stories</h4>
+ </div>
+ <div class="col-md-4">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/articles">View All Stories »</a>
+ </div>
+ </div>
+ <div class="row">
+ <a class="col-md-3 nav-card" href="/articles/meet-diving-grannies-new-caledonia">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzQ4YmUzNTQ3LTU1NmUtNDAzZi1iMjI1LWRhNzA4Y2QzNTVmY2RhOTZlMDAwYzljN2I4OGE5NF9kaXZpbmcuZ3Jhbm5pZXMuY3JvcHBlZC50aW1lc3RhbXAubGVhZC5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMjIyeDE0OCMiXV0/diving.grannies.cropped.timestamp.lead.jpg"
+ alt="Two of the &quot;Fantastic Grandmothers&quot; document a greater sea snake, photo-identifying its uniquely patterned tail for their growing database."
+ data-width="1828" data-height="1223" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title"><span class="title-underline">The Snorkeling Grannies of
+ New Caledonia</span></h3>
+ <div class="detail-sm nav-card-details js-time-ago"
+ data-timestamp="2020-01-27 22:10:00 UTC"></div>
+ </a> <a class="col-md-3 nav-card" href="/articles/were-there-witchhunts-in-south-america">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzL2ZlYzA3N2Y5LTcxOWEtNDI1My04ODUwLTkxMDFiZmExYjgxNDlkNGJkM2ZiNDgzMzBhNzBlYl9BdGxhc19PYnNjdXJhX1dpdGNoZXNfTEFfMS5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMjIyeDE0OCMiXV0/Atlas_Obscura_Witches_LA_1.jpg"
+ alt="According to Inquisition records, men were often fearful that women would slip magic potions into their morning hot chocolate. "
+ data-width="1280" data-height="853" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title"><span class="title-underline">The Chocolate-Brewing
+ Witches of Colonial Latin America</span></h3>
+ <div class="detail-sm nav-card-details js-time-ago"
+ data-timestamp="2020-01-27 21:51:00 UTC"></div>
+ </a> <a class="col-md-3 nav-card" href="/articles/ww2-bombs-berlin">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzYzZjU4NTNjLWM5ODAtNDAyYi05YjRhLTQ1ZmVjODRhYzhmNzcyNTBiMjhlYzliNTNkNTRhNF9HZXR0eUltYWdlcy0xMTk1MTkzNzc5X2Nyb3AuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/GettyImages-1195193779_crop.jpg"
+ alt="German authorities with a 500-pound bomb discovered during construction work. Such discoveries are regular occurrences in cities that were bombed during World War II. "
+ data-width="1280" data-height="888" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title"><span class="title-underline">What Happens When They
+ Find a World War II Bomb Down the Street</span></h3>
+ <div class="detail-sm nav-card-details js-time-ago"
+ data-timestamp="2020-01-27 20:07:00 UTC"></div>
+ </a> <a class="col-md-3 nav-card" href="/articles/eritrean-tank-graveyard">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzL2I4YTI1Y2RkLWM5MjktNGRiZi05YTg3LTNmMzM0YTk1NDYwN2NiNTU2NmY4NDc3ZGI0YzMwNF8xNjAwcHgtUGFzc2luZ190aGVfdGFua19ncmF2ZXlhcmQuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjIyMngxNDgjIl1d/1600px-Passing_the_tank_graveyard.jpg"
+ alt="A cyclist rides past the tank graveyard in 2016." data-width="1600"
+ data-height="1060" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title"><span class="title-underline">This Tank Graveyard Is a
+ Monument to Eritrea&#39;s Struggle for Liberation</span></h3>
+ <div class="detail-sm nav-card-details js-time-ago"
+ data-timestamp="2020-01-27 20:05:00 UTC"></div>
+ </a> <a class="col-md-3 nav-card"
+ href="/articles/french-missionary-english-duke-bring-back-chinese-deer">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzFiM2EzOGE0LTM2OGQtNDRkZi1iNzVhLWYyNzAwM2I3MjcwNjhhMjg0MTAxMWMwMjZiZTFkYV9kZWVyLmZvcmVzdC5ncmF6aW5nLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIyMjJ4MTQ4IyJdXQ/deer.forest.grazing.jpg"
+ alt="Today nearly 7,000 Père David&#39;s deer roam the wetlands of China. "
+ data-width="800" data-height="533" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ <h3 class="title-sm nav-card-title"><span class="title-underline">The French Missionary and
+ the English Duke Who Saved a Chinese Deer From Extinction</span></h3>
+ <div class="detail-sm nav-card-details js-time-ago"
+ data-timestamp="2020-01-24 23:20:00 UTC"></div>
+ </a> </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="nav-vertical nav-videos nav-content-vertical js-taphover">
+ <a class="heading-sm nav-link" href="/videos">
+ <div class="heading-sm nav-link-heading">Videos</div>
+ </a>
+ <div class="nav-dropdown">
+ <div class="container nav-dropdown-content">
+ <div class="row table-display">
+ <div class="nav-full-panel">
+ <div class="row">
+ <div class="col-md-8">
+ <h4 class="nav-category-heading heading-md-non-uppercase">Most Recent Videos</h4>
+ </div>
+ <div class="col-md-4">
+ <a class="detail-sm nav-dropdown-viewall-link" href="/videos">View All Videos »</a>
+ </div>
+ </div>
+ <div class="row">
+ <a class="col-md-3 nav-card" href="/videos/welsh-town-all-books">
+ <div class="video-thumb-container">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMjAvMDEvMjIvMTUvMzkvMTIvNDhlYTcxNTItYWI1MC00NGVjLTgyN2YtYzVjY2E1NTY5NTMyL2hheV9vbl93eWVfc3RpbGxfMDEuanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjQ0MHgyNDgjIl1d"
+ alt="" data-width="1920" data-height="1080" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">This Tiny Welsh Town Is
+ Brimming With Books</span></h3>
+ <div class="detail-sm nav-card-details video-duration">5:04</div>
+ </a> <a class="col-md-3 nav-card"
+ href="/videos/see-cars-roll-uphill-on-scotland-s-electric-brae">
+ <div class="video-thumb-container">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMjAvMDEvMjIvMTUvNDIvMjYvOWVkZDBkNDYtZDhkOC00OWI0LWJmNWUtYTE2N2VkMjk2M2FmL2FvX2VsZWN0cmljX2JyYWVfZmJfMDExMzE5X3YxLjAwXzAyXzU0XzIxLnN0aWxsMDAxXzcyMC5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNDQweDI0OCMiXV0"
+ alt="" data-width="720" data-height="405" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">On Scotland&#39;s Electric
+ Brae, Cars Really Do Seem to Roll Uphill</span></h3>
+ <div class="detail-sm nav-card-details video-duration">4:54</div>
+ </a> <a class="col-md-3 nav-card"
+ href="/videos/dynasty-handbag-los-angeles-performance-artist">
+ <div class="video-thumb-container">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMjAvMDEvMDgvMjAvMTIvMzAvMTY2ZTA0ZWEtYjYwMy00MWI3LWEyM2QtNmY3NTY0YTY0ODZiL0R5bmFzdHkgSGFuZGJhZyBTdGlsbCAwMS5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNDQweDI0OCMiXV0"
+ alt="" data-width="1920" data-height="1080" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Meet Dynasty Handbag,
+ L.A.’s Queen of Weird</span></h3>
+ <div class="detail-sm nav-card-details video-duration">6:14</div>
+ </a> <a class="col-md-3 nav-card" href="/videos/the-unusual-italian-village-in-wales">
+ <div class="video-thumb-container">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMTIvMjAvMjAvMjEvMTEvMzc5ZWFlMzctZmI3My00Zjc5LTlkMjItMzMzZWNkZTc4YmNlL0FPIFBvcnRtZWlyaW9uIFN0aWxsIDAyLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCI0NDB4MjQ4IyJdXQ"
+ alt="" data-width="1920" data-height="1080" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Why There&#39;s an
+ &#39;Italian&#39; Village in Wales</span></h3>
+ <div class="detail-sm nav-card-details video-duration">5:27</div>
+ </a> <a class="col-md-3 nav-card" href="/videos/surfing-alaska-famous-bore-tide">
+ <div class="video-thumb-container">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMTIvMjAvMjAvMTMvNDAvZWExMzNhMTQtMDRmNy00ODZkLTliNDYtMDRlNmQ1M2JiMzU4L0FPIEJvcmUgVGlkZSBTdGlsbCAwMy5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNDQweDI0OCMiXV0"
+ alt="" data-width="3840" data-height="2160" class="lazy img-responsive" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </div>
+ <h3 class="title-sm nav-card-title"><span class="title-underline">Surfing Alaska&#39;s
+ Unique Bore Tide</span></h3>
+ <div class="detail-sm nav-card-details video-duration">4:14</div>
+ </a> </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="hidden-md hidden-lg" style="width: 100%;">
+
+ <div class="nav-vertical nav-vertical-sessions-m">
+ <div class="nav-session-links-wrap-m">
+
+ <a href="/sign-in" class="heading-sm nav-session-link-m nav-link">
+ <i class="icon-profile nav-session-icon"></i>Sign In
+ </a><i class="or-pipe or-pipe-sessions-m"></i>
+ <a href="/join" class="heading-sm nav-session-link-m nav-link">
+ Join
+ </a>
+ </div>
+ </div>
+ </li>
+ </ul>
+ <ul class="nav-right-section">
+ <li class="user-cell">
+ <div id="nav-profile-menu" class="dropdown">
+ <div class="nav-session-link detail-sm profile-dropdown js-profile-dropdown js-taphover" tabindex="0"
+ aria-label="User Profile Options" aria-haspopup="true">
+ <a href="javascript:void(0)" class="profile-nav-hover-target" aria-label="Account options">
+ <i class="icon user-icon icon-profile"></i>
+ </a>
+ <div class="profile-dropdown-menu js-profile-dropdown-menu dropdown-menu dropdown-menu-right"
+ tabindex="0">
+ <a id="nav-sign-in-link" class="nav-session-link detail-sm dropdown-item" href="/sign-in">Sign
+ In</a>
+ <hr>
+ <a id="nav-sign-up-link" class="nav-session-link detail-sm dropdown-item" href="/join">Join</a>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li class="search-cell">
+ <div class="nav-search">
+ <a id="d-search-dropdown-link" class="heading-sm nav-link js-launch-search-link"
+ aria-label="Open Site Search Form" data-search-dock="nav-dropdown-search-dock"
+ data-category="Search Suggest" data-action="Opened Search Form" data-label="Global Search"
+ href="javascript:void(0)">
+ <i class="icon-search"></i>
+ <i class="icon-menu-close"></i>
+ </a>
+ <div class="sitewide-hero-search vue-js-nav-search-bar desktop-search">
+ <div class="hero-search-bar-bg"></div>
+ <div class="container hero-search-wrapper js-hero-search-wrapper">
+ <div class="row">
+ <div class="col-md-10 col-md-offset-1 hero-search-bar">
+ <form autocomplete="off" class="js-search-form-to-submit js-hero-search" action="/search"
+ accept-charset="UTF-8" method="get"><input name="utf8" type="hidden" value="&#x2713;" />
+ <search-input></search-input>
+ </form>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-10 col-md-offset-1">
+ <search-suggestions></search-suggestions>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </nav>
+ <div id="search-dock-m" class="container-fluid search-dock"></div>
+ <div class="m-nearby-search">
+ <a class="js-search-nearby btn btn-places btn-icon js-link-tracked" data-category="Search Nearby"
+ data-action="Submitted" data-label="articles" href="javascript:void(0)">
+ <i class="icon-nearby-place"></i>
+ What's near me?
+ </a>
+ <a id="delta-nearby-wrap" href="https://ad.doubleclick.net/ddm/clk/416154737;217514481;g" target="_blank"
+ rel="noopener" class="hidden non-decorated-link">
+ <div class="cta-xs">Brought to you by</div>
+ <div style="width: 130px; margin-left: 15px;">
+ <img class="img-responsive"
+ src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2Mvc3BvbnNvci1vbmUtb2Zmcy9kZWx0YV9sb2dvLnBuZyJdLFsicCIsInRodW1iIiwiMjYweD4iXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/delta_logo.png"
+ alt="Delta logo" />
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="oop-tracking-pixel">
+ <div class="htl-ad" data-unit="1x1_tracking_pixel" data-ref="1x1_tracking_pixel_div" data-sizes="1x1"
+ data-prebid="1x1_tracking_pixel" data-eager></div>
+ </div>
+ </header>
+ <div id="page-content">
+ <article class="athanasius item-content js-article-content article-content article--content ">
+ <div class="ArticleHeaderBg ArticleHeaderBg--dark ArticleHeaderBg--wide-hero ArticleHeaderBg-- ">
+ <header class="ArticleHeader js-item-header ArticleHeader--wide-hero ">
+ <div class="container">
+ <h1 class="ArticleHeader__title">The Missing Grave of Margaret Corbin, Revolutionary War Veteran</h1>
+ <h2 class="ArticleHeader__subtitle">
+ She&#8217;s the only woman veteran honored with a monument at West Point. But where was she buried?
+ </h2>
+ <div class="ArticleHeader__end-matter">
+ <div class="ArticleHeader__byline-dateline">
+ <span class="ArticleHeader__byline">by <a href="/users/shanecashman?view=articles">Shane
+ Cashman</a></span>
+ <span class="ArticleHeader__pub-date">January 14, 2020</span>
+ </div>
+ <div class="ArticleHeader__social hidden-sm hidden-xs">
+ <div class="SocialLinks ">
+ <a class="js-share-button-facebook js-social-action-tracked" data-position="Header"
+ data-service="Facebook" data-action="Share" aria-label="Share on Facebook" href="">
+ <i class="icon icon-facebook"></i>
+ </a>
+ <a target="_blank" class="js-social-action-tracked js-social-ask-for-follow" data-position="Header"
+ data-service="Twitter" data-action="Share" aria-label="Share on Twitter"
+ href="https://twitter.com/share?text=The%20Missing%20Grave%20of%20Margaret%20Corbin%2C%20Revolutionary%20War%20Veteran%20@atlasobscura&amp;count=none&amp;url=https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point">
+ <i class="icon icon-twitter"></i>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </header>
+ </div>
+ <div id="btf-nav" class="container-fluid hidden athanasius">
+ <div class="row">
+ <div class="mini-nav-wrapper Subnav--with-depth">
+ <div class="container-fluid">
+ <nav>
+ <div class="mini-nav-contents">
+ <div id="btf-nav-home" class="btf-nav-square hidden-sm hidden-xs">
+ <a class="mini-nav-logo-link non-decorated-link" title="Atlas Obscura" href="/"><i
+ class="icon icon-atlas-icon"></i></a>
+ </div>
+ <div id="btf-nav-title" class="detail hidden-sm hidden-xs">
+ The Missing Grave of Margaret Corbin, Revolutionary War Veteran
+ </div>
+ <div class="social-links">
+ <div class="btf-nav-square first-in-series">
+ <a href="javascript:void(0)"
+ class="icon icon-facebook link-facebook social-link js-social-action-tracked js-share-button-facebook"
+ data-position="Sticky Header" data-service="Facebook" data-action="Share" target="_blank"
+ aria-label="Share on Facebook"></a>
+ </div>
+ <div class="btf-nav-square">
+ <a href="https://twitter.com/share?text=The%20Missing%20Grave%20of%20Margaret%20Corbin%2C%20Revolutionary%20War%20Veteran%20@atlasobscura&amp;count=none&amp;url=https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point"
+ class="icon icon-twitter social-link link-twitter js-social-action-tracked js-social-ask-for-follow"
+ target="_blank" data-position="Sticky Header" data-service="Twitter" data-action="Share"
+ aria-label="Share on Twitter"></a>
+ </div>
+ <div class="btf-nav-square">
+ <a href="https://www.reddit.com/submit?url=https%3A%2F%2Fwww.atlasobscura.com%2Farticles%2Fmargaret-corbin-grave-west-point"
+ class="icon icon-reddit link-reddit social-link js-social-action-tracked"
+ data-position="Sticky Header" data-service="reddit" data-action="Share" target="_blank"
+ aria-label="Share on Reddit"></a>
+ </div>
+ <div class="btf-nav-square">
+ <a href="mailto:?subject=The Missing Grave of Margaret Corbin, Revolutionary War Veteran&body=Discovered on Atlas Obscura: https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point"
+ class="icon icon-envelope link-email social-link js-social-action-tracked js-btn-email-share hidden-md hidden-lg"
+ data-position="Sticky Header" data-service="Email" data-action="Send"
+ aria-label="Email this"></a>
+ </div>
+ </div>
+ </div>
+ </nav>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="article-container" class="container">
+ <div class="row">
+ <div id="article-hero" class="hero-img-wide col-xs-12 col-md-8 hidden-print ArticleHero ">
+ <img
+ alt="In 1926, the Daughters of the American Revolution joined forces with the U.S. Military Academy to exhume the supposed burial site of Margaret Corbin."
+ data-width="2304" data-height="1583" width="1280" class="article-image"
+ src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzkwYzgyMzI4LThlMDUtNGRiNS05MDg3LTUzMGUxZTM5N2RmMmVkOTM5ZDM4MGM4OTIxYTQ5MF9EQVIgZXhodW1hdGlvbiBvZiBNYXJnYXJldCBDb3JiaW4gZ3JhdmUgMTkyNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMTI4MHg-Il1d/DAR%20exhumation%20of%20Margaret%20Corbin%20grave%201926.jpg" />
+ <div class="article-caption-pre-rd article-hero-img-caption">
+ In 1926, the Daughters of the American Revolution joined forces with the U.S. Military Academy to exhume
+ the supposed burial site of Margaret Corbin. <span class='caption-credit'>Daughters of the American
+ Revolution</span>
+ </div>
+ </div>
+ </div>
+ <div class="row MainContentRow -- ">
+ <div id="ArticleLeftRail" class="article-left-siderail social-siderail-l hidden-print col-md-2">
+ <div class="siderail-placement siderail-associated-content-cards">
+ <div class="siderail-title o-heading fs--19">In This Story</div>
+ <div class="siderail-cards-container">
+ <div class="CardWrapper --PlaceWrapper js-CardWrapper --small-wrapper">
+ <div class="CardActionBtns">
+ <div class="CardActionBtns__positioner">
+ <div data-item-type="Place" data-place-title="Margaret Corbin&#39;s Grave"
+ data-place-city="West Point" data-place-country="United States" data-place-id="35141"
+ class="Card__action-btns vue-js-been-there-everywhere-place">
+ <been-there-everywhere></been-there-everywhere>
+ </div>
+ </div>
+ </div>
+ <a class="Card --content-card-v2 --content-card-item Card--small" data-type="Place"
+ data-item-id="35141" data-gtm-content-type="Place" data-lat="41.398684" data-lng="-73.967402"
+ data-city="West Point" data-state="New York" data-country="United States"
+ href="/places/margaret-corbin-grave">
+ <figure class="Card__figure js-Card__figure --content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzL2RkZmJiNGI4LTZhYTktNDNiOC05ZTIxLWViY2NhMzdjYzE0ZDMwMmVmYjI2MGQ4MzllNzU4MF9NYXJnYXJldCBDb3JiaW4gUmVkZWRpY2F0aW9uIENlcmVtb255XzUuMS4xOC5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweDQwMCMiXV0/Margaret%20Corbin%20Rededication%20Ceremony_5.1.18.jpg"
+ alt="" data-width="960" data-height="640"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat --place">Place</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>Margaret Corbin&#39;s Grave</span>
+ </h3>
+ <div class="Card__content js-subtitle-content">
+ West Point’s only monument to a woman veteran stands above an empty grave.
+ </div>
+ </div>
+ </a>
+ </div>
+ <div class="CardWrapper --GeoWrapper js-CardWrapper --small-wrapper">
+ <a class="Card --content-card-v2 --content-card-item Card--small Card--flipped"
+ data-gtm-content-type="Geo" data-lat="40.712784" data-lng="-74.005941" data-state="New York"
+ data-country="United States" data-global-region="North America" href="/things-to-do/new-york-state">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvcGxhY2VfaW1hZ2VzLzNiY2JkODcwMjMxYjNmNzM1NV81MTU5MTkxMjkwXzk4MDRlYTQyYjJfby5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweDQwMCMiXV0/5159191290_9804ea42b2_o.jpg"
+ alt="" data-width="1011" data-height="671"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat"> Destination Guide</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>New York State</span>
+ </h3>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="content-body col-md-6">
+ <section id="article-body" class="ArticleBody ArticleBody__default article--body ">
+ <p class="item-body-text-graf"><span class="section-start-text">In 2016, five days after
+ </span>Thanksgiving, Margaret Corbin&#8217;s grave was dug up for the second time since her death in
+ 1800. It began by accident. Contractors were working on a retaining wall near the West Point Cemetery,
+ at the U.S. Military Academy, when a hydraulic excavator got too close and chewed through the grave.</p>
+ <p class="item-body-text-graf">As soon as they noticed bones spilling from the soil, they alerted the
+ military police. The plot was quickly cordoned off, her monument was wrapped in tarp, and rumors started
+ to spread about Corbin&#8217;s resting place&#8212;that is, if it even <em>was</em> her resting place.
+ When forensic archaeologists arrived at the scene, they were perplexed: The bones seemed oddly large.
+ </p>
+ <p class="item-body-text-graf">The monument to Margaret Corbin is West Point&#8217;s only monument to a
+ woman veteran, and it greets visitors near the main gate, just feet from a neoclassical chapel. It faces
+ Washington Road, where the Academy&#8217;s top brass live, and depicts Corbin in a long dress, operating
+ a cannon as her long hair and cape fly in the wind. She wears a powder horn and holds a rammer to load
+ cannonballs; the rest of the rather cramped cemetery sprawls out behind her. The monument portrays the
+ moments before Corbin became a prisoner of war.</p>
+ <figure class=" contains-caption "><img class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="On the West Point monument, Corbin wears a long dress and a powder horn, and she operates a cannon while her long hair flies in the wind."
+ width="auto" data-kind="article-image" id="article-image-71546"
+ data-src="https://assets.atlasobscura.com/article_images/71546/image.jpg">
+ <figcaption class="caption structured-caption noskim">On the West Point monument, Corbin wears a long
+ dress and a powder horn, and she operates a cannon while her long hair flies in the wind. <span
+ class="caption-credit">Science History Images / Alamy Stock Photo</span></figcaption>
+ </figure>
+ <p class="item-body-text-graf">The story goes that Corbin joined her husband, John, to fight in the
+ American Revolution. At the time, many women followed their husbands to war, where they were commonly
+ known as &#8220;camp followers.&#8221; Typically, they foraged for food, cooked, and did laundry. Before
+ Martha Washington was the United States&#8217; first first lady, she was also a camp follower. In fact,
+ she and Margaret were with the same company&#8212;though the two experienced different lives, since
+ George was a general, and John manned a cannon.</p>
+ <p class="item-body-text-graf">At the Battle of Fort Washington, on November 16, 1776, in what is now
+ Washington Heights, the British and Hessians advanced far enough to make the Continental Army&#8217;s
+ position untenable. George Washington retreated with his forces to White Plains; John Corbin was shot
+ dead at his cannon. But Margaret was there to jump into John&#8217;s position and help fire the cannon.
+ During the battle, her jaw and shoulder were seriously injured, and grapeshot tore off part of her
+ breast. Despite the Continental Army&#8217;s efforts, the fort was soon surrendered, and Corbin was
+ captured along with approximately 2,837 soldiers.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="A watercolor by Thomas Davies depicting the attack on Fort Washington by the British and Hessian Brigades. Margaret Corbin was taken prisoner after fighting in the battle."
+ width="auto" data-kind="article-image" id="article-image-71538"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71538/image.jpg">
+ <figcaption class="caption structured-caption noskim">A watercolor by Thomas Davies depicting the attack
+ on Fort Washington by the British and Hessian Brigades. Margaret Corbin was taken prisoner after
+ fighting in the battle. <span class="caption-credit">Alamy Stock Photo</span></figcaption>
+ </figure>
+ <p class="item-body-text-graf">The British may have been unsure what to do with an injured woman, because
+ she was released fairly soon after the battle. The ordeal was one of many traumas in her life: According
+ to records collected by the <a
+ href="http://www.historichudsonhighlands.org/historian-s-office.html">historian</a> Stella Bailey,
+ Margaret was only five years old when her father was killed in a conflict with Native Americans in
+ Pennsylvania, where they lived. Her mother was kidnapped, and Margaret and her brother moved in with an
+ uncle. They never saw her again.</p>
+ <p class="item-body-text-graf">After Corbin&#8217;s return, she joined the Corps of Invalids, a group of
+ wounded soldiers that were still able to contribute to the war effort. They were stationed at West
+ Point, New York, where Corbin became known as a cantankerous woman who had a tough time making a home
+ for herself in the neighboring village of Highland Falls. She moved between various local families who
+ tried to care for her. Having witnessed her husband&#8217;s death and sustaining wounds, she was
+ probably in constant mental and physical pain.</p>
+ <hr class="baseline-grid-hr">
+ <p class="item-body-text-graf section-break-graf"><span class="section-start-text">When Margaret Corbin
+ died in </span>1800, she was buried in a pauper&#8217;s cemetery in Highland Falls, just three miles
+ from West Point. But in 1926, the national society of women known as the Daughters of the American
+ Revolution saw to it that Corbin would earn her vaunted cemetery plot. The society, which is made up of
+ women who can trace their lineage to participants in the American Revolution, was celebrating the
+ sesquicentennial of American independence, and saw Corbin as the consummate symbol of both their
+ organization and the Revolution. A year-long effort convinced the U.S. Military Academy to help them
+ exhume and transport the remains to the prestigious cemetery, to be reburied with a military funeral.
+ </p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="This sign directs visitors to the United States Military Academy to the purported site of Margaret Corbin's grave."
+ width="auto" data-kind="article-image" id="article-image-71506"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71506/image.jpg">
+ <figcaption class="caption structured-caption noskim">This sign directs visitors to the United States
+ Military Academy to the purported site of Margaret Corbin&#8217;s grave. <a class="caption-credit"
+ rel="nofollow" target="_blank"
+ href="https://commons.wikimedia.org/wiki/File:Margaret_Corbin_Historical_Road_Marker.JPG">Ahodges7 /
+ CC BY-SA 3.0</a></figcaption>
+ </figure>
+ <p class="item-body-text-graf">Exhumation was not a simple task: By the time the DAR began their campaign
+ to move Corbin, the location of her exact burial was known only by word-of-mouth, passed down through
+ generations. In collaboration with West Point, the Daughters found the great-grandson of the man who
+ supposedly dug Corbin&#8217;s original grave, a steamboat captain by the name of Farout. Her burial site
+ was apparently marked by the stump of a cedar tree; during the exhumation process, the gravedigger
+ accidentally drove the shovel through the skull. Still, the Army Surgeon reported injuries to the
+ skeleton that were consistent with grapeshot. The remains were given a new, flag-draped casket and
+ delivered to West Point by horse-drawn hearse.</p>
+ <p class="item-body-text-graf">Every year since then, the Daughters have gathered at Corbin&#8217;s
+ monument for Margaret Corbin Day. On the first Tuesday of May, the Daughters fill the chapel, share
+ Corbin&#8217;s story, sing hymns, and stand at the grave while soldiers perform a 21-gun salute.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="A horse-drawn hearse carried a flag-draped casket that was said to contain Corbin&#8217;s remains."
+ width="auto" data-kind="article-image" id="article-image-71497"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71497/image.jpg">
+ <figcaption class="caption structured-caption noskim">A horse-drawn hearse carried a flag-draped casket
+ that was said to contain Corbin&#8217;s remains. <span class="caption-credit">Daughters of the
+ American Revolution</span></figcaption>
+ </figure>
+ <p class="item-body-text-graf">After the U.S. Military Academy unintentionally reopened the grave beneath
+ Corbin&#8217;s monument in 2016, they decided to conduct an emergency forensic archaeological
+ excavation. They enlisted the help of Elizabeth A. DiGangi, an anthropology professor at Binghamton
+ University, and Michael K. Trimble, an archaeologist for the Army Corps of Engineers. Almost
+ immediately, the pair noticed that the size of the bones didn&#8217;t match Corbin&#8217;s description.
+ Corbin was reportedly a stout woman. &#8220;One of the first bones I saw when I was on site was the
+ humerus, or upper arm bone,&#8221; DiGangi says. &#8220;It was very large, which is not what you would
+ expect with an arm bone from a woman.&#8221;</p>
+ <p class="item-body-text-graf">DiGangi took the remains to her laboratory at Binghamton University to do a
+ full analysis. Some worried that other remains were mixed up with Corbin&#8217;s. (In the past, West
+ Point has discovered unknown remains when they&#8217;ve broken new ground for construction.) Ultimately,
+ DiGangi&#8217;s analysis revealed something even more shocking: The remains in Corbin&#8217;s grave
+ actually came from an adult male. DiGangi determined that it was a large man, who could&#8217;ve been
+ anywhere from five-foot-seven to six and a half feet tall. The remains of Margaret Corbin were not in
+ Margaret Corbin&#8217;s grave.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="In 1926, the remains from Highland Park were reinterred at West Point, and sat at the foot of the Margaret Corbin monument until 2016."
+ width="auto" data-kind="article-image" id="article-image-71024"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71024/image.jpg">
+ <figcaption class="caption structured-caption noskim">In 1926, the remains from Highland Park were
+ reinterred at West Point, and sat at the foot of the Margaret Corbin monument until 2016. <span
+ class="caption-credit">Daughters of the American Revolution</span></figcaption>
+ </figure>
+ <p class="item-body-text-graf">Once the archaeological excavation teams <a
+ href="https://www.dar.org/sites/default/files/FinalReportWestPointBurialRecovery.pdf">completed</a>
+ their reports, the Army National Cemeteries contacted the Daughters of the American Revolution. They
+ wanted a meeting at DAR headquarters in Washington, DC.</p>
+ <p class="item-body-text-graf">Jennifer Minus, the head of the New York chapter of the DAR, was among
+ those present at the meeting. Minus, a graduate of West Point and a former member of the Corbin Forum, a
+ club for cadet women, knew her Corbin history better than most. She asked how it could&#8217;ve been a
+ man in the grave, if in 1926 the Army surgeon said that grapeshot injuries were present. In her report,
+ DiGangi explains that what the surgeon considered a grapeshot injury was, in fact, post-mortem damage to
+ the remains.</p>
+ <p class="item-body-text-graf">So where is Margaret Corbin? Since the attempted reburial of Corbin&#8217;s
+ remains, in 1926, her original gravesite in Highland Falls has been lost to time. Sometime in the 1970s,
+ the town dropped a sewage plant where many believe it was once located. Yet Minus remains optimistic
+ that Corbin&#8217;s remains will one day be found.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="At left, another monument that pays homage to Margaret Corbin, near the site where she took over her husband's cannon; at right, a view of her monument at West Point."
+ width="auto" data-kind="article-image" id="article-image-71513"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71513/image.jpg">
+ <figcaption class="caption structured-caption noskim">At left, another monument that pays homage to
+ Margaret Corbin, near the site where she took over her husband&#8217;s cannon; at right, a view of her
+ monument at West Point. <a class="caption-credit" rel="nofollow" target="_blank"
+ href="https://commons.wikimedia.org/wiki/Category:Margaret_Corbin#/media/File:2015_Fort_Tryon_Park_Margaret_Corbin_memorial.">Beyond
+ My Ken / CC BY-SA 4.0; Ahodges7 / CC BY-SA 3.0</a></figcaption>
+ </figure>
+ <p class="item-body-text-graf">As upsetting as it was to learn that her remains were missing, the
+ Daughters also tried to see the discovery as an opportunity to spread Margaret&#8217;s story. It&#8217;s
+ as though they picked up right where the 1926 DAR members left off. Minus formed an unofficial Margaret
+ Corbin Task Force, drawing on the strengths of DAR members: One was a genealogist, and another was a
+ Navy veteran who had worked on locating the remains of American soldiers overseas.</p>
+ <hr class="baseline-grid-hr">
+ <p class="item-body-text-graf section-break-graf"><span class="section-start-text">On April 30, 2019,
+ Minus </span>combed the woods of Highland Falls, looking for the original gravesite. They tried to
+ match up the old photographs with newer ones, but this proved difficult, because most of the trees in
+ the photographs were saplings at the time. They looked for flat areas that would have been suitable for
+ burials: It was common at the time to bury people in elevated areas, to avoid rising water tables that
+ could push the caskets back up to the surface.</p>
+ <p class="item-body-text-graf">The next day, the Daughters gathered again around Corbin&#8217;s monument,
+ dressed in large hats and sashes. The ground looked as though it had never been disturbed. A casual
+ viewer would&#8217;ve never known that Corbin wasn&#8217;t under their feet.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="In 2018, the Margaret Corbin monument was rededicated with a wreath laying.
+" width="auto" data-kind="article-image" id="article-image-71504"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71504/image.jpg">
+ <figcaption class="caption structured-caption noskim">In 2018, the Margaret Corbin monument was
+ rededicated with a wreath laying.
+ <span class="caption-credit">Daughters of the American Revolution</span></figcaption>
+ </figure>
+ <p class="item-body-text-graf">It was an important day for the Daughters, but especially for Minus, who
+ joined the DAR in part because of Corbin. Once, before she graduated from West Point, she told her
+ grandparents about a lunch honoring Margaret Corbin. Her grandmother told her that her heritage made her
+ eligible to join the Daughters of the American Revolution, and a few years later, when she returned from
+ a post in Germany, her grandma prepared the necessary papers.</p>
+ <p class="item-body-text-graf">Minus is hopeful that they&#8217;ll find Corbin near the river, not far
+ from the grave that the Daughters dug up in 1926. &#8220;When they started digging, they found bones.
+ So, they didn&#8217;t make, like, 10 different holes over a field. They got it on their first attempt
+ and found bones. What I&#8217;m hoping is that they just had to do a 180, and she would&#8217;ve been
+ five feet over.&#8221;</p>
+ <p class="item-body-text-graf">The man they found in Corbin&#8217;s grave has since come back to the West
+ Point cemetery, to be reinterred with the other unidentified remains found in the area. No one yet knows
+ who the man could be. Some theorize it&#8217;s Corbin&#8217;s second husband&#8212;but there&#8217;s no
+ proof that she remarried. Others believe it was a Native American. It&#8217;s possible that the unknown
+ man might be dug up a third time, should the proper clues demand his participation. Corbin&#8217;s
+ original gravesite did not turn up <a
+ href="https://www.dar.org/national-society/dar-2018-search-efforts-0">in 2018</a>, but the search
+ continues.</p>
+ <p class="item-body-text-graf">On Corbin Day in 2019, after the 21-gun salute, the Daughters hold another
+ luncheon. This time, the Margaret Corbin Task Force has something special on display: a machine that
+ looks like a souped-up lawn mower. Many Daughters file into the room and ask what it is. &#8220;Just
+ wait,&#8221; Minus answers. Then Lieutenant Colonel Mindy Kimball, an environmental science professor at
+ West Point, holds a demonstration. It&#8217;s a ground-penetrating radar machine, which shoots
+ electromagnetic waves into the ground and sends information back up to the antennae, to identify
+ underground disturbances that could reveal human remains.</p>
+ <figure class="article-image-full-width contains-caption "><img
+ class="article-image with-structured-caption lazy"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png"
+ alt="The monument to Margaret Corbin is West Point&#8217;s only monument to a woman veteran."
+ width="auto" data-kind="article-image" id="article-image-71512"
+ data-src="https://assets.atlasobscura.com/article_images/lg/71512/image.jpg">
+ <figcaption class="caption structured-caption noskim">The monument to Margaret Corbin is West
+ Point&#8217;s only monument to a woman veteran. <a class="caption-credit" rel="nofollow"
+ target="_blank"
+ href="https://commons.wikimedia.org/wiki/File:Margaret_Corbin_Memorial_Base,_West_Point,_NY.JPG">Ahodges7
+ / CC BY-SA 3.0</a></figcaption>
+ </figure>
+ <p class="item-body-text-graf">Whether or not Corbin is ever located, just sharing her story helps to
+ immortalize her. Minus is fascinated by the many identities that Corbin came to inhabit.
+ &#8220;She&#8217;s an army spouse, and then an army widow, and then she was a soldier, and then she was
+ a wounded soldier, and then she was a prisoner of war, and then she was a veteran,&#8221; she says.
+ Corbin was also the first woman to receive a military pension from the government, and is mentioned by
+ name in the Congressional Record. &#8220;I really think of her as that building block for women in the
+ military.&#8221;</p>
+ <p class="item-body-text-graf">Stella Bailey, the town historian of Highland Falls, has been researching
+ Margaret Corbin for decades. She&#8217;s pored over old maps, trying to pinpoint exactly where Corbin
+ might have been buried in 1800. She even gets emails from people who think they might be related to
+ Corbin.</p>
+ <p class="item-body-text-graf item-body-last">Sitting in her office, overlooking Main Street in Highland
+ Falls, Bailey sighs. &#8220;We know she was real. West Point&#8217;s records acknowledge her
+ existence,&#8221; she says. But she can list discrepancies in Corbin&#8217;s story. Some say her husband
+ was shot in the head; some say he was shot in the heart. Others say Corbin dressed as a man to fight in
+ the war. Sometimes she wonders whether she will ever find answers. Perhaps these conflicting stories are
+ just a part of Corbin&#8217;s mystique. &#8220;The more I research, the less I know,&#8221; Bailey says.
+ </p>
+ <p class="item-body-text-graf"><em>You can <a
+ href="https://community.atlasobscura.com/t/the-missing-grave-of-margaret-corbin-revolutionary-war-veteran-discussion-thread/33149"
+ target="_blank" rel="noopener noreferrer">join the conversation </a>about this and other stories in
+ the Atlas Obscura Community Forums.</em></p>
+ </section>
+ <div class="ItemEndRow" data-category='Articles Page Recirc' data-action='Recirc Item Clicked'
+ data-label='Footer Next Article'>
+ <div class="HorizontalCardWrapper --ArticleWrapper ">
+ <a class="HorizontalCard" data-gtm-category="Articles Page Recirc" data-gtm-template="End Cap"
+ data-gtm-content-type="Article" data-gtm-recirc-association="Related"
+ href="/articles/most-expensive-sports-memorabilia-olympics-manifesto">
+ <div class="HorizontalCard__content-wrap">
+ <div class="HorizontalCard__hat">Read next</div>
+ <h3 class="HorizontalCard__heading">
+ <span class="js-socket-title">Sold: Pierre de Coubertin’s Blueprint for the Olympics</span>
+ </h3>
+ <div
+ class="HorizontalCard__content HorizontalCard__subheading js-subtitle-content js-socket-subtitle">
+ The 14-page speech is now the world’s most expensive piece of sports memorabilia.
+ </div>
+ </div>
+ <figure class="HorizontalCard__figure js-HorizontalCard__figure js-content-horizontal-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzM2MWMwYjc0LWY2Y2MtNDE4MC1hODc3LTQwODk1NjA5ODE1ODZkYzNlYzc0NWVmMjg4OGZkNl9TY3JlZW4gU2hvdCAyMDIwLTAxLTEzIGF0IDUuMzMuMTEgUE0ucG5nIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjYwMHg0MDAjIl1d/Screen%20Shot%202020-01-13%20at%205.33.11%20PM.png"
+ alt="" data-width="1073" data-height="615" class="lazy HorizontalCard__img u-img-responsive"
+ nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ </a>
+ </div>
+ </div>
+ <div class="itemTags ItemEndRow">
+ <span class="itemTags__tag itemTags__tag--rounded"><a class="itemTags__link"
+ href="/categories/graveyards">graveyards</a></span><span
+ class="itemTags__tag itemTags__tag--rounded"><a class="itemTags__link"
+ href="/categories/military-history">military history</a></span><span
+ class="itemTags__tag itemTags__tag--rounded"><a class="itemTags__link"
+ href="/categories/monuments">monuments</a></span><span class="itemTags__tag itemTags__tag--rounded"><a
+ class="itemTags__link" href="/categories/cemeteries">cemeteries</a></span><span
+ class="itemTags__tag itemTags__tag--rounded"><a class="itemTags__link"
+ href="/categories/history">history</a></span>
+ </div>
+ </div>
+ <div id="ArticleStickyRailTrack" class="content-siderail hidden-print">
+ <div class="js-above-rail"></div>
+ <div id="ArticleStickyRail" class="js-sticky-siderail" data-below-of=".js-above-rail"
+ data-affixed-top-margin="74" data-affixed-bottom-margin="0" data-above-of=".js-below-rail">
+ <div class="siderail-ad hidden-sm hidden-xs">
+ <div class=" fixedPositionAdBg--300 ad-background">
+ <div class='ad-wrapper hidden-print'>
+ <div class="htl-ad" data-unit="Rotational_Rectangle_Desktop"
+ data-ref="Rotational_Rectangle_Desktop_div" data-refresh="viewable" data-refresh-secs="15"
+ data-sizes="0x0:|668x0:300x250,300x400,300x600" data-prebid="Rotational_Rectangle_Desktop"
+ data-lazy></div>
+ </div>
+ </div>
+ </div>
+ <aside id="sidebar-popular" class="js-most-popular-recircs aside-recirc-module related-articles-module">
+ </aside>
+ </div>
+ </div>
+ <div id="ArticleStickyRailBrakePositioner" class="hidden-xs hidden-sm">
+ <div id="ArticleStickyRailBrake" class="js-below-rail"></div>
+ </div>
+ <div class="col-md-4 ArticleRightRail__space-holder hidden-xs hidden-sm"></div>
+ </div>
+ </div>
+ </article>
+ <div class="recirc-footer-wrap hidden-print ">
+ <div class="card-grid js-inject-gtm-data-in-child-links" data-gtm-category='Articles Page Recirc'
+ data-gtm-action='Recirc Item Clicked' data-gtm-label='Footer Related Content Card'
+ data-gtm-template='Footer Cards' data-gtm-recirc-association='Related Mixed Content'>
+ <div class="athanasius">
+ <div
+ class="full-width-container CardRecircSection CardRecircSection--8-cards-lg full-width-container CardRecircSection__article-footer">
+ <div class="container">
+ <div class='CardRecircSection__header'>
+ <div class="CardRecircSection__title">Keep Exploring</div>
+ </div>
+ <div class="card-grid CardRecircSection__card-grid js-inject-gtm-data-in-child-links"
+ data-gtm-category='Article Page Recirc' data-gtm-action='Recirc Item Clicked'
+ data-gtm-label='Related Content Card' data-gtm-template='Footer Cards'
+ data-gtm-recirc-association='Related Mixed Content'>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --ArticleWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat" data-type="Article"
+ data-item-id="13013" data-gtm-content-type="Article" data-lat="34.198423" data-lng="-90.553051"
+ href="/articles/where-is-robert-johnson-buried">
+ <figure class="Card__figure js-Card__figure --content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzdhOWJjZjViNGRmMmJlNjBmNl9EWE1XQ0guanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjYwMHg0MDAjIl1d/DXMWCH.jpg"
+ alt="" data-width="1280" data-height="843"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">cemeteries</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>The Not-So-Mysterious Missing Grave of Blues Legend Robert Johnson</span>
+ </h3>
+ <div class="Card__content js-subtitle-content">
+ The supernatural has surrounded the guitar virtuoso for decades.
+ </div>
+ <div class="Card__footer --content-card-footer">
+ <div class="Card__byline-dateline --article-byline-dateline">
+ <span class="Card__byline --article-byline">Matthew Taub</span>
+ <span class="Card__dateline --article-byline-date">October 23, 2019</span>
+ </div>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --ArticleWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat" data-type="Article"
+ data-item-id="12331" data-gtm-content-type="Article" href="/articles/london-double-sided-graves">
+ <figure class="Card__figure js-Card__figure --content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzL2IzYTVjMjI3OWVkNmJkYWNjNV8yMS0wMS0xOSBDTENDICgzNSkgMi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweDQwMCMiXV0/21-01-19%20CLCC%20%2835%29%202.jpg"
+ alt="" data-width="4471" data-height="2981"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">cemeteries</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>Are Double-Sided Graves the Solution to London&#39;s Burial Crisis? </span>
+ </h3>
+ <div class="Card__content js-subtitle-content">
+ Toward a new definition of eternity.
+ </div>
+ <div class="Card__footer --content-card-footer">
+ <div class="Card__byline-dateline --article-byline-dateline">
+ <span class="Card__byline --article-byline">Katie Thornton</span>
+ <span class="Card__dateline --article-byline-date">April 19, 2019</span>
+ </div>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --ArticleWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat" data-type="Article"
+ data-item-id="9443" data-gtm-content-type="Article"
+ href="/articles/cemeteries-to-visit-before-you-die-monuments">
+ <figure class="Card__figure js-Card__figure --content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzBjNmZkOTY4OWRjODE0ZTg1Ml8xMjlfc3BhaW5fcG9ibGVub3VfZm90b2tvbu-AonNodXR0ZXJzdG9ja180Mjc2MjAwMjguanBnIl0sWyJwIiwiY29udmVydCIsIiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtYXV0by1vcmllbnQiXSxbInAiLCJ0aHVtYiIsIjYwMHg0MDAjIl1d/129_spain_poblenou_fotokon%EF%80%A2shutterstock_427620028.jpg"
+ alt="" data-width="1200" data-height="873"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">cemeteries</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>In Search of Cemeteries Alive With Beauty, Art, and History</span>
+ </h3>
+ <div class="Card__content js-subtitle-content">
+ These resting places celebrate life.
+ </div>
+ <div class="Card__footer --content-card-footer">
+ <div class="Card__byline-dateline --article-byline-dateline">
+ <span class="Card__byline --article-byline">Anika Burgess</span>
+ <span class="Card__dateline --article-byline-date">October 31, 2018</span>
+ </div>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --ArticleWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat" data-type="Article"
+ data-item-id="10971" data-gtm-content-type="Article" data-lat="33.314837" data-lng="126.273449"
+ href="/articles/dark-past-jeju-island-korea">
+ <figure class="Card__figure js-Card__figure --content-card-figure js-content-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzQ0NTdhN2QxOTNkZDQ4ZTkzNl9UaGUgbWVtb3JpYWwgY2VtZXRlcnkgYXQgdGhlIEplanUgNC4zIFBlYWNlIFBhcmtfU2FtdWVsIEJlcmdzdHJvbV8yMDE4LmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCI2MDB4NDAwIyJdXQ/The%20memorial%20cemetery%20at%20the%20Jeju%204.3%20Peace%20Park_Samuel%20Bergstrom_2018.jpg"
+ alt="" data-width="2000" data-height="1333"
+ class="lazy Card__img u-img-responsive --img-responsive --content-card-img" nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">cemeteries</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>The Bloody Past of Korea’s ‘Honeymoon Island’</span>
+ </h3>
+ <div class="Card__content js-subtitle-content">
+ Reckoning with a dark legacy at a time of great change.
+ </div>
+ <div class="Card__footer --content-card-footer">
+ <div class="Card__byline-dateline --article-byline-dateline">
+ <span class="Card__byline --article-byline">Erin Craig</span>
+ <span class="Card__dateline --article-byline-date">May 11, 2018</span>
+ </div>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/how-to-dig-a-grave">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDQvMjMvMjAvMTcvMDEvMGU4NzI2ODgtOTdkOC00NjAwLWFlYTctZjhlMDQ4ODZhMmNiL0dyYXZlZGlnZ2luZyAwNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMTIwMDB4MTIwMDA-Il1d"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>How to Dig a Grave</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>8:51</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/an-ancient-cemetery-pillaged-by-grave-robbers">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDUvMTQvMTQvMzAvMTEvYzdhZjlkNDQtMjczNS00OTg0LWEzZjEtOGY0YmE1ZDViNjIxL0NoYXVjaGlsbGFzdGlsbDA0LmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIxMjAwMHgxMjAwMD4iXV0"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video&nbsp;&bull; PinDrop</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>An Ancient Cemetery, Resurrected</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>1:51</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/abandoned-futuristic-fort-portland-maine">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDMvMjcvMDAvMDIvNTAvYTgwY2FkZTMtOWU3NS00MzQ4LWJmYTItZTg4NTE2MDEyY2IxL0JhdHRlcnlTdGVlbGVTdGlsbC5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMTIwMDB4MTIwMDA-Il1d"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>There&#39;s an Abandoned Futuristic Fort in Portland, Maine</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>3:32</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/a-conversation-in-america-s-most-metal-cemetery">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDMvMjUvMTgvMzYvMzAvZDA2NzgwNDQtMGRiZi00NTlhLThiNjMtMzY4NDE5NmU2YjU3L0VsbGEgJiBDYWl0bGluIDAzLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIxMjAwMHgxMjAwMD4iXV0"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>An Introduction to America&#39;s Most Metal Cemetery</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>6:04</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/inside-hawai-i-s-native-language-newspaper-archive">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDMvMjcvMTQvNTIvMDEvMjhiOWM0ZjAtNGNlOC00YTlhLTg4MTEtYWZmNjE3ZDRiZGQ2L0hhd2FpaVN0aWxsLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIxMjAwMHgxMjAwMD4iXV0"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video&nbsp;&bull; AO Docs</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>Hawaiʻi’s Native-Language Newspaper Archive</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>3:35</span></div>
+ <div class="Card__footer VideoCard__footer --content-card-footer">
+ <span class="sponsored-article-tag"><span class='js-tracked-sponsored-recirc'
+ data-recirc-pid='video-54'>Sponsored by Olukai</span>
+ </span>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/gaze-upon-a-million-monarch-butterflies-in-mexico">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDQvMDkvMjAvMzcvMzMvNDlmMGEzMjAtY2ViYS00MGYyLTg5NjktMzYzZjkyZDM1YmFlL21vbmFyY2gwMi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiMTIwMDB4MTIwMDA-Il1d"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video&nbsp;&bull; AO Docs</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>&#39;Discovering&#39; Mexico&#39;s Monarch Butterfly Migration</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>6:46</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ <div class="CardWrapper js-CardWrapper">
+ <div class="CardWrapper --VideoWrapper js-CardWrapper ">
+ <a class="Card --content-card-v2 --content-card-item Card--flat Card--video"
+ data-gtm-content-type="Video" href="/videos/what-were-george-washingtons-dentures-made-of">
+ <figure class="Card__figure js-Card__figure -- content-card-figure js-content-card-figure">
+ <img
+ src="https://assets.atlasobscura.com/media/W1siZiIsIjIwMTkvMDkvMDMvMjAvMzIvMTQvNTUyZjg2YjAtMTg0NC00ZWI2LWIzMWEtOTllNzJkYWQxYTFiL0RlbnR1cmVzIDAzLmpwZyJdLFsicCIsImNvbnZlcnQiLCIiXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLWF1dG8tb3JpZW50Il0sWyJwIiwidGh1bWIiLCIxMjAwMHgxMjAwMD4iXV0"
+ class="Card__img u-img-responsive --img-responsive --content-card-img lazy" alt="" />
+ </figure>
+ <div class="Card__content-wrap --content-card-text">
+ <div class="Card__hat">Video&nbsp;&bull; Object of Intrigue</div>
+ <h3 class="Card__heading --content-card-v2-title">
+ <span>The Real Story Behind George Washington&#39;s Dentures</span>
+ </h3>
+ <div class="Card__subheading VideoCard__subheading --content-card-footer"><i
+ class=" atlas-svg-wrap wrap-icon-aoc-video">
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <use href="#icon-aoc-video" xlink:href="#icon-aoc-video" />
+ </svg>
+ </i>
+ <span>3:30</span></div>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="container ad-container">
+ <hr class="above-ad-border hidden-sm hidden-xs">
+ <div class='ad-wrapper hidden-print ad-inline-banner ad-banner-lg'>
+ <div class="htl-ad" data-unit="Site_Bottom_Full_Width" data-ref="Site_Bottom_Full_Width_div"
+ data-sizes="0x0:|668x0:728x90,970x250,1440x585" data-prebid="Site_Bottom_Full_Width" data-eager></div>
+ </div>
+ </div>
+ <div class="footer-reflow">
+ </div>
+ </div>
+ <div id="articleBody__interrupt-card" class="hidden hidden-print">
+ <div class="js-inject-gtm-data-in-child-links" data-gtm-category='Articles Page Recirc'
+ data-gtm-action='Recirc Item Clicked' data-gtm-label='Interrupt Related Article' data-gtm-template='Interruptor'
+ data-gtm-content-type='Article' data-gtm-recirc-association='Related Article'>
+ <div class="HorizontalCardWrapper --ArticleWrapper ">
+ <a class="HorizontalCard"
+ href="/articles/grim-history-hidden-under-baltimore-parking-lot-cemetery-african-american">
+ <div class="HorizontalCard__content-wrap">
+ <div class="HorizontalCard__hat">Related</div>
+ <h3 class="HorizontalCard__heading">
+ <span class="js-socket-title">The Grim History Hidden Under a Baltimore Parking Lot</span>
+ </h3>
+ <div class="HorizontalCard__content HorizontalCard__subheading js-subtitle-content js-socket-subtitle">
+ After an African-American cemetery was bulldozed, families wondered what happened to the graves.
+ </div>
+ <div class="HorizontalCard__footer">Read more</div>
+ </div>
+ <figure class="HorizontalCard__figure js-HorizontalCard__figure js-content-horizontal-card-figure">
+ <img
+ data-src="https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzk5N2EwYTgxZjlhYzA2MzRiYl9GcmFuayBBIEdyYXkgTmV3IE1hcCBvZiBCYWx0aW1vcmUgMTg3NiAoMSkuanBnIl0sWyJwIiwiY29udmVydCIsIi1hdXRvLW9yaWVudCAiXSxbInAiLCJ0aHVtYiIsIjg2NHg1NzYrMTc1KzU2Il0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9yaWVudCJdLFsicCIsInRodW1iIiwiNjAweDQwMCMiXV0/Frank%20A%20Gray%20New%20Map%20of%20Baltimore%201876%20%281%29.jpg"
+ alt="" data-width="1039" data-height="1074" class="lazy HorizontalCard__img u-img-responsive"
+ nopin="nopin"
+ src="https://assets.atlasobscura.com/assets/blank-11b9c95a68e295dddd0ea924647536578ce285b2c8469a223c01df1ff3166af1.png" />
+ </figure>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="modal lightbox-modal fadeX" id="lightbox" tabindex="-1" role="dialog" aria-labelledby="lightbox">
+ <div id="lightbox-content">
+ <span class="icon-menu-close modal-close" data-dismiss="modal"></span>
+ <div id="lightbox-figure-box">
+ <figure id="lightbox-figure">
+ <img src="" id="lightbox-image" alt="" />
+ <figcaption id="lightbox-caption" class="caption">
+ </figcaption>
+ </figure>
+ </div>
+ <div id="lightbox-controls" class="hidden-sm hidden-xs">
+ <a class="js-lightbox-prev lightbox-gallery-control prev-arrow" href="javascript:void(0)"
+ aria-label="Previous image">
+ <i class="icon-expand_left"></i>
+ </a>
+ <a class="js-lightbox-next lightbox-gallery-control next-arrow" href="javascript:void(0)"
+ aria-label="Next image">
+ <i class="icon-expand_right"></i>
+ </a>
+ </div>
+ <div id="mobile-lightbox-control-prev" class="hidden-md hidden-lg">
+ <a class="js-lightbox-prev lightbox-gallery-control prev-arrow" href="javascript:void(0)">
+ <i class="icon-expand_left"></i>
+ </a>
+ </div>
+ <div id="mobile-lightbox-control-next" class="hidden-md hidden-lg">
+ <a class="js-lightbox-next lightbox-gallery-control next-arrow" href="javascript:void(0)">
+ <i class="icon-expand_right"></i>
+ </a>
+ </div>
+ </div>
+ <div class="lightbox-thumbnails hidden-sm hidden-xs hidden-md">
+ <div class="lightbox-thumbnails-container">
+ </div>
+ </div>
+ </div>
+ <div class="hidden-md hidden-lg m-social-adhesive-wrap">
+ <div id="js-item-social-adhesive" class="item-social-adhesive">
+ <div id="js-item-adhesive-btns" class="item-social-row ">
+ <a href=""
+ class="btn btn-adhesive btn-social-row btn-facebook btn-icon js-share-button-facebook js-social-action-tracked"
+ data-position="Bottom-Sticky-mobile" data-service="Facebook" data-action="Share"
+ aria-label="Share on Facebook">
+ <i class="icon-facebook"></i>
+ </a>
+ <a href="https://twitter.com/share?text=The%20Missing%20Grave%20of%20Margaret%20Corbin%2C%20Revolutionary%20War%20Veteran%3A%20@atlasobscura&amp;count=none&amp;url=https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point"
+ class="btn btn-adhesive btn-social-row btn-twitter btn-icon js-social-action-tracked js-social-ask-for-follow"
+ target="_blank" data-position="Bottom-Sticky-mobile" data-service="Twitter" data-action="Share"
+ aria-label="Share on Twitter">
+ <i class="icon-twitter"></i>
+ </a>
+ <a href="https://www.reddit.com/submit?url=https%3A%2F%2Fwww.atlasobscura.com%2Farticles%2Fmargaret-corbin-grave-west-point"
+ target="_blank"
+ class="btn btn-adhesive btn-social-row btn-reddit btn-icon icon-reddit js-social-action-tracked"
+ data-position="Bottom-Sticky-mobile" data-service="reddit" data-action="Share"
+ aria-label="Share on Reddit"></a>
+ <a href="mailto:?subject=The%20Missing%20Grave%20of%20Margaret%20Corbin%2C%20Revolutionary%20War%20Veteran&body=Discovered%20on%20Atlas%20Obscura%3A%20https://www.atlasobscura.com/articles/margaret-corbin-grave-west-point"
+ class="btn btn-adhesive btn-social-row btn-email btn-icon js-social-action-tracked"
+ data-position="Bottom-Sticky-mobile" data-service="Email" data-action="Send" aria-label="Email this">
+ <i class="icon-envelope"></i>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <footer class="new-footer page-footer">
+ <div class="footer-z-cover"></div>
+ <div class="container js-page-left-reference">
+ <div class="row">
+ <div class="col-md-6 col-lg-4">
+ <div class="footer-social">
+ <section class="subscribe-sm footer-subscribe">
+ <h4 class="heading-sm footer-links-heading js-footer-links-heading">Get Our Email Newsletter</h4>
+ <form class="footer-newsletter-form js-email-ask-form" action="/email_lists/signup" accept-charset="UTF-8"
+ data-remote="true" method="post"><input name="utf8" type="hidden" value="&#x2713;" />
+ <input type="hidden" name="source" value="footer-email-ask" />
+ <input type="hidden" name="subscribe_general" value="true" />
+ <input type="email" name="email" placeholder="enter your email" class="js-footer-email-ask-removable"
+ aria-label="Email" />
+ <input type="submit" name="commit" value="Subscribe"
+ class="btn btn-secondary btn-orange js-footer-email-ask-removable" />
+ <div id="js-footer-email-ask-thanks" class="subscribe-thanks detail">Thanks for subscribing!
+ <a target="_parent" data-category="View All Newsletters Link" data-action="Click" data-label="Footer"
+ class="js-view-newsletters" href="/newsletters">View all newsletters &raquo;</a>
+ </div>
+ </form>
+ </section>
+ </div>
+ </div>
+ <div class="col-md-3 social-icons">
+ <div class="row nested-row">
+ <h4 class="heading-sm footer-links-heading js-footer-links-heading social-icons-heading">Follow Us</h4>
+ <ul id="footer-social-list">
+ <li><a target="_blank" href="https://www.facebook.com/atlasobscura/"
+ class="icon-facebook btn-icon footer-social-btn js-social-action-tracked" data-position="Footer"
+ data-service="Facebook" data-action="Like" aria-label="Like us on Facebook"></a></li>
+ <li><a target="_blank" href="https://www.youtube.com/user/atlasobscura"
+ class="icon-youtube btn-icon footer-social-btn js-social-action-tracked" data-position="Footer"
+ data-service="Youtube" data-action="Follow" aria-label="Subscribe to our Youtube channel"></a></li>
+ <li><a target="_blank" href="https://twitter.com/atlasobscura"
+ class="icon-twitter btn-icon footer-social-btn js-social-action-tracked" data-position="Footer"
+ data-service="Twitter" data-action="Follow" aria-label="Follow us on Twitter"></a></li>
+ <li><a target="_blank" href="https://www.instagram.com/atlasobscura/"
+ class="icon-instagram btn-icon footer-social-btn js-social-action-tracked" data-position="Footer"
+ data-service="Instagram" data-action="Follow" aria-label="Follow us on Instagram"></a></li>
+ <li><a target="_blank" href="/feeds/latest"
+ class="icon-rss btn-icon footer-social-btn js-social-action-tracked" data-position="Footer"
+ data-service="RSS" data-action="Follow" aria-label="Subscribe to our RSS feed"></a></li>
+ </ul>
+ </div>
+ </div>
+ <div class="hidden-sm hidden-xs hidden-md promotional-content">
+ <div class="footer-shop-callout-wrap">
+ <a class="shop-callout non-decorated-link" target="" href="/unique-gifts/atlas-obscura-book">
+ <figure class="shop-callout-image-wrap">
+ <img class="img-responsive"
+ src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvYXBwLWltYWdlcy9ib29rLzJuZC1lZGl0aW9uLW9uLXNpdGUtcHJvbW8ucG5nIl0sWyJwIiwidGh1bWIiLCIyNTB4PiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/2nd-edition-on-site-promo.png"
+ alt="2nd edition on site promo" />
+ </figure>
+ <div class="shop-callout-text">
+ <div class="detail-md">Get the Atlas Obscura book</div>
+ <div class="cta-xs shop-action-text">Shop Now »</div>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="row footer-links">
+ <div class="col-md-7 five-wide-3">
+ <section class="atlas-section col-md-4">
+ <h3 class="heading-sm footer-links-heading mobile-accordion-title js-footer-links-heading">
+ Places <i class="icon-expand_more footer-expand-arrow visible-sm visible-xs"></i>
+ </h3>
+ <div class="is-slider-hidden-m">
+ <ul class="links-column">
+ <li><a target="" href="/places">Recently Added</a></li>
+ <li><a target="" href="/places?sort=likes_count">Most Popular</a></li>
+ <li><a target="" href="/random">Random</a></li>
+ <li><a target="" href="/search/search_nearby">Nearby</a></li>
+ <li><a class="js-user-required" data-cause-key="p_add" target="" href="/places/new">Add a Place</a></li>
+ </ul>
+ </div>
+ </section>
+ <section class="col-md-4">
+ <h3 class="heading-sm footer-links-heading mobile-accordion-title js-footer-links-heading">
+ Foods <i class="icon-expand_more footer-expand-arrow visible-sm visible-xs"></i>
+ </h3>
+ <div class="is-slider-hidden-m">
+ <ul class="links-column">
+ <li><a target="" href="/gastro">Latest</a></li>
+ <li><a target="" href="/unique-food-drink">Food &amp; Drink</a></li>
+ <li><a target="" href="/gastro/stories">Stories</a></li>
+ <li><a target="" href="/gastro/places">Places</a></li>
+ <li style="display: none;"><a target="" href="/foods/new">Add a Food</a></li>
+ </ul>
+ </div>
+ </section>
+ <section class="col-md-4">
+ <h3 class="heading-sm footer-links-heading mobile-accordion-title js-footer-links-heading">
+ Experiences & Trips <i class="icon-expand_more footer-expand-arrow visible-sm visible-xs"></i>
+ </h3>
+ <div class="is-slider-hidden-m">
+ <ul class="links-column">
+ <li><a target="" href="/experiences">Upcoming Experiences</a></li>
+ <li><a target="" href="/unusual-trips/all">Upcoming Trips</a></li>
+ <li><a href="https://blog.atlasobscura.com">Trips Blog</a></li>
+ </ul>
+ </div>
+ </section>
+ </div>
+ <div class="col-md-5 five-wide-2">
+ <section class="col-md-6">
+ <h3 class="heading-sm footer-links-heading mobile-accordion-title js-footer-links-heading">
+ Community <i class="icon-expand_more footer-expand-arrow visible-sm visible-xs"></i>
+ </h3>
+ <div class="is-slider-hidden-m">
+ <ul class="links-column">
+ <li><a target="" href="https://community.atlasobscura.com">Travel Forum</a></li>
+ <li><a target="" href="https://community.atlasobscura.com/latest">Latest Posts</a></li>
+ <li><a target="" href="https://community.atlasobscura.com/top">Top Posts</a></li>
+ </ul>
+ </div>
+ </section>
+ <section class="col-md-6">
+ <h3 class="heading-sm footer-links-heading mobile-accordion-title js-footer-links-heading">
+ Company <i class="icon-expand_more footer-expand-arrow visible-sm visible-xs"></i>
+ </h3>
+ <div class="is-slider-hidden-m">
+ <ul class="links-column">
+ <li><a target="" href="/about">About</a></li>
+ <li class="hidden-md hidden-lg"><a
+ onclick="window.open(this.href,'Contact Atlas Obscura','width=600,height=700,toolbar=no,menubar=no,scrollbars'); return false"
+ href="https://www.atlasobscura.com/contact_form" target="_blank">Contact Us</a></li>
+ <li><a target="" href="/faq">FAQ</a></li>
+ <li><a target="" href="/jobs">Work With Us</a></li>
+ <li><a target="" href="mailto:ads@atlasobscura.com">Advertising</a></li>
+ <li><a target="" href="/unique-gifts">Unique Gifts</a></li>
+ <li><a target="" href="/privacy">Privacy Policy</a></li>
+ <li><a target="" href="/terms">Terms of Use</a></li>
+ </ul>
+ </div>
+ </section>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-4 hidden-md hidden-lg hidden-xl promotional-content">
+ <div class="footer-shop-callout-wrap">
+ <a class="shop-callout non-decorated-link" target="" href="/unique-gifts/atlas-obscura-book">
+ <figure class="shop-callout-image-wrap">
+ <img class="img-responsive"
+ src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvYXBwLWltYWdlcy9ib29rLzJuZC1lZGl0aW9uLW9uLXNpdGUtcHJvbW8ucG5nIl0sWyJwIiwidGh1bWIiLCIyNTB4PiJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/2nd-edition-on-site-promo.png"
+ alt="2nd edition on site promo" />
+ </figure>
+ <div class="shop-callout-text">
+ <div class="detail-md">Get the Atlas Obscura book</div>
+ <div class="cta-xs shop-action-text">Shop Now »</div>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="copyright col-md-8 col-lg-6">
+ &copy; 2020 Atlas Obscura. All rights reserved.
+ </div>
+ </div>
+ </div>
+ </footer>
+ <a id="site-feedback-wrap" target="_blank"
+ onclick="window.open(this.href,'Contact Atlas Obscura','width=600,height=700,toolbar=no,menubar=no,scrollbars'); return false"
+ href="https://www.atlasobscura.com/contact_form">
+ <button id="btn-site-feedback" class="btn btn-stretch">Questions or Feedback? <span class="static-underline">Contact
+ Us</span></button>
+ </a>
+
+ <div class="js-paginate-content-modal-controls paginate-content-modal-controls close-button-container">
+ <button type="button" class="modal-close-button js-modal-close-button icon icon-menu-close"
+ aria-label="Close"></button>
+ </div>
+ <div class="js-paginate-content-modal paginate-content-modal modal">
+ <div class="modal-body">
+ </div>
+ </div>
+ <div class="js-confirmation-modal confirmation-modal modal">
+ <div class="modal-dialog">
+ <div class="modal-bg">
+ <div class="modal-header hidden"></div>
+ <div class="modal-body">
+ <div class="modal-dismiss">
+ <button type="button" data-dismiss="modal"
+ class="modal-close-button js-modal-close-button icon icon-menu-close" aria-label="Close"></button>
+ </div>
+ <div class="modal-content">
+ <div class="confirmation-modal-heading title-md baseline-standard baseline-mobile"></div>
+ <p class="confirmation-modal-text"></p>
+ </div>
+ <div class="modal-buttons">
+ <div class="submit-button btn btn-modal-cancel"></div>
+ <div data-dismiss="modal" class="back-button btn btn-modal-submit"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="social-follow-ask-modal" class="confirmation-modal modal">
+ <div class="modal-dialog">
+ <div class="modal-bg">
+ <div class="modal-body">
+ <div class="modal-dismiss">
+ <button type="button" data-dismiss="modal"
+ class="modal-close-button js-modal-close-button icon icon-menu-close" aria-label="Close"></button>
+ </div>
+ <div class="modal-content">
+ <div class="confirmation-modal-heading title-md baseline-standard baseline-mobile">Thanks for sharing!</div>
+ <p data-service="Twitter" style="display: none;" class="baseline-standard baseline-mobile">Follow us on
+ Twitter to get the latest on the world's hidden wonders.</p>
+ <p data-service="Facebook" style="display: none;" class="baseline-standard baseline-mobile">Like us on
+ Facebook to get the latest on the world's hidden wonders.</p>
+ <a href="https://twitter.com/atlasobscura"
+ class="js-social-action-tracked btn btn-twitter fullscreen-modal-social-btn" target="_blank"
+ data-service="Twitter" data-action="Follow" data-position="Modal After Social Action"
+ style="display: none;"><i class="icon icon-twitter"></i>Follow us on Twitter</a>
+ <a href="https://www.facebook.com/atlasobscura/"
+ class="js-social-action-tracked btn btn-facebook fullscreen-modal-social-btn" target="_blank"
+ data-service="Facebook" data-action="Like" data-position="Modal After Social Action"
+ style="display: none;"><i class="icon icon-facebook"></i>Like us on Facebook</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="book-contest-email-modal" class="modal modal-sm-fullscreen modal-md-fullscreen js-subscription-ask-modal"
+ role="dialog">
+ <div class="modal-body-fullscreen">
+ <div class="close-button-container">
+ <i class="modal-close-button icon icon-menu-close" data-dismiss="modal" aria-label="Close"></i>
+ </div>
+ <div id="contest-wrap" class="standalone-signup-wrap align-items-center fullpage-bg-modal plain-beige-version">
+ <div id="contest-bg" class="topographic transparent-topo"></div>
+ <div class="container pre-final-container">
+ <div class="row">
+ <center class="contest-contents col-lg-10 col-lg-push-1">
+ <div class="contest-form-wrap">
+ <form class="js-email-roadblock-form js-email-ask-form contest-signup-ui" id="contest-form"
+ action="/email_lists/signup" accept-charset="UTF-8" data-remote="true" method="post"><input
+ name="utf8" type="hidden" value="&#x2713;" />
+ <input type="hidden" name="subscribe_general" value="true" />
+ <input id="contest-source" type="hidden" name="source" value="Email Ask (Red, Free Book)" />
+ <input type="hidden" name="merge_vars[MMERGE15]" id="merge_vars_MMERGE15" value="1" />
+ <input type="hidden" name="merge_vars[MMERGE21]" id="merge_vars_MMERGE21"
+ value="Book Contest - January 2020" />
+ <h4 id="book-contest-title" class="title-lg baseline-standard baseline-mobile animate-swing-up">Want a
+ Free Book?</h4>
+ <div class="animate-text-reveal">
+ <div class="subtitle-lg baseline-standard baseline-mobile">Sign up for our newsletter and enter to
+ win the second edition of our book, <em>Atlas Obscura: An Explorer’s Guide to the World’s Hidden
+ Wonders</em>.</div>
+ <fieldset>
+ <div class="cta-lg validate-message"></div>
+ <div class="submit-inline baseline-standard baseline-mobile">
+ <input type="email" name="email" class="detail-md" required="required"
+ placeholder="Enter your email address" aria-label="email" />
+ <button type="submit" class="g-recaptcha btn btn-default js-contest-submit-btn"
+ data-badge="bottomright" data-callback="submitCaptchadContestForm"
+ data-sitekey="6LeCJy0UAAAAAO5grI_UrlSR1oz9AceexUbkcHgC" eager-recaptcha="1">Subscribe</button>
+ </div>
+ </fieldset>
+ <a href="javascript:void(0)" class="contest-dismiss-link cta-lg" data-dismiss="modal">No Thanks</a>
+ <a href="/" class="contest-take-me-home-link cta-lg" data-dismiss="modal">Visit AtlasObscura.com</a>
+ </div>
+ </form>
+ </div>
+ <div id="contest-image-wrap" class="hidden-xs hidden-sm">
+ <picture>
+ <source
+ data-srcset="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvZmF2aWNvbi0xNngxNi5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/favicon-16x16.png"
+ media="(max-width: 667px)"
+ srcset="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaWNvbnMvZmF2aWNvbi0xNngxNi5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/favicon-16x16.png" />
+ <img class="img-responsive"
+ data-src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaW50ZXJuYWwtb25lLW9mZnMvc2hvcC9zZWNvbmQtZWR0aW9uLW9wdGltLnBuZyJdLFsicCIsImNvbnZlcnQiLCItcXVhbGl0eSA4MSAtc3RyaXAiXV0/second-edtion-optim.png"
+ src="https://assets.atlasobscura.com/media/W1siZnUiLCJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vYXRsYXMtZGV2L21pc2MvaW50ZXJuYWwtb25lLW9mZnMvc2hvcC9zZWNvbmQtZWRpdGlvbi1vcHRpbS5wbmciXSxbInAiLCJjb252ZXJ0IiwiLXF1YWxpdHkgODEgLXN0cmlwIl1d/second-edition-optim.png"
+ alt="Atlas Obscura: An Explorer's Guide to the World's Hidden Wonders" />
+ </picture>
+ </div>
+ </center>
+ </div>
+ </div>
+ <div class="final-state-asks">
+ <center>
+ <h1 class="title-lg baseline-standard baseline-mobile" style="color: #fff;">Stay in Touch!</h1>
+ <div class="subtitle-lg baseline-standard baseline-mobile">Follow us on social media to add even more wonder
+ to your day.</div>
+ <a href="https://twitter.com/atlasobscura"
+ class="js-social-action-tracked js-hidden-if-contest btn btn-twitter fullscreen-modal-social-btn"
+ target="_blank" data-service="Twitter" data-action="Follow" data-position="Contest Ask"><i
+ class="icon icon-twitter"></i>Follow us on Twitter</a>
+ <a href="https://www.facebook.com/atlasobscura/"
+ class="js-social-action-tracked btn btn-facebook fullscreen-modal-social-btn" target="_blank"
+ data-service="Facebook" data-action="Like" data-position="Contest Ask"><i
+ class="icon icon-facebook"></i>Like us on Facebook</a>
+ <a href="https://www.instagram.com/atlasobscura/"
+ class="js-social-action-tracked btn btn-instagram fullscreen-modal-social-btn" target="_blank"
+ data-service="Instagram" data-action="Follow" data-position="Contest Ask" style="margin-bottom: 24px;"><i
+ class="icon icon-instagram"></i>Follow Us on Instagram</a>
+ <a href="javascript:void(0)" style="" class="contest-dismiss-link cta-lg" data-dismiss="modal">No Thanks</a>
+ <a href="/" class="contest-take-me-home-link cta-lg" data-dismiss="modal">Visit AtlasObscura.com</a>
+ </center>
+ </div>
+ <div class="container contest-disclaimer contest-signup-ui">
+ <div class="row">
+ <div class="col-lg-6 col-lg-push-1">
+ <div class="contest-footnote">No purchase necessary. Winner will be selected at random on 02/01/2020.
+ Offer available only in the U.S. (including Puerto Rico). Offer subject to change without notice. See <a
+ href="/newsletters/contest-rules">contest rules</a> for full details.</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="email-roadblock-topographic-modal" class="modal js-subscription-ask-modal " role="dialog">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-wrap modal-wrap-topographic topographic modal-wrap-responsive">
+ <i class="icon-modal-close modal-close" data-dismiss="modal"></i>
+ <div class="row modal-bg roadblock-modal-content">
+ <div class="modal-body" id="newsletter-email-collection-modal">
+ <div id="js-email-roadblock-topographic-modal-thanks" class="form-complete-notice"></div>
+ <form class="js-email-roadblock-form js-email-ask-form" action="/email_lists/signup"
+ accept-charset="UTF-8" data-remote="true" method="post"><input name="utf8" type="hidden"
+ value="&#x2713;" />
+ <input type="hidden" name="subscribe_general" value="true" />
+ <input type="hidden" name="source" value="email-roadblock-topographic-modal" />
+ <h4 class="title-lg modal-title-roadblock">Add Some Wonder to Your Inbox</h4>
+ <div class="subtitle-md">Every weekday we compile our most wondrous stories and deliver them straight to
+ you.</div>
+ <div id="js-email-roadblock-topographic-modal-error"></div>
+ <fieldset class="modal-fieldset">
+ <div class="cta-lg validate-message"></div>
+ <div class="email-submit-group-m">
+ <input type="email" name="email" class="col-md-12" required="required" placeholder="your email"
+ aria-label="Email" />
+ <button name="button" type="submit" class="btn btn-stretch btn-submit">
+ <i class="icon-envelope"></i><span class="hidden-sm hidden-xs">Subscribe</span>
+ </button> </div>
+ </fieldset>
+ <footer class="roadblock-footer">
+ <a href="" class="roadblock-dismiss-link cta-lg" data-dismiss="modal">No Thanks</a>
+ </footer>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="facebook-topographic-modal" class="modal js-subscription-ask-modal " role="dialog">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-wrap modal-wrap-topographic topographic modal-wrap-responsive modal-wrap-fb">
+ <i class="icon-modal-close modal-close" data-dismiss="modal"></i>
+ <div class="row modal-bg roadblock-modal-content">
+ <div class="modal-body">
+ <div id="js-facebook-topographic-modal-thanks" class="form-complete-notice"></div>
+ <h4 class="title-lg modal-title-roadblock" style="font-size: 38px;">We'd Like You to Like Us</h4>
+ <div class="subtitle-md">Like Atlas Obscura and get our latest and greatest stories in your Facebook feed.
+ </div>
+ <fieldset class="modal-fieldset">
+ <div id="fb-modal-like-wrap"></div>
+ </fieldset>
+ <footer class="roadblock-footer">
+ <a href="" class="roadblock-dismiss-link cta-lg" data-dismiss="modal">No Thanks</a>
+ </footer>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="cookie-consent-modal" class="modal" data-backdrop="static" data-keyboard="false" role="dialog">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-wrap modal-wrap-topographic topographic modal-wrap-responsive">
+ <div class="row modal-bg roadblock-modal-content">
+ <div class="modal-body">
+ <h6 class="title-md baseline-near baseline-mobile">We value your privacy</h6>
+ <p style="margin-bottom: 21px;">Atlas Obscura and our trusted partners use technology such as cookies on
+ our website to personalise ads, support social media features, and analyse our traffic. Please click
+ below to consent to the use of this technology while browsing our site. To learn more or withdraw
+ consent, please visit our <a target="_blank" href="/privacy">privacy policy</a>.</p>
+ <div>
+ <a href="javascript:void(0)" class="btn btn-submit js-cookie-consent-accept" data-dismiss="modal">I
+ Accept</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="fixed-notice-m" class="js-notice">
+ <div class="notice">
+ <i class="icon-info"></i>
+ <span id="fixed-notice-m-text" class="flash-message"></span>
+ <i class="icon-menu-close js-dismiss-notice"></i>
+ </div>
+ </div>
+ <noscript>
+ <img src="https://sb.scorecardresearch.com/p?c1=2&c2=17564338&cv=2.0&cj=1" />
+ </noscript>
+
+
+ <noscript>
+ <div style="display:none;"><img src="//pixel.quantserve.com/pixel/p-wCQ2x-2BzmYPY.gif" border="0" height="1"
+ width="1" alt="Quantcast" /></div>
+ </noscript>
+
+ <div id="parsely-root" style="display: none">
+ <span id="parsely-cfg" data-parsely-site="atlasobscura.com"></span>
+ </div>
+
+ <svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <symbol id="icon-aoc-cancel" viewBox="0 0 24 24">
+ <title>aoc-cancel</title>
+ <path
+ d="M12 2c-5.53 0-10 4.47-10 10s4.47 10 10 10c5.53 0 10-4.47 10-10s-4.47-10-10-10v0zM17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59 3.59 3.59z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-video" viewBox="0 0 24 24">
+ <title>aoc-video</title>
+ <path
+ d="M10 16.5l6-4.5-6-4.5v9zM12 2c-5.52 0-10 4.48-10 10s4.48 10 10 10c5.52 0 10-4.48 10-10s-4.48-10-10-10v0zM12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8c4.41 0 8 3.59 8 8s-3.59 8-8 8v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-building" viewBox="0 0 24 24">
+ <title>aoc-building</title>
+ <path
+ d="M16 8.050c-0.096 0.098-0.187 0.202-0.272 0.312-1.061 0.455-1.911 1.305-2.366 2.366-0.129 0.099-0.25 0.207-0.362 0.322v-0.050h-2v2h1.036c-0.024 0.164-0.036 0.331-0.036 0.5 0 1.883 1.487 3.419 3.351 3.497 0.2 0.177 0.418 0.334 0.649 0.468v4.535h-5v-6h-4v6h-5v-20h14v6.050zM5 11v2h2v-2h-2zM5 7v2h2v-2h-2zM8 7v2h2v-2h-2zM8 11v2h2v-2h-2zM11 7v2h2v-2h-2zM17 16.829c-0.485-0.171-0.913-0.464-1.247-0.842-0.083 0.008-0.167 0.013-0.253 0.013-1.381 0-2.5-1.119-2.5-2.5 0-0.898 0.474-1.686 1.185-2.127 0.349-1.027 1.161-1.839 2.188-2.188 0.441-0.711 1.228-1.185 2.127-1.185 1.381 0 2.5 1.119 2.5 2.5 0 0.185-0.020 0.365-0.058 0.539 1.17 0.209 2.058 1.231 2.058 2.461 0 1.381-1.119 2.5-2.5 2.5-0.085 0-0.17-0.004-0.253-0.013-0.334 0.378-0.762 0.67-1.247 0.842v5.171h-2v-5.171z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-clock" viewBox="0 0 24 24">
+ <title>aoc-clock</title>
+ <path
+ d="M11.99 2c5.53 0 10.010 4.48 10.010 10s-4.48 10-10.010 10c-5.52 0-9.99-4.48-9.99-10s4.47-10 9.99-10zM13 11.423v-5.423c0-0.552-0.448-1-1-1s-1 0.448-1 1v6.577l3.964 2.289c0.478 0.276 1.090 0.112 1.366-0.366s0.112-1.090-0.366-1.366l-2.964-1.711z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-clipboard" viewBox="0 0 19 24">
+ <title>aoc-clipboard</title>
+ <path
+ d="M12.984 2.4c-0.504-1.392-1.824-2.4-3.384-2.4s-2.88 1.008-3.384 2.4h-3.816c-1.32 0-2.4 1.080-2.4 2.4v16.8c0 1.32 1.080 2.4 2.4 2.4h14.4c1.32 0 2.4-1.080 2.4-2.4v-16.8c0-1.32-1.080-2.4-2.4-2.4h-3.816zM10.8 3.6c0 0.66-0.54 1.2-1.2 1.2s-1.2-0.54-1.2-1.2c0-0.66 0.54-1.2 1.2-1.2s1.2 0.54 1.2 1.2zM4.804 20.4c-0.665 0-1.204-0.533-1.204-1.2v0c0-0.663 0.525-1.2 1.204-1.2h5.993c0.665 0 1.204 0.533 1.204 1.2v0c0 0.663-0.525 1.2-1.204 1.2h-5.993zM4.794 15.6c-0.66 0-1.194-0.533-1.194-1.2v0c0-0.663 0.547-1.2 1.194-1.2h9.611c0.66 0 1.194 0.533 1.194 1.2v0c0 0.663-0.547 1.2-1.194 1.2h-9.611zM4.794 10.8c-0.66 0-1.194-0.533-1.194-1.2v0c0-0.663 0.547-1.2 1.194-1.2h9.611c0.66 0 1.194 0.533 1.194 1.2v0c0 0.663-0.547 1.2-1.194 1.2h-9.611z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-help" viewBox="0 0 24 24">
+ <title>aoc-help</title>
+ <path
+ d="M12 1c6.072 0 11 4.928 11 11s-4.928 11-11 11c-6.072 0-11-4.928-11-11s4.928-11 11-11zM12 19.5c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM7.6 8.7c0 0.608 0.492 1.1 1.1 1.1s1.1-0.492 1.1-1.1c0-1.21 0.99-2.2 2.2-2.2s2.2 0.99 2.2 2.2c0 0.605-0.242 1.155-0.649 1.551l-1.364 1.386c-0.67 0.679-1.285 1.592-1.285 2.563h-0.002c0 0.608 0.492 1.1 1.1 1.1s1.1-0.492 1.1-1.1c0.067-1.003 0.7-1.417 1.287-2.013l0.99-1.012c0.627-0.627 1.023-1.507 1.023-2.475 0-2.431-1.969-4.4-4.4-4.4s-4.4 1.969-4.4 4.4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-right" viewBox="0 0 24 24">
+ <title>aoc-arrow-right</title>
+ <path d="M9.295 7.115l1.41-1.41 6 6-6 6-1.41-1.41 4.58-4.59z"></path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-left" viewBox="0 0 24 24">
+ <title>aoc-arrow-left</title>
+ <path d="M7.295 11.705l6-6 1.41 1.41-4.58 4.59 4.58 4.59-1.41 1.41z"></path>
+ </symbol>
+ <symbol id="icon-aoc-ticket" viewBox="0 0 24 24">
+ <title>aoc-ticket</title>
+ <path
+ d="M19.778 12.707l-8.485-8.485 2.828-2.828c0.391-0.391 1.024-0.391 1.414 0l2.311 2.311c-0.005 0.004-0.009 0.009-0.013 0.013-0.71 0.71-0.743 1.828-0.073 2.498s1.788 0.637 2.498-0.073c0.004-0.004 0.009-0.009 0.013-0.013l2.336 2.336c0.391 0.391 0.391 1.024 0 1.414l-2.828 2.828zM19.071 13.414l-9.192 9.192c-0.391 0.391-1.024 0.391-1.414 0l-2.336-2.336c0.697-0.711 0.725-1.819 0.060-2.484s-1.774-0.637-2.484 0.060l-2.311-2.311c-0.391-0.391-0.391-1.024 0-1.414l9.192-9.192 8.485 8.485zM5.636 14.121c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l4.95-4.95c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-4.95 4.95zM8.464 16.95c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l4.95-4.95c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-4.95 4.95z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-place-entry" viewBox="0 0 24 24">
+ <title>aoc-place-entry</title>
+ <path
+ d="M18 8c0-3.31-2.69-6-6-6s-6 2.69-6 6c0 4.5 6 11 6 11s6-6.5 6-11v0zM10 8c0-1.1 0.9-2 2-2s2 0.9 2 2c0 1.1-0.89 2-2 2-1.1 0-2-0.9-2-2v0zM5 20v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-facebook" viewBox="0 0 24 24">
+ <title>aoc-facebook</title>
+ <path
+ d="M20.895 2h-17.789c-0.61 0-1.105 0.495-1.105 1.105v17.789c0 0.61 0.495 1.105 1.105 1.105h9.579v-7.719h-2.614v-3.042h2.6v-2.221c0-2.586 1.575-3.993 3.881-3.993 0.783 0.002 1.566 0.047 2.344 0.133v2.698h-1.593c-1.253 0-1.495 0.593-1.495 1.467v1.93h3l-0.389 3.028h-2.611v7.719h5.088c0.61 0 1.105-0.495 1.105-1.105v-17.789c0-0.61-0.495-1.105-1.105-1.105z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-instagram" viewBox="0 0 24 24">
+ <title>aoc-instagram</title>
+ <path
+ d="M12 2c2.716 0 3.056 0.012 4.123 0.060 1.064 0.049 1.791 0.218 2.427 0.465 0.658 0.256 1.215 0.597 1.771 1.153s0.898 1.114 1.153 1.771c0.247 0.636 0.416 1.363 0.465 2.427 0.049 1.067 0.060 1.407 0.060 4.123s-0.012 3.056-0.060 4.123c-0.049 1.064-0.218 1.791-0.465 2.427-0.256 0.658-0.597 1.215-1.153 1.771s-1.114 0.898-1.771 1.153c-0.636 0.247-1.363 0.416-2.427 0.465-1.067 0.049-1.407 0.060-4.123 0.060s-3.056-0.012-4.123-0.060c-1.064-0.049-1.791-0.218-2.427-0.465-0.658-0.256-1.215-0.597-1.771-1.153s-0.898-1.114-1.153-1.771c-0.247-0.636-0.416-1.363-0.465-2.427-0.049-1.067-0.060-1.407-0.060-4.123s0.012-3.056 0.060-4.123c0.049-1.064 0.218-1.791 0.465-2.427 0.256-0.658 0.597-1.215 1.153-1.771s1.114-0.898 1.771-1.153c0.636-0.247 1.363-0.416 2.427-0.465 1.067-0.049 1.407-0.060 4.123-0.060zM12 3.802c-2.67 0-2.986 0.010-4.041 0.058-0.975 0.044-1.504 0.207-1.857 0.344-0.467 0.181-0.8 0.398-1.15 0.748s-0.567 0.683-0.748 1.15c-0.137 0.352-0.3 0.882-0.344 1.857-0.048 1.054-0.058 1.371-0.058 4.041s0.010 2.986 0.058 4.041c0.044 0.975 0.207 1.504 0.344 1.857 0.181 0.467 0.398 0.8 0.748 1.15s0.683 0.567 1.15 0.748c0.352 0.137 0.882 0.3 1.857 0.344 1.054 0.048 1.371 0.058 4.041 0.058s2.987-0.010 4.041-0.058c0.975-0.044 1.504-0.207 1.857-0.344 0.467-0.181 0.8-0.398 1.15-0.748s0.567-0.683 0.748-1.15c0.137-0.352 0.3-0.882 0.344-1.857 0.048-1.054 0.058-1.371 0.058-4.041s-0.010-2.986-0.058-4.041c-0.044-0.975-0.207-1.504-0.344-1.857-0.181-0.467-0.398-0.8-0.748-1.15s-0.683-0.567-1.15-0.748c-0.352-0.137-0.882-0.3-1.857-0.344-1.054-0.048-1.371-0.058-4.041-0.058zM12 6.865c2.836 0 5.135 2.299 5.135 5.135s-2.299 5.135-5.135 5.135c-2.836 0-5.135-2.299-5.135-5.135s2.299-5.135 5.135-5.135zM12 15.333c1.841 0 3.333-1.492 3.333-3.333s-1.492-3.333-3.333-3.333c-1.841 0-3.333 1.492-3.333 3.333s1.492 3.333 3.333 3.333zM18.538 6.662c0 0.663-0.537 1.2-1.2 1.2s-1.2-0.537-1.2-1.2 0.537-1.2 1.2-1.2c0.663 0 1.2 0.537 1.2 1.2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-reddit" viewBox="0 0 24 24">
+ <title>aoc-reddit</title>
+ <path
+ d="M22.999 12.034c0-1.397-1.137-2.534-2.534-2.534-0.621 0-1.208 0.225-1.67 0.632-1.648-1.054-3.834-1.732-6.252-1.823l1.441-4.096 3.601 0.861c0.002 1.139 0.929 2.065 2.069 2.065 1.141 0 2.069-0.928 2.069-2.069s-0.929-2.069-2.069-2.069c-0.866 0-1.609 0.536-1.917 1.292l-4.265-1.020-1.771 5.030c-2.519 0.048-4.803 0.729-6.512 1.816-0.46-0.399-1.040-0.618-1.655-0.618-1.397 0-2.534 1.137-2.534 2.534 0 0.864 0.445 1.661 1.166 2.126-0.044 0.253-0.069 0.509-0.069 0.77 0 3.658 4.434 6.634 9.884 6.634s9.884-2.976 9.884-6.634c0-0.253-0.023-0.502-0.064-0.748 0.74-0.461 1.199-1.27 1.199-2.148l-0.001-0.001zM7.283 13.759c0-0.86 0.697-1.557 1.558-1.557 0.86 0 1.557 0.697 1.557 1.557 0 0.86-0.697 1.557-1.557 1.557-0.861 0-1.558-0.697-1.558-1.557zM15.652 17.971c-0.046 0.049-1.164 1.185-3.689 1.185-2.538 0-3.554-1.153-3.595-1.202-0.143-0.167-0.124-0.419 0.044-0.562 0.166-0.142 0.415-0.124 0.559 0.041 0.023 0.025 0.87 0.926 2.993 0.926 2.16 0 3.107-0.933 3.116-0.942 0.153-0.156 0.404-0.16 0.562-0.006s0.162 0.401 0.011 0.56l0.001-0.001zM15.342 15.316c-0.861 0-1.558-0.697-1.558-1.557s0.697-1.557 1.558-1.557c0.86 0 1.557 0.697 1.557 1.557 0 0.86-0.697 1.557-1.557 1.557z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-rss" viewBox="0 0 24 24">
+ <title>aoc-rss</title>
+ <path
+ d="M3 2c-0.552 0-1 0.448-1 1v18c0 0.552 0.448 1 1 1h18c0.552 0 1-0.448 1-1v-18c0-0.552-0.448-1-1-1h-18zM14.083 18.25h-2.381c0-3.282-2.671-5.952-5.953-5.952v-2.381c4.595 0 8.333 3.738 8.333 8.333zM18.25 18.25h-2.381c0-5.58-4.539-10.119-10.119-10.119v-2.381c6.892 0 12.5 5.608 12.5 12.5zM5.75 16.464c0-0.986 0.8-1.786 1.786-1.786s1.786 0.8 1.786 1.786c0 0.986-0.8 1.786-1.786 1.786s-1.786-0.8-1.786-1.786z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-twitter" viewBox="0 0 24 24">
+ <title>aoc-twitter</title>
+ <path
+ d="M23 6.072c-0.772 0.351-1.602 0.589-2.474 0.695 0.89-0.546 1.573-1.412 1.895-2.443-0.833 0.506-1.754 0.873-2.738 1.071-0.784-0.858-1.904-1.394-3.144-1.394-2.378 0-4.307 1.978-4.307 4.418 0 0.346 0.037 0.683 0.111 1.006-3.581-0.185-6.755-1.941-8.881-4.617-0.371 0.655-0.583 1.414-0.583 2.223 0 1.532 0.761 2.884 1.917 3.677-0.705-0.021-1.371-0.222-1.952-0.551v0.054c0 2.141 1.485 3.927 3.457 4.332-0.361 0.104-0.742 0.155-1.135 0.155-0.277 0-0.549-0.027-0.811-0.078 0.549 1.754 2.139 3.032 4.024 3.066-1.474 1.186-3.333 1.892-5.351 1.892-0.348 0-0.692-0.020-1.028-0.061 1.907 1.251 4.172 1.983 6.604 1.983 7.926 0 12.258-6.731 12.258-12.569 0-0.192-0.004-0.384-0.011-0.573 0.842-0.623 1.573-1.401 2.148-2.287z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-accommodation" viewBox="0 0 24 24">
+ <title>aoc-accommodation</title>
+ <path
+ d="M23 12v1h-17c-0.552 0-1-0.448-1-1s0.448-1 1-1h4v-2.834c0-0.552 0.448-1 1-1 0.051 0 0.102 0.004 0.152 0.012l11 1.692c0.488 0.075 0.848 0.495 0.848 0.988v2.142zM4 14h19v6h-3v-3h-16v3h-3v-15c0-0.552 0.448-1 1-1h1c0.552 0 1 0.448 1 1v9zM7 10c-1.105 0-2-0.895-2-2s0.895-2 2-2c1.105 0 2 0.895 2 2s-0.895 2-2 2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-activity-level" viewBox="0 0 24 24">
+ <title>aoc-activity-level</title>
+ <path
+ d="M22.994 17.99h-7.239c-0.366 0-0.72-0.134-0.994-0.377l-11.172-9.883 3.409-4.016c0.422-0.557 0.839-0.788 1.251-0.694 0.619 0.141 0.619 2.349 2.249 3.47 0.909 0.625 1.601 0.94 5 0.5 0.889 1.249 2.099 5.976 3.050 6.747s4.447 1.234 4.447 3.753v0.5zM22.994 18.99v1c0 0.552-0.448 1-1 1l-6.864-0c-0.73 0-1.435-0.266-1.983-0.749l-10.808-9.522c-0.409-0.36-0.454-0.982-0.101-1.397l0.704-0.829 11.157 9.87c0.457 0.404 1.046 0.628 1.656 0.628h7.239z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-a-photo" viewBox="0 0 24 24">
+ <title>aoc-add-a-photo</title>
+ <path
+ d="M3 4v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3zM6 10v-3h3v-3h7l1.83 2h3.17c1.1 0 2 0.9 2 2v12c0 1.1-0.9 2-2 2h-16c-1.1 0-2-0.9-2-2v-10h3zM13 19c2.76 0 5-2.24 5-5s-2.24-5-5-5c-2.76 0-5 2.24-5 5s2.24 5 5 5v0zM9.8 14c0 1.77 1.43 3.2 3.2 3.2s3.2-1.43 3.2-3.2c0-1.77-1.43-3.2-3.2-3.2s-3.2 1.43-3.2 3.2v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-box" viewBox="0 0 24 24">
+ <title>aoc-add-box</title>
+ <path
+ d="M19 3h-14c-1.11 0-2 0.9-2 2v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2v-14c0-1.1-0.9-2-2-2v0zM17 13h-4v4h-2v-4h-4v-2h4v-4h2v4h4v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-shape" viewBox="0 0 24 24">
+ <title>aoc-add-shape</title>
+ <path d="M19 13h-6v6h-2v-6h-6v-2h6v-6h2v6h6z"></path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-forward" viewBox="0 0 24 24">
+ <title>aoc-arrow-forward</title>
+ <path d="M12 4l-1.41 1.41 5.58 5.59h-12.17v2h12.17l-5.58 5.59 1.41 1.41 8-8z"></path>
+ </symbol>
+ <symbol id="icon-aoc-been-here" viewBox="0 0 24 24">
+ <title>aoc-been-here</title>
+ <path d="M7 20h-2l-1-16h2l1 16zM8.625 15l-0.625-10h12l-3 5 3 5h-11.375z"></path>
+ </symbol>
+ <symbol id="icon-aoc-chat-bubbles" viewBox="0 0 24 24">
+ <title>aoc-chat-bubbles</title>
+ <path
+ d="M21 6h-2v9h-13v2c0 0.55 0.45 1 1 1h11l4 4v-15c0-0.55-0.45-1-1-1v0zM17 12v-9c0-0.55-0.45-1-1-1h-13c-0.55 0-1 0.45-1 1v14l4-4h10c0.55 0 1-0.45 1-1v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-close" viewBox="0 0 24 24">
+ <title>aoc-close</title>
+ <path
+ d="M19 6.41l-1.41-1.41-5.59 5.59-5.59-5.59-1.41 1.41 5.59 5.59-5.59 5.59 1.41 1.41 5.59-5.59 5.59 5.59 1.41-1.41-5.59-5.59z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-expand-more" viewBox="0 0 24 24">
+ <title>aoc-expand-more</title>
+ <path d="M16.59 9l-4.59 4.58-4.59-4.58-1.41 1.41 6 6 6-6z"></path>
+ </symbol>
+ <symbol id="icon-aoc-expand-less" viewBox="0 0 24 24">
+ <title>aoc-expand-less</title>
+ <path d="M12 8l-6 6 1.41 1.41 4.59-4.58 4.59 4.58 1.41-1.41z"></path>
+ </symbol>
+ <symbol id="icon-aoc-forum-flag" viewBox="0 0 24 24">
+ <title>aoc-forum-flag</title>
+ <path
+ d="M6 15v6c0 0.552-0.448 1-1 1s-1-0.448-1-1v-18c0-0.552 0.448-1 1-1s1 0.448 1 1h14l-3 6 3 6h-14zM16.764 5h-10.764v8h10.764l-2-4 2-4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-group-size" viewBox="0 0 24 24">
+ <title>aoc-group-size</title>
+ <path
+ d="M12 3c1.812 0 3.281 1.469 3.281 3.281s-1.469 3.281-3.281 3.281c-1.812 0-3.281-1.469-3.281-3.281s1.469-3.281 3.281-3.281zM6.292 10.178c-0.345 0.519-0.542 1.139-0.542 1.797v5.025h-4c-0.414 0-0.75-0.336-0.75-0.75v-5.266c0-0.688 0.468-1.288 1.136-1.455l0.71-0.178c0.582 0.63 1.416 1.024 2.341 1.024 0.388 0 0.76-0.069 1.104-0.197zM17.488 9.885c0.492 0.31 1.075 0.49 1.699 0.49 0.888 0 1.691-0.363 2.269-0.948l0.408 0.102c0.668 0.167 1.136 0.767 1.136 1.455v5.266c0 0.414-0.336 0.75-0.75 0.75h-4v-5.025c0-0.787-0.282-1.52-0.762-2.091zM19.188 9.375c-1.208 0-2.187-0.979-2.187-2.187s0.979-2.187 2.187-2.187c1.208 0 2.187 0.979 2.187 2.187s-0.979 2.187-2.187 2.187zM5.187 9.375c-1.208 0-2.187-0.979-2.187-2.187s0.979-2.187 2.187-2.187c1.208 0 2.187 0.979 2.187 2.187s-0.979 2.187-2.187 2.187zM9.279 9.587c0.74 0.61 1.688 0.976 2.721 0.976s1.981-0.366 2.721-0.976l0.824 0.206c1.002 0.25 1.704 1.15 1.704 2.183v6.9c0 0.621-0.504 1.125-1.125 1.125h-8.25c-0.621 0-1.125-0.504-1.125-1.125v-6.9c0-1.032 0.703-1.932 1.704-2.183l0.824-0.206z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-heart-outline" viewBox="0 0 24 24">
+ <title>aoc-heart-outline</title>
+ <path
+ d="M18.91 11.473c0.697-0.71 1.090-1.677 1.090-2.689s-0.394-1.979-1.091-2.689c-0.689-0.703-1.62-1.095-2.587-1.095s-1.897 0.393-2.587 1.095l-1.736 1.768-1.736-1.768c-1.433-1.46-3.741-1.46-5.174 0-1.454 1.481-1.454 3.897-0.031 5.347l6.941 6.765 6.91-6.734zM11.375 4.395l0.625 0.613 0.623-0.611c1.026-0.898 2.338-1.397 3.7-1.397 1.506 0 2.95 0.61 4.015 1.695s1.663 2.556 1.663 4.090-0.598 3.006-1.663 4.090l-8.337 8.125-8.337-8.125c-2.217-2.259-2.217-5.921 0-8.18 2.114-2.154 5.481-2.254 7.712-0.3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-heart-solid" viewBox="0 0 24 24">
+ <title>aoc-heart-solid</title>
+ <path
+ d="M11.375 4.395l0.625 0.613 0.623-0.611c1.026-0.898 2.338-1.397 3.7-1.397 1.506 0 2.95 0.61 4.015 1.695s1.663 2.556 1.663 4.090-0.598 3.006-1.663 4.090l-8.337 8.125-8.337-8.125c-2.217-2.259-2.217-5.921 0-8.18 2.114-2.154 5.481-2.254 7.712-0.3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-home" viewBox="0 0 24 24">
+ <title>aoc-home</title>
+ <path d="M15 22v-5c0-1.657-1.343-3-3-3s-3 1.343-3 3v5h-5v-11h-2v-1l9.995-8 10.005 8v1h-2v11h-5z"></path>
+ </symbol>
+ <symbol id="icon-aoc-important" viewBox="0 0 24 24">
+ <title>aoc-important</title>
+ <path
+ d="M21.775 18.469c0.641 1.125-0.164 2.531-1.444 2.531h-16.663c-1.283 0-2.083-1.408-1.444-2.531l8.331-14.626c0.641-1.125 2.247-1.123 2.887 0l8.331 14.626zM12 8c-0.552 0-1 0.448-1 1v5c0 0.552 0.448 1 1 1s1-0.448 1-1v-5c0-0.552-0.448-1-1-1zM12 19c0.552 0 1-0.448 1-1s-0.448-1-1-1c-0.552 0-1 0.448-1 1s0.448 1 1 1z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-knife-fork" viewBox="0 0 24 24">
+ <title>aoc-knife-fork</title>
+ <path
+ d="M9.25 2.75v4.625c0 0.345 0.28 0.625 0.625 0.625s0.625-0.28 0.625-0.625v-4.625c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v6.25c0 1.306-0.835 2.417-2 2.829v8.671c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5v-8.671c-1.165-0.412-2-1.523-2-2.829v-6.25c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v4.625c0 0.345 0.28 0.625 0.625 0.625s0.625-0.28 0.625-0.625v-4.625c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75zM19 1v19.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5v-7.5h-1c-0.552 0-1-0.448-1-1v-6c0-2.761 2.239-5 5-5z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-library-books" viewBox="0 0 24 24">
+ <title>aoc-library-books</title>
+ <path
+ d="M4 6h-2v14c0 1.1 0.9 2 2 2h14v-2h-14v-14zM20 2h-12c-1.1 0-2 0.9-2 2v12c0 1.1 0.9 2 2 2h12c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2v0zM19 11h-10v-2h10v2zM15 15h-6v-2h6v2zM19 7h-10v-2h10v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-link" viewBox="0 0 24 24">
+ <title>aoc-link</title>
+ <path
+ d="M9.937 12.2c0.441-0.33 1.067-0.24 1.397 0.201 0.542 0.724 1.372 1.178 2.274 1.242s1.788-0.266 2.428-0.906l2.451-2.451c1.187-1.229 1.171-3.173-0.032-4.377-1.201-1.201-3.146-1.221-4.355-0.053l-1.421 1.413c-0.391 0.389-1.023 0.387-1.412-0.004s-0.387-1.023 0.004-1.412l1.431-1.423c2.002-1.934 5.192-1.904 7.164 0.067 1.975 1.975 1.998 5.165 0.044 7.187l-2.463 2.463c-1.049 1.049-2.502 1.591-3.982 1.485s-2.841-0.85-3.73-2.038c-0.33-0.441-0.24-1.067 0.201-1.397zM14.425 12.152c-0.441 0.33-1.067 0.24-1.397-0.201-0.542-0.724-1.372-1.178-2.274-1.242s-1.788 0.266-2.428 0.906l-2.451 2.451c-1.187 1.229-1.171 3.173 0.032 4.377 1.201 1.201 3.145 1.221 4.352 0.056l1.414-1.414c0.39-0.39 1.022-0.39 1.412 0s0.39 1.022-0 1.412l-1.426 1.426c-2.002 1.933-5.191 1.903-7.163-0.068-1.975-1.975-1.998-5.165-0.044-7.187l2.463-2.463c1.049-1.049 2.502-1.591 3.982-1.485s2.841 0.85 3.73 2.038c0.33 0.441 0.24 1.067-0.201 1.397z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-list-circle-bullets" viewBox="0 0 24 24">
+ <title>aoc-list-circle-bullets</title>
+ <path
+ d="M4 10.5c-0.83 0-1.5 0.67-1.5 1.5s0.67 1.5 1.5 1.5c0.83 0 1.5-0.67 1.5-1.5s-0.67-1.5-1.5-1.5v0zM4 4.5c-0.83 0-1.5 0.67-1.5 1.5s0.67 1.5 1.5 1.5c0.83 0 1.5-0.67 1.5-1.5s-0.67-1.5-1.5-1.5v0zM4 16.5c-0.835 0-1.5 0.677-1.5 1.5s0.677 1.5 1.5 1.5c0.823 0 1.5-0.677 1.5-1.5s-0.665-1.5-1.5-1.5v0zM7 19h14v-2h-14v2zM7 13h14v-2h-14v2zM7 5v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-list" viewBox="0 0 24 24">
+ <title>aoc-list</title>
+ <path d="M3 13h2v-2h-2v2zM3 17h2v-2h-2v2zM3 9h2v-2h-2v2zM7 13h14v-2h-14v2zM7 17h14v-2h-14v2zM7 7v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-location-add" viewBox="0 0 24 24">
+ <title>aoc-location-add</title>
+ <path
+ d="M12 2c-3.86 0-7 3.14-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.86-3.14-7-7-7v0zM16 10h-3v3h-2v-3h-3v-2h3v-3h2v3h3v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-location" viewBox="0 0 24 24">
+ <title>aoc-location</title>
+ <path
+ d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7v0zM12 11.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5c1.38 0 2.5 1.12 2.5 2.5s-1.12 2.5-2.5 2.5v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-mail" viewBox="0 0 24 24">
+ <title>aoc-mail</title>
+ <path
+ d="M20 4h-16c-1.1 0-1.99 0.9-1.99 2l-0.010 12c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2v0zM20 8l-8 5-8-5v-2l8 5 8-5v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-map" viewBox="0 0 24 24">
+ <title>aoc-map</title>
+ <path
+ d="M20.5 3l-0.16 0.030-5.34 2.070-6-2.1-5.64 1.9c-0.21 0.070-0.36 0.25-0.36 0.48v15.12c0 0.28 0.22 0.5 0.5 0.5l0.16-0.030 5.34-2.070 6 2.1 5.64-1.9c0.21-0.070 0.36-0.25 0.36-0.48v-15.12c0-0.28-0.22-0.5-0.5-0.5v0zM15 19l-6-2.11v-11.89l6 2.11v11.89z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-menu" viewBox="0 0 24 24">
+ <title>aoc-menu</title>
+ <path d="M3 5h18v2h-18v-2zM3 11h18v2h-18v-2zM3 17h18v2h-18v-2z"></path>
+ </symbol>
+ <symbol id="icon-aoc-more-horizontal" viewBox="0 0 24 24">
+ <title>aoc-more-horizontal</title>
+ <path
+ d="M6 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0zM18 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0zM12 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-my-location" viewBox="0 0 24 24">
+ <title>aoc-my-location</title>
+ <path
+ d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4c2.21 0 4-1.79 4-4s-1.79-4-4-4v0zM20.94 11c-0.46-4.17-3.77-7.48-7.94-7.94v-2.060h-2v2.060c-4.17 0.46-7.48 3.77-7.94 7.94h-2.060v2h2.060c0.46 4.17 3.77 7.48 7.94 7.94v2.060h2v-2.060c4.17-0.46 7.48-3.77 7.94-7.94h2.060v-2h-2.060zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7c3.87 0 7 3.13 7 7s-3.13 7-7 7v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-near-me" viewBox="0 0 24 24">
+ <title>aoc-near-me</title>
+ <path d="M21 3l-18 7.53v0.98l6.84 2.65 2.64 6.84h0.98z"></path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-alert" viewBox="0 0 24 24">
+ <title>aoc-notifications-alert</title>
+ <path
+ d="M18.988 10.589c0.008 0.136 0.012 0.273 0.012 0.411v5l2 1v2h-18v-2l2-1v-5c0-3.526 2.608-6.444 6-6.929v-1.071c0-0.552 0.448-1 1-1s1 0.447 1 1c-0.628 0.836-1 1.875-1 3 0 2.761 2.239 5 5 5 0.707 0 1.379-0.147 1.988-0.411zM10 20h4c0 1.105-0.895 2-2 2s-2-0.895-2-2zM17 9c-1.657 0-3-1.343-3-3s1.343-3 3-3c1.657 0 3 1.343 3 3s-1.343 3-3 3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-mentions" viewBox="0 0 24 24">
+ <title>aoc-notifications-mentions</title>
+ <path
+ d="M15.52 15.487c-0.585 0.857-1.949 1.786-3.776 1.786-3.094 0-5.189-2.154-5.189-5.164 0-2.937 2.144-5.237 5.092-5.237 1.486 0 2.704 0.587 3.265 1.297v-1.126h2.12v6.534c0 1.126 0.39 1.835 1.535 1.835 1.34 0 2.388-1.786 2.388-4.601 0-4.943-3.411-7.978-8.771-7.978-5.677 0-9.039 4.185-9.039 9.201 0 5.555 3.898 9.103 9.623 9.103 2.68 0 4.848-1.003 6.383-2.349l1.121 1.37c-1.437 1.444-4.166 2.839-7.553 2.839-7.358 0-11.719-4.748-11.719-10.914 0-6.412 4.629-11.086 11.256-11.086 6.797 0 10.744 4.283 10.744 9.715 0 4.209-1.998 6.534-4.653 6.534-1.657 0-2.509-0.832-2.826-1.762zM8.75 12.011c0 1.747 1.19 2.989 2.929 2.989s3.071-1.149 3.071-2.989c0-1.839-1.357-3.011-3.095-3.011s-2.905 1.241-2.905 3.011z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-muted" viewBox="0 0 24 24">
+ <title>aoc-notifications-muted</title>
+ <path
+ d="M13 4.071c3.392 0.485 6 3.403 6 6.929v5l2 1v2h-18v-2l2-1v-5c0-3.526 2.608-6.444 6-6.929v-1.071c0-0.552 0.448-1 1-1s1 0.448 1 1v1.071zM10 20h4c0 1.105-0.895 2-2 2s-2-0.895-2-2zM13.407 11.993l2.828-2.828-1.414-1.414-2.828 2.828-2.828-2.828-1.414 1.414 2.828 2.828-2.828 2.828 1.414 1.414 2.828-2.828 2.828 2.828 1.414-1.414-2.828-2.828z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-tracking" viewBox="0 0 24 24">
+ <title>aoc-notifications-tracking</title>
+ <path
+ d="M19 9.9c0.323 0.066 0.658 0.1 1 0.1s0.677-0.034 1-0.1v10.1c0 1.105-0.895 2-2 2h-14c-1.105 0-2-0.895-2-2v-14c0-1.105 0.895-2 2-2h10.1c-0.066 0.323-0.1 0.658-0.1 1s0.034 0.677 0.1 1h-10.1v14h14v-10.1zM20 8c-1.657 0-3-1.343-3-3s1.343-3 3-3c1.657 0 3 1.343 3 3s-1.343 3-3 3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-open-in-new" viewBox="0 0 24 24">
+ <title>aoc-open-in-new</title>
+ <path
+ d="M19 19h-14v-14h7v-2h-7c-1.11 0-2 0.9-2 2v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41 9.83-9.83v3.59h2v-7h-7z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-pencil" viewBox="0 0 24 24">
+ <title>aoc-pencil</title>
+ <path
+ d="M3 17.25v3.75h3.75l11.060-11.060-3.75-3.75-11.060 11.060zM20.71 7.040c0.39-0.39 0.39-1.020 0-1.41l-2.34-2.34c-0.39-0.39-1.020-0.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-person" viewBox="0 0 24 24">
+ <title>aoc-person</title>
+ <path
+ d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4c-2.21 0-4 1.79-4 4s1.79 4 4 4v0zM12 14c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-pinned" viewBox="0 0 24 24">
+ <title>aoc-pinned</title>
+ <path
+ d="M6.515 16.077l-4.223-4.223c1.774-1.774 4.266-2.391 6.541-1.853l5.516-4.667c-0.493-1.098-0.288-2.433 0.614-3.335l7.039 7.039c-0.902 0.902-2.236 1.106-3.335 0.614l-4.667 5.516c0.539 2.274-0.079 4.767-1.852 6.541l-4.223-4.223-4.223 4.223c-0.389 0.389-1.019 0.389-1.408 0s-0.389-1.019 0-1.408l4.223-4.223z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-plane-takeoff" viewBox="0 0 24 24">
+ <title>aoc-plane-takeoff</title>
+ <path
+ d="M2.5 19h19v2h-19v-2zM22.070 9.64c-0.21-0.8-1.040-1.28-1.84-1.060l-5.31 1.42-6.9-6.43-1.93 0.51 4.14 7.17-4.97 1.33-1.97-1.54-1.45 0.39 1.82 3.16 0.77 1.33 1.6-0.43 14.97-4c0.81-0.23 1.28-1.050 1.070-1.85v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-plane" viewBox="0 0 24 24">
+ <title>aoc-plane</title>
+ <path
+ d="M21 16v-2l-8-5v-5.5c0-0.83-0.67-1.5-1.5-1.5s-1.5 0.67-1.5 1.5v5.5l-8 5v2l8-2.5v5.5l-2 1.5v1.5l3.5-1 3.5 1v-1.5l-2-1.5v-5.5l8 2.5z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-print" viewBox="0 0 24 24">
+ <title>aoc-print</title>
+ <path
+ d="M19 8h-14c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3v0zM16 19h-8v-5h8v5zM19 12c-0.55 0-1-0.45-1-1s0.45-1 1-1c0.55 0 1 0.45 1 1s-0.45 1-1 1v0zM18 3h-12v4h12v-4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-reply" viewBox="0 0 24 24">
+ <title>aoc-reply</title>
+ <path d="M10 9v-4l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11v0z"></path>
+ </symbol>
+ <symbol id="icon-aoc-search" viewBox="0 0 24 24">
+ <title>aoc-search</title>
+ <path
+ d="M15.5 14h-0.79l-0.28-0.27c0.98-1.14 1.57-2.62 1.57-4.23 0-3.59-2.91-6.5-6.5-6.5s-6.5 2.91-6.5 6.5c0 3.59 2.91 6.5 6.5 6.5 1.61 0 3.090-0.59 4.23-1.57l0.27 0.28v0.79l5 4.99 1.49-1.49-4.99-5zM9.5 14c-2.49 0-4.5-2.010-4.5-4.5s2.010-4.5 4.5-4.5c2.49 0 4.5 2.010 4.5 4.5s-2.010 4.5-4.5 4.5v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-shuffle" viewBox="0 0 24 24">
+ <title>aoc-shuffle</title>
+ <path
+ d="M10.59 9.17l-5.18-5.17-1.41 1.41 5.17 5.17 1.42-1.41zM14.5 4l2.040 2.040-12.54 12.55 1.41 1.41 12.55-12.54 2.040 2.040v-5.5h-5.5zM14.83 13.41l-1.41 1.41 3.13 3.13-2.050 2.050h5.5v-5.5l-2.040 2.040-3.13-3.13z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-star" viewBox="0 0 24 24">
+ <title>aoc-star</title>
+ <path
+ d="M18.18 21l-1.635-7.029 5.455-4.727-7.191-0.617-2.809-6.627-2.809 6.627-7.191 0.617 5.455 4.727-1.635 7.029 6.18-3.728z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-subject" viewBox="0 0 24 24">
+ <title>aoc-subject</title>
+ <path d="M14 17h-10v2h10v-2zM20 9h-16v2h16v-2zM4 15h16v-2h-16v2zM4 5v2h16v-2h-16z"></path>
+ </symbol>
+ <symbol id="icon-aoc-trip-style" viewBox="0 0 24 24">
+ <title>aoc-trip-style</title>
+ <path
+ d="M20 6c1.11 0 2 0.89 2 2v11c0 1.11-0.89 2-2 2h-16c-1.11 0-2-0.89-2-2l0.010-11c0-1.11 0.88-2 1.99-2h4v-2c0-1.11 0.89-2 2-2h4c1.11 0 2 0.89 2 2v2h4zM14 6v-2h-4v2h4zM6.5 14c0.828 0 1.5-0.672 1.5-1.5s-0.672-1.5-1.5-1.5c-0.828 0-1.5 0.672-1.5 1.5s0.672 1.5 1.5 1.5zM6 16.042l0.347 1.97 5.909-1.042-0.347-1.97-5.909 1.042z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-unpinned" viewBox="0 0 24 24">
+ <title>aoc-unpinned</title>
+ <path
+ d="M5.416 12.15l6.433 6.433c0.362-0.93 0.439-1.959 0.203-2.955l-0.233-0.982 5.94-7.020-1.386-1.386-7.020 5.94-0.982-0.233c-0.996-0.236-2.025-0.16-2.955 0.203zM6.515 16.077l-4.223-4.223c1.774-1.774 4.266-2.391 6.541-1.853l5.516-4.667c-0.493-1.098-0.288-2.433 0.614-3.335l7.039 7.039c-0.902 0.902-2.236 1.106-3.335 0.614l-4.667 5.516c0.539 2.274-0.079 4.767-1.852 6.541l-4.223-4.223-4.223 4.223c-0.389 0.389-1.019 0.389-1.408 0s-0.389-1.019 0-1.408l4.223-4.223z">
+ </path>
+ </symbol>
+ </defs>
+ </svg>
+</body>
+<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <symbol id="icon-aoc-cancel" viewBox="0 0 24 24">
+ <title>aoc-cancel</title>
+ <path
+ d="M12 2c-5.53 0-10 4.47-10 10s4.47 10 10 10c5.53 0 10-4.47 10-10s-4.47-10-10-10v0zM17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59 3.59 3.59z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-video" viewBox="0 0 24 24">
+ <title>aoc-video</title>
+ <path
+ d="M10 16.5l6-4.5-6-4.5v9zM12 2c-5.52 0-10 4.48-10 10s4.48 10 10 10c5.52 0 10-4.48 10-10s-4.48-10-10-10v0zM12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8c4.41 0 8 3.59 8 8s-3.59 8-8 8v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-building" viewBox="0 0 24 24">
+ <title>aoc-building</title>
+ <path
+ d="M16 8.050c-0.096 0.098-0.187 0.202-0.272 0.312-1.061 0.455-1.911 1.305-2.366 2.366-0.129 0.099-0.25 0.207-0.362 0.322v-0.050h-2v2h1.036c-0.024 0.164-0.036 0.331-0.036 0.5 0 1.883 1.487 3.419 3.351 3.497 0.2 0.177 0.418 0.334 0.649 0.468v4.535h-5v-6h-4v6h-5v-20h14v6.050zM5 11v2h2v-2h-2zM5 7v2h2v-2h-2zM8 7v2h2v-2h-2zM8 11v2h2v-2h-2zM11 7v2h2v-2h-2zM17 16.829c-0.485-0.171-0.913-0.464-1.247-0.842-0.083 0.008-0.167 0.013-0.253 0.013-1.381 0-2.5-1.119-2.5-2.5 0-0.898 0.474-1.686 1.185-2.127 0.349-1.027 1.161-1.839 2.188-2.188 0.441-0.711 1.228-1.185 2.127-1.185 1.381 0 2.5 1.119 2.5 2.5 0 0.185-0.020 0.365-0.058 0.539 1.17 0.209 2.058 1.231 2.058 2.461 0 1.381-1.119 2.5-2.5 2.5-0.085 0-0.17-0.004-0.253-0.013-0.334 0.378-0.762 0.67-1.247 0.842v5.171h-2v-5.171z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-clock" viewBox="0 0 24 24">
+ <title>aoc-clock</title>
+ <path
+ d="M11.99 2c5.53 0 10.010 4.48 10.010 10s-4.48 10-10.010 10c-5.52 0-9.99-4.48-9.99-10s4.47-10 9.99-10zM13 11.423v-5.423c0-0.552-0.448-1-1-1s-1 0.448-1 1v6.577l3.964 2.289c0.478 0.276 1.090 0.112 1.366-0.366s0.112-1.090-0.366-1.366l-2.964-1.711z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-clipboard" viewBox="0 0 19 24">
+ <title>aoc-clipboard</title>
+ <path
+ d="M12.984 2.4c-0.504-1.392-1.824-2.4-3.384-2.4s-2.88 1.008-3.384 2.4h-3.816c-1.32 0-2.4 1.080-2.4 2.4v16.8c0 1.32 1.080 2.4 2.4 2.4h14.4c1.32 0 2.4-1.080 2.4-2.4v-16.8c0-1.32-1.080-2.4-2.4-2.4h-3.816zM10.8 3.6c0 0.66-0.54 1.2-1.2 1.2s-1.2-0.54-1.2-1.2c0-0.66 0.54-1.2 1.2-1.2s1.2 0.54 1.2 1.2zM4.804 20.4c-0.665 0-1.204-0.533-1.204-1.2v0c0-0.663 0.525-1.2 1.204-1.2h5.993c0.665 0 1.204 0.533 1.204 1.2v0c0 0.663-0.525 1.2-1.204 1.2h-5.993zM4.794 15.6c-0.66 0-1.194-0.533-1.194-1.2v0c0-0.663 0.547-1.2 1.194-1.2h9.611c0.66 0 1.194 0.533 1.194 1.2v0c0 0.663-0.547 1.2-1.194 1.2h-9.611zM4.794 10.8c-0.66 0-1.194-0.533-1.194-1.2v0c0-0.663 0.547-1.2 1.194-1.2h9.611c0.66 0 1.194 0.533 1.194 1.2v0c0 0.663-0.547 1.2-1.194 1.2h-9.611z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-help" viewBox="0 0 24 24">
+ <title>aoc-help</title>
+ <path
+ d="M12 1c6.072 0 11 4.928 11 11s-4.928 11-11 11c-6.072 0-11-4.928-11-11s4.928-11 11-11zM12 19.5c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25zM7.6 8.7c0 0.608 0.492 1.1 1.1 1.1s1.1-0.492 1.1-1.1c0-1.21 0.99-2.2 2.2-2.2s2.2 0.99 2.2 2.2c0 0.605-0.242 1.155-0.649 1.551l-1.364 1.386c-0.67 0.679-1.285 1.592-1.285 2.563h-0.002c0 0.608 0.492 1.1 1.1 1.1s1.1-0.492 1.1-1.1c0.067-1.003 0.7-1.417 1.287-2.013l0.99-1.012c0.627-0.627 1.023-1.507 1.023-2.475 0-2.431-1.969-4.4-4.4-4.4s-4.4 1.969-4.4 4.4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-right" viewBox="0 0 24 24">
+ <title>aoc-arrow-right</title>
+ <path d="M9.295 7.115l1.41-1.41 6 6-6 6-1.41-1.41 4.58-4.59z"></path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-left" viewBox="0 0 24 24">
+ <title>aoc-arrow-left</title>
+ <path d="M7.295 11.705l6-6 1.41 1.41-4.58 4.59 4.58 4.59-1.41 1.41z"></path>
+ </symbol>
+ <symbol id="icon-aoc-ticket" viewBox="0 0 24 24">
+ <title>aoc-ticket</title>
+ <path
+ d="M19.778 12.707l-8.485-8.485 2.828-2.828c0.391-0.391 1.024-0.391 1.414 0l2.311 2.311c-0.005 0.004-0.009 0.009-0.013 0.013-0.71 0.71-0.743 1.828-0.073 2.498s1.788 0.637 2.498-0.073c0.004-0.004 0.009-0.009 0.013-0.013l2.336 2.336c0.391 0.391 0.391 1.024 0 1.414l-2.828 2.828zM19.071 13.414l-9.192 9.192c-0.391 0.391-1.024 0.391-1.414 0l-2.336-2.336c0.697-0.711 0.725-1.819 0.060-2.484s-1.774-0.637-2.484 0.060l-2.311-2.311c-0.391-0.391-0.391-1.024 0-1.414l9.192-9.192 8.485 8.485zM5.636 14.121c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l4.95-4.95c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-4.95 4.95zM8.464 16.95c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l4.95-4.95c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-4.95 4.95z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-place-entry" viewBox="0 0 24 24">
+ <title>aoc-place-entry</title>
+ <path
+ d="M18 8c0-3.31-2.69-6-6-6s-6 2.69-6 6c0 4.5 6 11 6 11s6-6.5 6-11v0zM10 8c0-1.1 0.9-2 2-2s2 0.9 2 2c0 1.1-0.89 2-2 2-1.1 0-2-0.9-2-2v0zM5 20v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-facebook" viewBox="0 0 24 24">
+ <title>aoc-facebook</title>
+ <path
+ d="M20.895 2h-17.789c-0.61 0-1.105 0.495-1.105 1.105v17.789c0 0.61 0.495 1.105 1.105 1.105h9.579v-7.719h-2.614v-3.042h2.6v-2.221c0-2.586 1.575-3.993 3.881-3.993 0.783 0.002 1.566 0.047 2.344 0.133v2.698h-1.593c-1.253 0-1.495 0.593-1.495 1.467v1.93h3l-0.389 3.028h-2.611v7.719h5.088c0.61 0 1.105-0.495 1.105-1.105v-17.789c0-0.61-0.495-1.105-1.105-1.105z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-instagram" viewBox="0 0 24 24">
+ <title>aoc-instagram</title>
+ <path
+ d="M12 2c2.716 0 3.056 0.012 4.123 0.060 1.064 0.049 1.791 0.218 2.427 0.465 0.658 0.256 1.215 0.597 1.771 1.153s0.898 1.114 1.153 1.771c0.247 0.636 0.416 1.363 0.465 2.427 0.049 1.067 0.060 1.407 0.060 4.123s-0.012 3.056-0.060 4.123c-0.049 1.064-0.218 1.791-0.465 2.427-0.256 0.658-0.597 1.215-1.153 1.771s-1.114 0.898-1.771 1.153c-0.636 0.247-1.363 0.416-2.427 0.465-1.067 0.049-1.407 0.060-4.123 0.060s-3.056-0.012-4.123-0.060c-1.064-0.049-1.791-0.218-2.427-0.465-0.658-0.256-1.215-0.597-1.771-1.153s-0.898-1.114-1.153-1.771c-0.247-0.636-0.416-1.363-0.465-2.427-0.049-1.067-0.060-1.407-0.060-4.123s0.012-3.056 0.060-4.123c0.049-1.064 0.218-1.791 0.465-2.427 0.256-0.658 0.597-1.215 1.153-1.771s1.114-0.898 1.771-1.153c0.636-0.247 1.363-0.416 2.427-0.465 1.067-0.049 1.407-0.060 4.123-0.060zM12 3.802c-2.67 0-2.986 0.010-4.041 0.058-0.975 0.044-1.504 0.207-1.857 0.344-0.467 0.181-0.8 0.398-1.15 0.748s-0.567 0.683-0.748 1.15c-0.137 0.352-0.3 0.882-0.344 1.857-0.048 1.054-0.058 1.371-0.058 4.041s0.010 2.986 0.058 4.041c0.044 0.975 0.207 1.504 0.344 1.857 0.181 0.467 0.398 0.8 0.748 1.15s0.683 0.567 1.15 0.748c0.352 0.137 0.882 0.3 1.857 0.344 1.054 0.048 1.371 0.058 4.041 0.058s2.987-0.010 4.041-0.058c0.975-0.044 1.504-0.207 1.857-0.344 0.467-0.181 0.8-0.398 1.15-0.748s0.567-0.683 0.748-1.15c0.137-0.352 0.3-0.882 0.344-1.857 0.048-1.054 0.058-1.371 0.058-4.041s-0.010-2.986-0.058-4.041c-0.044-0.975-0.207-1.504-0.344-1.857-0.181-0.467-0.398-0.8-0.748-1.15s-0.683-0.567-1.15-0.748c-0.352-0.137-0.882-0.3-1.857-0.344-1.054-0.048-1.371-0.058-4.041-0.058zM12 6.865c2.836 0 5.135 2.299 5.135 5.135s-2.299 5.135-5.135 5.135c-2.836 0-5.135-2.299-5.135-5.135s2.299-5.135 5.135-5.135zM12 15.333c1.841 0 3.333-1.492 3.333-3.333s-1.492-3.333-3.333-3.333c-1.841 0-3.333 1.492-3.333 3.333s1.492 3.333 3.333 3.333zM18.538 6.662c0 0.663-0.537 1.2-1.2 1.2s-1.2-0.537-1.2-1.2 0.537-1.2 1.2-1.2c0.663 0 1.2 0.537 1.2 1.2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-reddit" viewBox="0 0 24 24">
+ <title>aoc-reddit</title>
+ <path
+ d="M22.999 12.034c0-1.397-1.137-2.534-2.534-2.534-0.621 0-1.208 0.225-1.67 0.632-1.648-1.054-3.834-1.732-6.252-1.823l1.441-4.096 3.601 0.861c0.002 1.139 0.929 2.065 2.069 2.065 1.141 0 2.069-0.928 2.069-2.069s-0.929-2.069-2.069-2.069c-0.866 0-1.609 0.536-1.917 1.292l-4.265-1.020-1.771 5.030c-2.519 0.048-4.803 0.729-6.512 1.816-0.46-0.399-1.040-0.618-1.655-0.618-1.397 0-2.534 1.137-2.534 2.534 0 0.864 0.445 1.661 1.166 2.126-0.044 0.253-0.069 0.509-0.069 0.77 0 3.658 4.434 6.634 9.884 6.634s9.884-2.976 9.884-6.634c0-0.253-0.023-0.502-0.064-0.748 0.74-0.461 1.199-1.27 1.199-2.148l-0.001-0.001zM7.283 13.759c0-0.86 0.697-1.557 1.558-1.557 0.86 0 1.557 0.697 1.557 1.557 0 0.86-0.697 1.557-1.557 1.557-0.861 0-1.558-0.697-1.558-1.557zM15.652 17.971c-0.046 0.049-1.164 1.185-3.689 1.185-2.538 0-3.554-1.153-3.595-1.202-0.143-0.167-0.124-0.419 0.044-0.562 0.166-0.142 0.415-0.124 0.559 0.041 0.023 0.025 0.87 0.926 2.993 0.926 2.16 0 3.107-0.933 3.116-0.942 0.153-0.156 0.404-0.16 0.562-0.006s0.162 0.401 0.011 0.56l0.001-0.001zM15.342 15.316c-0.861 0-1.558-0.697-1.558-1.557s0.697-1.557 1.558-1.557c0.86 0 1.557 0.697 1.557 1.557 0 0.86-0.697 1.557-1.557 1.557z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-rss" viewBox="0 0 24 24">
+ <title>aoc-rss</title>
+ <path
+ d="M3 2c-0.552 0-1 0.448-1 1v18c0 0.552 0.448 1 1 1h18c0.552 0 1-0.448 1-1v-18c0-0.552-0.448-1-1-1h-18zM14.083 18.25h-2.381c0-3.282-2.671-5.952-5.953-5.952v-2.381c4.595 0 8.333 3.738 8.333 8.333zM18.25 18.25h-2.381c0-5.58-4.539-10.119-10.119-10.119v-2.381c6.892 0 12.5 5.608 12.5 12.5zM5.75 16.464c0-0.986 0.8-1.786 1.786-1.786s1.786 0.8 1.786 1.786c0 0.986-0.8 1.786-1.786 1.786s-1.786-0.8-1.786-1.786z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-twitter" viewBox="0 0 24 24">
+ <title>aoc-twitter</title>
+ <path
+ d="M23 6.072c-0.772 0.351-1.602 0.589-2.474 0.695 0.89-0.546 1.573-1.412 1.895-2.443-0.833 0.506-1.754 0.873-2.738 1.071-0.784-0.858-1.904-1.394-3.144-1.394-2.378 0-4.307 1.978-4.307 4.418 0 0.346 0.037 0.683 0.111 1.006-3.581-0.185-6.755-1.941-8.881-4.617-0.371 0.655-0.583 1.414-0.583 2.223 0 1.532 0.761 2.884 1.917 3.677-0.705-0.021-1.371-0.222-1.952-0.551v0.054c0 2.141 1.485 3.927 3.457 4.332-0.361 0.104-0.742 0.155-1.135 0.155-0.277 0-0.549-0.027-0.811-0.078 0.549 1.754 2.139 3.032 4.024 3.066-1.474 1.186-3.333 1.892-5.351 1.892-0.348 0-0.692-0.020-1.028-0.061 1.907 1.251 4.172 1.983 6.604 1.983 7.926 0 12.258-6.731 12.258-12.569 0-0.192-0.004-0.384-0.011-0.573 0.842-0.623 1.573-1.401 2.148-2.287z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-accommodation" viewBox="0 0 24 24">
+ <title>aoc-accommodation</title>
+ <path
+ d="M23 12v1h-17c-0.552 0-1-0.448-1-1s0.448-1 1-1h4v-2.834c0-0.552 0.448-1 1-1 0.051 0 0.102 0.004 0.152 0.012l11 1.692c0.488 0.075 0.848 0.495 0.848 0.988v2.142zM4 14h19v6h-3v-3h-16v3h-3v-15c0-0.552 0.448-1 1-1h1c0.552 0 1 0.448 1 1v9zM7 10c-1.105 0-2-0.895-2-2s0.895-2 2-2c1.105 0 2 0.895 2 2s-0.895 2-2 2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-activity-level" viewBox="0 0 24 24">
+ <title>aoc-activity-level</title>
+ <path
+ d="M22.994 17.99h-7.239c-0.366 0-0.72-0.134-0.994-0.377l-11.172-9.883 3.409-4.016c0.422-0.557 0.839-0.788 1.251-0.694 0.619 0.141 0.619 2.349 2.249 3.47 0.909 0.625 1.601 0.94 5 0.5 0.889 1.249 2.099 5.976 3.050 6.747s4.447 1.234 4.447 3.753v0.5zM22.994 18.99v1c0 0.552-0.448 1-1 1l-6.864-0c-0.73 0-1.435-0.266-1.983-0.749l-10.808-9.522c-0.409-0.36-0.454-0.982-0.101-1.397l0.704-0.829 11.157 9.87c0.457 0.404 1.046 0.628 1.656 0.628h7.239z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-a-photo" viewBox="0 0 24 24">
+ <title>aoc-add-a-photo</title>
+ <path
+ d="M3 4v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3zM6 10v-3h3v-3h7l1.83 2h3.17c1.1 0 2 0.9 2 2v12c0 1.1-0.9 2-2 2h-16c-1.1 0-2-0.9-2-2v-10h3zM13 19c2.76 0 5-2.24 5-5s-2.24-5-5-5c-2.76 0-5 2.24-5 5s2.24 5 5 5v0zM9.8 14c0 1.77 1.43 3.2 3.2 3.2s3.2-1.43 3.2-3.2c0-1.77-1.43-3.2-3.2-3.2s-3.2 1.43-3.2 3.2v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-box" viewBox="0 0 24 24">
+ <title>aoc-add-box</title>
+ <path
+ d="M19 3h-14c-1.11 0-2 0.9-2 2v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2v-14c0-1.1-0.9-2-2-2v0zM17 13h-4v4h-2v-4h-4v-2h4v-4h2v4h4v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-add-shape" viewBox="0 0 24 24">
+ <title>aoc-add-shape</title>
+ <path d="M19 13h-6v6h-2v-6h-6v-2h6v-6h2v6h6z"></path>
+ </symbol>
+ <symbol id="icon-aoc-arrow-forward" viewBox="0 0 24 24">
+ <title>aoc-arrow-forward</title>
+ <path d="M12 4l-1.41 1.41 5.58 5.59h-12.17v2h12.17l-5.58 5.59 1.41 1.41 8-8z"></path>
+ </symbol>
+ <symbol id="icon-aoc-been-here" viewBox="0 0 24 24">
+ <title>aoc-been-here</title>
+ <path d="M7 20h-2l-1-16h2l1 16zM8.625 15l-0.625-10h12l-3 5 3 5h-11.375z"></path>
+ </symbol>
+ <symbol id="icon-aoc-chat-bubbles" viewBox="0 0 24 24">
+ <title>aoc-chat-bubbles</title>
+ <path
+ d="M21 6h-2v9h-13v2c0 0.55 0.45 1 1 1h11l4 4v-15c0-0.55-0.45-1-1-1v0zM17 12v-9c0-0.55-0.45-1-1-1h-13c-0.55 0-1 0.45-1 1v14l4-4h10c0.55 0 1-0.45 1-1v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-close" viewBox="0 0 24 24">
+ <title>aoc-close</title>
+ <path
+ d="M19 6.41l-1.41-1.41-5.59 5.59-5.59-5.59-1.41 1.41 5.59 5.59-5.59 5.59 1.41 1.41 5.59-5.59 5.59 5.59 1.41-1.41-5.59-5.59z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-expand-more" viewBox="0 0 24 24">
+ <title>aoc-expand-more</title>
+ <path d="M16.59 9l-4.59 4.58-4.59-4.58-1.41 1.41 6 6 6-6z"></path>
+ </symbol>
+ <symbol id="icon-aoc-expand-less" viewBox="0 0 24 24">
+ <title>aoc-expand-less</title>
+ <path d="M12 8l-6 6 1.41 1.41 4.59-4.58 4.59 4.58 1.41-1.41z"></path>
+ </symbol>
+ <symbol id="icon-aoc-forum-flag" viewBox="0 0 24 24">
+ <title>aoc-forum-flag</title>
+ <path
+ d="M6 15v6c0 0.552-0.448 1-1 1s-1-0.448-1-1v-18c0-0.552 0.448-1 1-1s1 0.448 1 1h14l-3 6 3 6h-14zM16.764 5h-10.764v8h10.764l-2-4 2-4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-group-size" viewBox="0 0 24 24">
+ <title>aoc-group-size</title>
+ <path
+ d="M12 3c1.812 0 3.281 1.469 3.281 3.281s-1.469 3.281-3.281 3.281c-1.812 0-3.281-1.469-3.281-3.281s1.469-3.281 3.281-3.281zM6.292 10.178c-0.345 0.519-0.542 1.139-0.542 1.797v5.025h-4c-0.414 0-0.75-0.336-0.75-0.75v-5.266c0-0.688 0.468-1.288 1.136-1.455l0.71-0.178c0.582 0.63 1.416 1.024 2.341 1.024 0.388 0 0.76-0.069 1.104-0.197zM17.488 9.885c0.492 0.31 1.075 0.49 1.699 0.49 0.888 0 1.691-0.363 2.269-0.948l0.408 0.102c0.668 0.167 1.136 0.767 1.136 1.455v5.266c0 0.414-0.336 0.75-0.75 0.75h-4v-5.025c0-0.787-0.282-1.52-0.762-2.091zM19.188 9.375c-1.208 0-2.187-0.979-2.187-2.187s0.979-2.187 2.187-2.187c1.208 0 2.187 0.979 2.187 2.187s-0.979 2.187-2.187 2.187zM5.187 9.375c-1.208 0-2.187-0.979-2.187-2.187s0.979-2.187 2.187-2.187c1.208 0 2.187 0.979 2.187 2.187s-0.979 2.187-2.187 2.187zM9.279 9.587c0.74 0.61 1.688 0.976 2.721 0.976s1.981-0.366 2.721-0.976l0.824 0.206c1.002 0.25 1.704 1.15 1.704 2.183v6.9c0 0.621-0.504 1.125-1.125 1.125h-8.25c-0.621 0-1.125-0.504-1.125-1.125v-6.9c0-1.032 0.703-1.932 1.704-2.183l0.824-0.206z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-heart-outline" viewBox="0 0 24 24">
+ <title>aoc-heart-outline</title>
+ <path
+ d="M18.91 11.473c0.697-0.71 1.090-1.677 1.090-2.689s-0.394-1.979-1.091-2.689c-0.689-0.703-1.62-1.095-2.587-1.095s-1.897 0.393-2.587 1.095l-1.736 1.768-1.736-1.768c-1.433-1.46-3.741-1.46-5.174 0-1.454 1.481-1.454 3.897-0.031 5.347l6.941 6.765 6.91-6.734zM11.375 4.395l0.625 0.613 0.623-0.611c1.026-0.898 2.338-1.397 3.7-1.397 1.506 0 2.95 0.61 4.015 1.695s1.663 2.556 1.663 4.090-0.598 3.006-1.663 4.090l-8.337 8.125-8.337-8.125c-2.217-2.259-2.217-5.921 0-8.18 2.114-2.154 5.481-2.254 7.712-0.3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-heart-solid" viewBox="0 0 24 24">
+ <title>aoc-heart-solid</title>
+ <path
+ d="M11.375 4.395l0.625 0.613 0.623-0.611c1.026-0.898 2.338-1.397 3.7-1.397 1.506 0 2.95 0.61 4.015 1.695s1.663 2.556 1.663 4.090-0.598 3.006-1.663 4.090l-8.337 8.125-8.337-8.125c-2.217-2.259-2.217-5.921 0-8.18 2.114-2.154 5.481-2.254 7.712-0.3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-home" viewBox="0 0 24 24">
+ <title>aoc-home</title>
+ <path d="M15 22v-5c0-1.657-1.343-3-3-3s-3 1.343-3 3v5h-5v-11h-2v-1l9.995-8 10.005 8v1h-2v11h-5z"></path>
+ </symbol>
+ <symbol id="icon-aoc-important" viewBox="0 0 24 24">
+ <title>aoc-important</title>
+ <path
+ d="M21.775 18.469c0.641 1.125-0.164 2.531-1.444 2.531h-16.663c-1.283 0-2.083-1.408-1.444-2.531l8.331-14.626c0.641-1.125 2.247-1.123 2.887 0l8.331 14.626zM12 8c-0.552 0-1 0.448-1 1v5c0 0.552 0.448 1 1 1s1-0.448 1-1v-5c0-0.552-0.448-1-1-1zM12 19c0.552 0 1-0.448 1-1s-0.448-1-1-1c-0.552 0-1 0.448-1 1s0.448 1 1 1z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-knife-fork" viewBox="0 0 24 24">
+ <title>aoc-knife-fork</title>
+ <path
+ d="M9.25 2.75v4.625c0 0.345 0.28 0.625 0.625 0.625s0.625-0.28 0.625-0.625v-4.625c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v6.25c0 1.306-0.835 2.417-2 2.829v8.671c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5v-8.671c-1.165-0.412-2-1.523-2-2.829v-6.25c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v4.625c0 0.345 0.28 0.625 0.625 0.625s0.625-0.28 0.625-0.625v-4.625c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75zM19 1v19.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5v-7.5h-1c-0.552 0-1-0.448-1-1v-6c0-2.761 2.239-5 5-5z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-library-books" viewBox="0 0 24 24">
+ <title>aoc-library-books</title>
+ <path
+ d="M4 6h-2v14c0 1.1 0.9 2 2 2h14v-2h-14v-14zM20 2h-12c-1.1 0-2 0.9-2 2v12c0 1.1 0.9 2 2 2h12c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2v0zM19 11h-10v-2h10v2zM15 15h-6v-2h6v2zM19 7h-10v-2h10v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-link" viewBox="0 0 24 24">
+ <title>aoc-link</title>
+ <path
+ d="M9.937 12.2c0.441-0.33 1.067-0.24 1.397 0.201 0.542 0.724 1.372 1.178 2.274 1.242s1.788-0.266 2.428-0.906l2.451-2.451c1.187-1.229 1.171-3.173-0.032-4.377-1.201-1.201-3.146-1.221-4.355-0.053l-1.421 1.413c-0.391 0.389-1.023 0.387-1.412-0.004s-0.387-1.023 0.004-1.412l1.431-1.423c2.002-1.934 5.192-1.904 7.164 0.067 1.975 1.975 1.998 5.165 0.044 7.187l-2.463 2.463c-1.049 1.049-2.502 1.591-3.982 1.485s-2.841-0.85-3.73-2.038c-0.33-0.441-0.24-1.067 0.201-1.397zM14.425 12.152c-0.441 0.33-1.067 0.24-1.397-0.201-0.542-0.724-1.372-1.178-2.274-1.242s-1.788 0.266-2.428 0.906l-2.451 2.451c-1.187 1.229-1.171 3.173 0.032 4.377 1.201 1.201 3.145 1.221 4.352 0.056l1.414-1.414c0.39-0.39 1.022-0.39 1.412 0s0.39 1.022-0 1.412l-1.426 1.426c-2.002 1.933-5.191 1.903-7.163-0.068-1.975-1.975-1.998-5.165-0.044-7.187l2.463-2.463c1.049-1.049 2.502-1.591 3.982-1.485s2.841 0.85 3.73 2.038c0.33 0.441 0.24 1.067-0.201 1.397z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-list-circle-bullets" viewBox="0 0 24 24">
+ <title>aoc-list-circle-bullets</title>
+ <path
+ d="M4 10.5c-0.83 0-1.5 0.67-1.5 1.5s0.67 1.5 1.5 1.5c0.83 0 1.5-0.67 1.5-1.5s-0.67-1.5-1.5-1.5v0zM4 4.5c-0.83 0-1.5 0.67-1.5 1.5s0.67 1.5 1.5 1.5c0.83 0 1.5-0.67 1.5-1.5s-0.67-1.5-1.5-1.5v0zM4 16.5c-0.835 0-1.5 0.677-1.5 1.5s0.677 1.5 1.5 1.5c0.823 0 1.5-0.677 1.5-1.5s-0.665-1.5-1.5-1.5v0zM7 19h14v-2h-14v2zM7 13h14v-2h-14v2zM7 5v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-list" viewBox="0 0 24 24">
+ <title>aoc-list</title>
+ <path d="M3 13h2v-2h-2v2zM3 17h2v-2h-2v2zM3 9h2v-2h-2v2zM7 13h14v-2h-14v2zM7 17h14v-2h-14v2zM7 7v2h14v-2h-14z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-location-add" viewBox="0 0 24 24">
+ <title>aoc-location-add</title>
+ <path
+ d="M12 2c-3.86 0-7 3.14-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.86-3.14-7-7-7v0zM16 10h-3v3h-2v-3h-3v-2h3v-3h2v3h3v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-location" viewBox="0 0 24 24">
+ <title>aoc-location</title>
+ <path
+ d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7v0zM12 11.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5c1.38 0 2.5 1.12 2.5 2.5s-1.12 2.5-2.5 2.5v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-mail" viewBox="0 0 24 24">
+ <title>aoc-mail</title>
+ <path
+ d="M20 4h-16c-1.1 0-1.99 0.9-1.99 2l-0.010 12c0 1.1 0.9 2 2 2h16c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2v0zM20 8l-8 5-8-5v-2l8 5 8-5v2z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-map" viewBox="0 0 24 24">
+ <title>aoc-map</title>
+ <path
+ d="M20.5 3l-0.16 0.030-5.34 2.070-6-2.1-5.64 1.9c-0.21 0.070-0.36 0.25-0.36 0.48v15.12c0 0.28 0.22 0.5 0.5 0.5l0.16-0.030 5.34-2.070 6 2.1 5.64-1.9c0.21-0.070 0.36-0.25 0.36-0.48v-15.12c0-0.28-0.22-0.5-0.5-0.5v0zM15 19l-6-2.11v-11.89l6 2.11v11.89z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-menu" viewBox="0 0 24 24">
+ <title>aoc-menu</title>
+ <path d="M3 5h18v2h-18v-2zM3 11h18v2h-18v-2zM3 17h18v2h-18v-2z"></path>
+ </symbol>
+ <symbol id="icon-aoc-more-horizontal" viewBox="0 0 24 24">
+ <title>aoc-more-horizontal</title>
+ <path
+ d="M6 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0zM18 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0zM12 10c-1.1 0-2 0.9-2 2s0.9 2 2 2c1.1 0 2-0.9 2-2s-0.9-2-2-2v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-my-location" viewBox="0 0 24 24">
+ <title>aoc-my-location</title>
+ <path
+ d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4c2.21 0 4-1.79 4-4s-1.79-4-4-4v0zM20.94 11c-0.46-4.17-3.77-7.48-7.94-7.94v-2.060h-2v2.060c-4.17 0.46-7.48 3.77-7.94 7.94h-2.060v2h2.060c0.46 4.17 3.77 7.48 7.94 7.94v2.060h2v-2.060c4.17-0.46 7.48-3.77 7.94-7.94h2.060v-2h-2.060zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7c3.87 0 7 3.13 7 7s-3.13 7-7 7v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-near-me" viewBox="0 0 24 24">
+ <title>aoc-near-me</title>
+ <path d="M21 3l-18 7.53v0.98l6.84 2.65 2.64 6.84h0.98z"></path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-alert" viewBox="0 0 24 24">
+ <title>aoc-notifications-alert</title>
+ <path
+ d="M18.988 10.589c0.008 0.136 0.012 0.273 0.012 0.411v5l2 1v2h-18v-2l2-1v-5c0-3.526 2.608-6.444 6-6.929v-1.071c0-0.552 0.448-1 1-1s1 0.447 1 1c-0.628 0.836-1 1.875-1 3 0 2.761 2.239 5 5 5 0.707 0 1.379-0.147 1.988-0.411zM10 20h4c0 1.105-0.895 2-2 2s-2-0.895-2-2zM17 9c-1.657 0-3-1.343-3-3s1.343-3 3-3c1.657 0 3 1.343 3 3s-1.343 3-3 3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-mentions" viewBox="0 0 24 24">
+ <title>aoc-notifications-mentions</title>
+ <path
+ d="M15.52 15.487c-0.585 0.857-1.949 1.786-3.776 1.786-3.094 0-5.189-2.154-5.189-5.164 0-2.937 2.144-5.237 5.092-5.237 1.486 0 2.704 0.587 3.265 1.297v-1.126h2.12v6.534c0 1.126 0.39 1.835 1.535 1.835 1.34 0 2.388-1.786 2.388-4.601 0-4.943-3.411-7.978-8.771-7.978-5.677 0-9.039 4.185-9.039 9.201 0 5.555 3.898 9.103 9.623 9.103 2.68 0 4.848-1.003 6.383-2.349l1.121 1.37c-1.437 1.444-4.166 2.839-7.553 2.839-7.358 0-11.719-4.748-11.719-10.914 0-6.412 4.629-11.086 11.256-11.086 6.797 0 10.744 4.283 10.744 9.715 0 4.209-1.998 6.534-4.653 6.534-1.657 0-2.509-0.832-2.826-1.762zM8.75 12.011c0 1.747 1.19 2.989 2.929 2.989s3.071-1.149 3.071-2.989c0-1.839-1.357-3.011-3.095-3.011s-2.905 1.241-2.905 3.011z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-muted" viewBox="0 0 24 24">
+ <title>aoc-notifications-muted</title>
+ <path
+ d="M13 4.071c3.392 0.485 6 3.403 6 6.929v5l2 1v2h-18v-2l2-1v-5c0-3.526 2.608-6.444 6-6.929v-1.071c0-0.552 0.448-1 1-1s1 0.448 1 1v1.071zM10 20h4c0 1.105-0.895 2-2 2s-2-0.895-2-2zM13.407 11.993l2.828-2.828-1.414-1.414-2.828 2.828-2.828-2.828-1.414 1.414 2.828 2.828-2.828 2.828 1.414 1.414 2.828-2.828 2.828 2.828 1.414-1.414-2.828-2.828z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-notifications-tracking" viewBox="0 0 24 24">
+ <title>aoc-notifications-tracking</title>
+ <path
+ d="M19 9.9c0.323 0.066 0.658 0.1 1 0.1s0.677-0.034 1-0.1v10.1c0 1.105-0.895 2-2 2h-14c-1.105 0-2-0.895-2-2v-14c0-1.105 0.895-2 2-2h10.1c-0.066 0.323-0.1 0.658-0.1 1s0.034 0.677 0.1 1h-10.1v14h14v-10.1zM20 8c-1.657 0-3-1.343-3-3s1.343-3 3-3c1.657 0 3 1.343 3 3s-1.343 3-3 3z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-open-in-new" viewBox="0 0 24 24">
+ <title>aoc-open-in-new</title>
+ <path
+ d="M19 19h-14v-14h7v-2h-7c-1.11 0-2 0.9-2 2v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41 9.83-9.83v3.59h2v-7h-7z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-pencil" viewBox="0 0 24 24">
+ <title>aoc-pencil</title>
+ <path
+ d="M3 17.25v3.75h3.75l11.060-11.060-3.75-3.75-11.060 11.060zM20.71 7.040c0.39-0.39 0.39-1.020 0-1.41l-2.34-2.34c-0.39-0.39-1.020-0.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-person" viewBox="0 0 24 24">
+ <title>aoc-person</title>
+ <path
+ d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4c-2.21 0-4 1.79-4 4s1.79 4 4 4v0zM12 14c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-pinned" viewBox="0 0 24 24">
+ <title>aoc-pinned</title>
+ <path
+ d="M6.515 16.077l-4.223-4.223c1.774-1.774 4.266-2.391 6.541-1.853l5.516-4.667c-0.493-1.098-0.288-2.433 0.614-3.335l7.039 7.039c-0.902 0.902-2.236 1.106-3.335 0.614l-4.667 5.516c0.539 2.274-0.079 4.767-1.852 6.541l-4.223-4.223-4.223 4.223c-0.389 0.389-1.019 0.389-1.408 0s-0.389-1.019 0-1.408l4.223-4.223z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-plane-takeoff" viewBox="0 0 24 24">
+ <title>aoc-plane-takeoff</title>
+ <path
+ d="M2.5 19h19v2h-19v-2zM22.070 9.64c-0.21-0.8-1.040-1.28-1.84-1.060l-5.31 1.42-6.9-6.43-1.93 0.51 4.14 7.17-4.97 1.33-1.97-1.54-1.45 0.39 1.82 3.16 0.77 1.33 1.6-0.43 14.97-4c0.81-0.23 1.28-1.050 1.070-1.85v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-plane" viewBox="0 0 24 24">
+ <title>aoc-plane</title>
+ <path
+ d="M21 16v-2l-8-5v-5.5c0-0.83-0.67-1.5-1.5-1.5s-1.5 0.67-1.5 1.5v5.5l-8 5v2l8-2.5v5.5l-2 1.5v1.5l3.5-1 3.5 1v-1.5l-2-1.5v-5.5l8 2.5z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-print" viewBox="0 0 24 24">
+ <title>aoc-print</title>
+ <path
+ d="M19 8h-14c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3v0zM16 19h-8v-5h8v5zM19 12c-0.55 0-1-0.45-1-1s0.45-1 1-1c0.55 0 1 0.45 1 1s-0.45 1-1 1v0zM18 3h-12v4h12v-4z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-reply" viewBox="0 0 24 24">
+ <title>aoc-reply</title>
+ <path d="M10 9v-4l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11v0z"></path>
+ </symbol>
+ <symbol id="icon-aoc-search" viewBox="0 0 24 24">
+ <title>aoc-search</title>
+ <path
+ d="M15.5 14h-0.79l-0.28-0.27c0.98-1.14 1.57-2.62 1.57-4.23 0-3.59-2.91-6.5-6.5-6.5s-6.5 2.91-6.5 6.5c0 3.59 2.91 6.5 6.5 6.5 1.61 0 3.090-0.59 4.23-1.57l0.27 0.28v0.79l5 4.99 1.49-1.49-4.99-5zM9.5 14c-2.49 0-4.5-2.010-4.5-4.5s2.010-4.5 4.5-4.5c2.49 0 4.5 2.010 4.5 4.5s-2.010 4.5-4.5 4.5v0z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-shuffle" viewBox="0 0 24 24">
+ <title>aoc-shuffle</title>
+ <path
+ d="M10.59 9.17l-5.18-5.17-1.41 1.41 5.17 5.17 1.42-1.41zM14.5 4l2.040 2.040-12.54 12.55 1.41 1.41 12.55-12.54 2.040 2.040v-5.5h-5.5zM14.83 13.41l-1.41 1.41 3.13 3.13-2.050 2.050h5.5v-5.5l-2.040 2.040-3.13-3.13z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-star" viewBox="0 0 24 24">
+ <title>aoc-star</title>
+ <path
+ d="M18.18 21l-1.635-7.029 5.455-4.727-7.191-0.617-2.809-6.627-2.809 6.627-7.191 0.617 5.455 4.727-1.635 7.029 6.18-3.728z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-subject" viewBox="0 0 24 24">
+ <title>aoc-subject</title>
+ <path d="M14 17h-10v2h10v-2zM20 9h-16v2h16v-2zM4 15h16v-2h-16v2zM4 5v2h16v-2h-16z"></path>
+ </symbol>
+ <symbol id="icon-aoc-trip-style" viewBox="0 0 24 24">
+ <title>aoc-trip-style</title>
+ <path
+ d="M20 6c1.11 0 2 0.89 2 2v11c0 1.11-0.89 2-2 2h-16c-1.11 0-2-0.89-2-2l0.010-11c0-1.11 0.88-2 1.99-2h4v-2c0-1.11 0.89-2 2-2h4c1.11 0 2 0.89 2 2v2h4zM14 6v-2h-4v2h4zM6.5 14c0.828 0 1.5-0.672 1.5-1.5s-0.672-1.5-1.5-1.5c-0.828 0-1.5 0.672-1.5 1.5s0.672 1.5 1.5 1.5zM6 16.042l0.347 1.97 5.909-1.042-0.347-1.97-5.909 1.042z">
+ </path>
+ </symbol>
+ <symbol id="icon-aoc-unpinned" viewBox="0 0 24 24">
+ <title>aoc-unpinned</title>
+ <path
+ d="M5.416 12.15l6.433 6.433c0.362-0.93 0.439-1.959 0.203-2.955l-0.233-0.982 5.94-7.020-1.386-1.386-7.020 5.94-0.982-0.233c-0.996-0.236-2.025-0.16-2.955 0.203zM6.515 16.077l-4.223-4.223c1.774-1.774 4.266-2.391 6.541-1.853l5.516-4.667c-0.493-1.098-0.288-2.433 0.614-3.335l7.039 7.039c-0.902 0.902-2.236 1.106-3.335 0.614l-4.667 5.516c0.539 2.274-0.079 4.767-1.852 6.541l-4.223-4.223-4.223 4.223c-0.389 0.389-1.019 0.389-1.408 0s-0.389-1.019 0-1.408l4.223-4.223z">
+ </path>
+ </symbol>
+ </defs>
+</svg>
+
+</html> \ No newline at end of file
diff --git a/test/fixtures/mastodon-post-activity.json b/test/fixtures/mastodon-post-activity.json
index b91263431..5c3d22722 100644
--- a/test/fixtures/mastodon-post-activity.json
+++ b/test/fixtures/mastodon-post-activity.json
@@ -35,6 +35,19 @@
"inReplyTo": null,
"inReplyToAtomUri": null,
"published": "2018-02-12T14:08:20Z",
+ "replies": {
+ "id": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies",
+ "type": "Collection",
+ "first": {
+ "type": "CollectionPage",
+ "next": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true",
+ "partOf": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies",
+ "items": [
+ "http://mastodon.example.org/users/admin/statuses/99512778738411823",
+ "http://mastodon.example.org/users/admin/statuses/99512778738411824"
+ ]
+ }
+ },
"sensitive": true,
"summary": "cw",
"tag": [
diff --git a/test/fixtures/misskey-like.json b/test/fixtures/misskey-like.json
new file mode 100644
index 000000000..84d56f473
--- /dev/null
+++ b/test/fixtures/misskey-like.json
@@ -0,0 +1,14 @@
+{
+ "@context" : [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {"Hashtag" : "as:Hashtag"}
+ ],
+ "_misskey_reaction" : "pudding",
+ "actor": "http://mastodon.example.org/users/admin",
+ "cc" : ["https://testing.pleroma.lol/users/lain"],
+ "id" : "https://misskey.xyz/75149198-2f45-46e4-930a-8b0538297075",
+ "nickname" : "lain",
+ "object" : "https://testing.pleroma.lol/objects/c331bbf7-2eb9-4801-a709-2a6103492a5a",
+ "type" : "Like"
+}
diff --git a/test/fixtures/modules/runtime_module.ex b/test/fixtures/modules/runtime_module.ex
new file mode 100644
index 000000000..f11032b57
--- /dev/null
+++ b/test/fixtures/modules/runtime_module.ex
@@ -0,0 +1,9 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule RuntimeModule do
+ @moduledoc """
+ This is a dummy module to test custom runtime modules.
+ """
+end
diff --git a/test/fixtures/nypd-facial-recognition-children-teenagers4.html b/test/fixtures/nypd-facial-recognition-children-teenagers4.html
new file mode 100644
index 000000000..9f15cc42e
--- /dev/null
+++ b/test/fixtures/nypd-facial-recognition-children-teenagers4.html
@@ -0,0 +1,228 @@
+<!DOCTYPE html>
+<html lang="en" itemId="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" itemType="http://schema.org/NewsArticle" itemScope="" class="story" xmlns:og="http://opengraphprotocol.org/schema/">
+ <head>
+ <title data-rh="true">She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times</title>
+ <title data-rh="true">She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times</title>
+ <meta data-rh="true" itemprop="inLanguage" content="en-US"/><meta data-rh="true" property="article:published" itemprop="datePublished dateCreated" content="2019-08-01T17:15:31.000Z"/><meta data-rh="true" property="article:modified" itemprop="dateModified" content="2019-08-02T09:30:23.000Z"/><meta data-rh="true" http-equiv="Content-Language" content="en"/><meta data-rh="true" name="robots" content="noarchive"/><meta data-rh="true" name="articleid" itemprop="identifier" content="100000006583622"/><meta data-rh="true" name="nyt_uri" itemprop="identifier" content="nyt://article/9da58246-2495-505f-9abd-b5fda8e67b56"/><meta data-rh="true" name="pubp_event_id" itemprop="identifier" content="pubp://event/47a657bafa8a476bb36832f90ee5ac6e"/><meta data-rh="true" name="description" itemprop="description" content="With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers."/><meta data-rh="true" name="image" itemprop="image" content="https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg"/><meta data-rh="true" name="byl" content="By Joseph Goldstein and Ali Watkins"/><meta data-rh="true" name="thumbnail" itemprop="thumbnailUrl" content="https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-thumbStandard.jpg"/><meta data-rh="true" name="news_keywords" content="NYPD,Juvenile delinquency,Facial Recognition,Privacy,Government Surveillance,Police,Civil Rights,NYC"/><meta data-rh="true" name="pdate" content="20190801"/><meta data-rh="true" property="og:url" content="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><meta data-rh="true" property="og:type" content="article"/><meta data-rh="true" property="og:title" content="She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database."/><meta data-rh="true" property="og:image" content="https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg"/><meta data-rh="true" property="og:description" content="With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers."/><meta data-rh="true" property="article:section" itemprop="articleSection" content="New York"/><meta data-rh="true" property="article:tag" content="Police Department (NYC)"/><meta data-rh="true" property="article:tag" content="Juvenile Delinquency"/><meta data-rh="true" property="article:tag" content="Facial Recognition Software"/><meta data-rh="true" property="article:tag" content="Privacy"/><meta data-rh="true" property="article:tag" content="Surveillance of Citizens by Government"/><meta data-rh="true" property="article:tag" content="Police"/><meta data-rh="true" property="article:tag" content="Civil Rights and Liberties"/><meta data-rh="true" property="article:tag" content="New York City"/><meta data-rh="true" name="CG" content="nyregion"/><meta data-rh="true" name="SCG" content=""/><meta data-rh="true" name="CN" content="experience-tech-and-society"/><meta data-rh="true" name="CT" content="spotlight"/><meta data-rh="true" name="PT" content="article"/><meta data-rh="true" name="PST" content="News"/><meta data-rh="true" name="url" itemprop="url" content="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><meta data-rh="true" name="msapplication-starturl" content="https://www.nytimes.com"/><meta data-rh="true" property="al:android:url" content="nytimes://reader/id/100000006583622"/><meta data-rh="true" property="al:android:package" content="com.nytimes.android"/><meta data-rh="true" property="al:android:app_name" content="NYTimes"/><meta data-rh="true" name="twitter:app:name:googleplay" content="NYTimes"/><meta data-rh="true" name="twitter:app:id:googleplay" content="com.nytimes.android"/><meta data-rh="true" name="twitter:app:url:googleplay" content="nytimes://reader/id/100000006583622"/><meta data-rh="true" property="al:iphone:url" content="nytimes://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><meta data-rh="true" property="al:iphone:app_store_id" content="284862083"/><meta data-rh="true" property="al:iphone:app_name" content="NYTimes"/><meta data-rh="true" property="al:ipad:url" content="nytimes://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><meta data-rh="true" property="al:ipad:app_store_id" content="357066198"/><meta data-rh="true" property="al:ipad:app_name" content="NYTimes"/>
+ <meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
+<meta property="fb:app_id" content="9869919170" />
+<meta name="twitter:site" value="@nytimes" />
+
+
+ <script type="text/javascript">
+ // 20.585kB
+ window.viHeadScriptSize = 20.585;
+ (function () { var _f=function(e){window.vi=window.vi||{},window.vi.env=Object.freeze(e)};;_f.apply(null, [{"JKIDD_PATH":"https://a.nytimes.com/svc/nyt/data-layer","ET2_URL":"https://a.et.nytimes.com","WEDDINGS_PATH":"https://content.api.nytimes.com","GDPR_PATH":"https://us-central1-nyt-wfvi-prd.cloudfunctions.net/gdpr-email-form","RECAPTCHA_SITEKEY":"6LevSGcUAAAAAF-7fVZF05VTRiXvBDAY4vBSPaTF","ABRA_ET_URL":"//et.nytimes.com","NODE_ENV":"production","SENTRY_SAMPLE_RATE":"10","EXPERIMENTAL_ROUTE_PREFIX":"","ENVIRONMENT":"prd","RELEASE":"034494769d779a637c178f47c2096df69b7c07a4","AUTH_HOST":"https://myaccount.nytimes.com","SWG_PUBLICATION_ID":"nytimes.com","GQL_FETCH_TIMEOUT":"4000"}]); })();;
+ !function(){if('PerformanceLongTaskTiming' in window){var g=window.__tti={e:[]};
+ g.o=new PerformanceObserver(function(l){g.e=g.e.concat(l.getEntries())});
+ g.o.observe({entryTypes:['longtask']})}}();
+;
+ !function(n,e){var t,o,i,c=[],f={passive:!0,capture:!0},r=new Date,a="pointerup",u="pointercancel";function p(n,c){t||(t=c,o=n,i=new Date,w(e),s())}function s(){o>=0&&o<i-r&&(c.forEach(function(n){n(o,t)}),c=[])}function l(t){if(t.cancelable){var o=(t.timeStamp>1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,o){function i(){p(t,o),r()}function c(){r()}function r(){e(a,i,f),e(u,c,f)}n(a,i,f),n(u,c,f)}(o,t):p(o,t)}}function w(n){["click","mousedown","keydown","touchstart","pointerdown"].forEach(function(e){n(e,l,f)})}w(n),self.perfMetrics=self.perfMetrics||{},self.perfMetrics.onFirstInputDelay=function(n){c.push(n),s()}}(addEventListener,removeEventListener);
+;try {
+ var observer = new window.PerformanceObserver(function (list) {
+ var entries = list.getEntries();
+
+ for (var i = 0; i < entries.length; i += 1) {
+ var entry = entries[i];
+ var performance = {};
+
+ performance[entry.name] = Math.round(entry.startTime + entry.duration);
+ (window.dataLayer = window.dataLayer || []).push({
+ event: "performance",
+ pageview: {
+ performance: performance
+ }
+ });
+ }
+ });
+ observer.observe({
+ entryTypes: ["paint"]
+ });
+} catch (e) {};
+!function(i,e){var a,s,c,p,u,g=[],
+l="object"==typeof i.navigator&&"string"==typeof i.navigator.userAgent&&/iP(ad|hone|od)/.test(
+i.navigator.userAgent),f="object"==typeof i.navigator&&i.navigator.sendBeacon,
+y=f?l?"xhr_ios":"beacon":"xhr";function d(){var e,t,n=i.crypto||i.msCrypto;if(n)t=n.getRandomValues(
+new Uint8Array(18));else for(t=[];t.length<18;)t.push(256*Math.random()^255&(e=e||+new Date)),
+e=Math.floor(e/256);return btoa(String.fromCharCode.apply(String,t)).replace(/\+/g,"-").replace(
+/\//g,"_")}if(i.nyt_et)try{console.warn("et2 snippet should only load once per page")}catch(e
+){}else i.nyt_et=function(){var e,t,n,o=arguments;function r(r){g.length&&(function(e,t,n){if(
+"beacon"===y||f&&r)return i.navigator.sendBeacon(e,t)
+;var o="undefined"!=typeof XMLHttpRequest?new XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP")
+;o.open("POST",e),o.withCredentials=!0,o.setRequestHeader("Accept","*/*"),
+"string"==typeof t?o.setRequestHeader("Content-Type","text/plain;charset=UTF-8"
+):"[object Blob]"==={}.toString.call(t)&&t.type&&o.setRequestHeader("Content-Type",t.type);try{
+o.send(t)}catch(e){}}(a+"/track",JSON.stringify(g)),g.length=0,clearTimeout(u),u=null)}if(
+"string"==typeof o[0]&&/init/.test(o[0])&&(c=d(),"init"==o[0]&&!s)){if(s=d(),
+"string"!=typeof o[1]||!/^http/.test(o[1]))throw new Error("init must include an et host url")
+;a=String(o[1]).replace(/\/$/,""),"string"==typeof o[2]&&(p=o[2])}n="page_exit"==(e=o[o.length-1]
+).subject||"ob_click"==(e.eventData||{}).type,a&&"object"==typeof e&&(t="page"==e.subject?c:d(),
+e.sourceApp&&(p=e.sourceApp),e.sourceApp=p,g.push({context_id:s,pageview_id:c,event_id:t,
+client_lib:"v1.0.5",sourceApp:p,how:n&&l&&f?"beacon_ios":y,client_ts:+new Date,data:JSON.parse(
+JSON.stringify(e))}),"send"==o[0]||t==c||n?r(n):u||(u=setTimeout(r,5500)))},
+i.nyt_et.get_pageview_id=function(){return c}}(window);
+;
+var NYTD=NYTD||{};NYTD.Abra=function(t){"use strict";function e(t){var e=r[t];return e&&e[1]||null}function n(t,e){if(t){var n,r,o=e[0],i=e[1],c=0,u=0;if(1!==i.length||4294967296!==i[0])for(n=a(t+" "+o)>>>0,c=0,u=0;r=i[c++];)if(n<(u+=r[0]))return r}}function a(t){for(var e,n,a,r,o,i,c,u=0,h=0,l=[],s=[e=1732584193,n=4023233417,~e,~n,3285377520],f=[],p=t.length;h<=p;)f[h>>2]|=(h<p?t.charCodeAt(h):128)<<8*(3-h++%4);for(f[c=p+8>>2|15]=p<<3;u<=c;u+=16){for(e=s,h=0;h<80;e=[0|[(i=((t=e[0])<<5|t>>>27)+e[4]+(l[h]=h<16?~~f[u+h]:i<<1|i>>>31)+1518500249)+((n=e[1])&(a=e[2])|~n&(r=e[3])),o=i+(n^a^r)+341275144,i+(n&a|n&r|a&r)+882459459,o+1535694389][0|h++/20],t,n<<30|n>>>2,a,r])i=l[h-3]^l[h-8]^l[h-14]^l[h-16];for(h=5;h;)s[--h]=s[h]+e[h]|0}return s[0]}var r,o={};return t.dataLayer=t.dataLayer||[],e.init=function(e){var a,o,i,c,u,h,l,s,f,p,d=[],v=[],m=(t.document.cookie.match(/(?:^|;) *nyt-a=([^;]*)/)||[])[1],b=(t.document.cookie.match(/(?:^|;) *ab7=([^;]*)/)||[])[1],g=(t.location.search.match(/(?:^\?|&)abra=([^&]*)/)||[])[1];if(r)throw new Error("can't init twice");for(r={},u=(decodeURIComponent(b||"")+"&"+decodeURIComponent(g||"")).split("&"),a=u.length-1;a>=0;a--)h=u[a].split("="),h.length<2||(l=h[0])&&!r[l]&&(s=h[1]||null,r[l]=[,s,1],s&&d.push(l+"="+s),v.push({test:l,variant:s||"0"}));for(a=0;a<e.length;a++)i=e[a],(o=i[0])in r||(c=n(m,i)||[],c[0],f=c[1],p=!!c[2],r[o]=c,f&&d.push(o.replace(/[^\w-]/g)+"="+(""+f).replace(/[^\w-]/g)),p&&v.push({test:o,variant:f||"0"}));d.length&&t.document.documentElement.setAttribute("data-nyt-ab",d.join(" ")),v.length&&t.dataLayer.push({event:"ab-alloc",abtest:{batch:v}})},e.reportExposure=function(e,n){if(!o[e]){o[e]=1;var a=r[e];if(a){var i=a[1];a[2]&&t.dataLayer.push({event:"ab-expose",abtest:{test:e,variant:i||"0"}})}n&&t.setTimeout(function(){n(null)},0)}},e}(this);
+;(function () { var NYTD=window.NYTD||{};function setupTimeZone(){var e='[data-timezone][data-timezone~="'+(new Date).getHours()+'"] { display: block }',t=document.createElement("style");t.innerHTML=e,document.head.appendChild(t)}function addNYTAppClass(){var e=window.navigator.userAgent||window.navigator.vendor||window.opera,t=-1!==e.indexOf("nyt_android"),n=-1!==e.indexOf("nytios");(t||n)&&document.documentElement.classList.add("NYTApp")}function setupPageViewId(){NYTD.PageViewId={},NYTD.PageViewId.update=function(){return"undefined"!=typeof nyt_et&&"function"==typeof window.nyt_et.get_pageview_id?(window.nyt_et("pageinit"),NYTD.PageViewId.current=window.nyt_et.get_pageview_id()):NYTD.PageViewId.current="xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(e){var t=16*Math.random()|0;return("x"===e?t:3&t|8).toString(16)}),NYTD.PageViewId.current}}var _f=function(e){try{document.domain="nytimes.com"}catch(e){}window.swgUserInfoXhrObject=new XMLHttpRequest,window.__emotion=e.emotionIds,setupPageViewId(),setupTimeZone(),addNYTAppClass(),window.nyt_et("init",vi.env.ET2_URL,"nyt-vi",{subject:"page",canonicalUrl:(document.querySelector("link[rel=canonical]")||{}).href,articleId:(document.querySelector("meta[name=articleid]")||{}).content,nyt_uri:(document.querySelector("meta[name=nyt_uri]")||{}).content,pubpEventId:(document.querySelector("meta[name=pubp_event_id]")||{}).content,url:location.href,referrer:document.referrer||void 0,client_tz_offset:(new Date).getTimezoneOffset()}),"undefined"!=typeof nyt_et&&"function"==typeof window.nyt_et.get_pageview_id?NYTD.PageViewId.current=window.nyt_et.get_pageview_id():NYTD.PageViewId.update(),NYTD.Abra.init(e.abraConfig,vi.env.ABRA_ET_URL)};;_f.apply(null, [{"emotionIds":["0","1dv1kvn","v89234","nuvmzp","1gz70xg","9e9ivx","2bwtzy","1hyfx7x","6n7j50","1kj7lfb","10m9xeu","vz7hjd","1fe7a5q","1rn5q1r","10488qs","1iruc8t","1ropbjl","uw59u","jxzr5i","oylsik","1otr2jl","1c8n994","qtw155","v0l3hm","g4gku8","1rr4qq7","6xhk3s","rxqrcl","tj0ten","ist4u3","1gprdgz","10t7hia","mzqdl","kwpx34","1k2cjfc","1vhk1ks","6td9kr","r5ic95","15uy5yv","1p8nkc0","5j8bii","1am0aiv","1g7m0tk","d8bdto","y8aj3r","60hakz","i29ckm","acwcvw","1baulvz","f8wsfj","mhvv8m","m6999o","i9gxme","1m9j9gf","1sy8kpn","19vbshk","l9onyx","79elbk","1q1yk17","g7rb99","k008qs","bsn42l","11cwn6f","ghw4n2","1c5cfvc","htgkrt","e64et","9zaqp9","16fq4rz","1kjk1j2","88g286","12yx39b","4hu8jm","1wqz2f4","yl3z84","1q3gjvc","nc39ev","amd09y","ru1vxe","ajnadh","1ri25x2","12fr9lp","1hfdzay","4g4cvq","m6xlts","1ahhg7f","fwqvlz","17xtcya","x15j1o","1705lsu","1iwv8en","b7n1on","1b9egsl","1rj8to8","4w91ra","wg1cha","1ubp8k9","1egl8em","vdv0al","1i2y565","o6xoe7","1fanzo5","53u6y8","1m50asq","z3e15g","uwwqev","1ly73wi","7y3qfv","l72opv","4skfbu","1fcn4th","13zu7ev","f7l8cz","16ogagc","17ai7jg","8i9d0s","1nwzsjy","10698na","nhjhh0","1nuro5j","1w5cs23","4brsb6","uhuo44","exrw3m","1a48zt4","1xdhyk6","vuqh7u","1l44abu","jcw7oy","10raysz","ar1l6a","1ede5it","mn5hq9","1qmnftd","1ho5u4o","13o0c9t","1yo489b","ulr03x","1bymuyk","1waixk9","1f7ibof","l2ztic","19lv58h","mgtjo2","1wr3we4","y3sf94","1bnxwmn","1i8g3m4","3qijnq","uqyvli","1uqjmks","1bvtpon","1vxca1d","1vkm6nb","1ox9jel","1riqqik","2fg4z9","11n4cex","1ifw933","1rjmmt7","rqb9bm","19hdyf3","15g2oxy","2b3w4o","14b9hti","1j8dw05","1vm5oi9","32rbo2","llk6mt","1s4ffep","pdw9fk","1txwxcy","1soubk3"],"abraConfig":[["vi-ads-et",[[257698038,"2_remainder",1],[4037269258,null,0]]],["messaging-optimizely",[[4294967296,"1",0]]],["dfp_adslot4v2",[[4294967296,"1_external",1]]],["DFP_als",[[4294967296,"1_als",1]]],["DFP_als_home",[[214748365,"1_als",1],[214748365,"1_als",1],[429496730,"1_als",1],[429496729,"1_als",1],[858993459,"1_als",1],[1073741824,"1_als",1],[1073741824,null,0]]],["medianet_toggle",[[4294967296,"0_default",0]]],["amazon_toggle",[[4294967296,null,0]]],["index_toggle",[[4294967296,"1_block",0]]],["dfp_home_toggle",[[4294967296,null,0]]],["dfp_story_toggle",[[4294967296,null,0]]],["dfp_interactive_toggle",[[4294967296,null,0]]],["FREEX_Best_In_Show",[[2147483648,"0_Control",1],[2147483648,"1_Best",1]]],["MKT_dfp_ocean_language",[[2147483648,"0_control",1],[2147483648,"1_language",1]]],["MC_magnolia_0519",[[4294967296,"1_magnolia",1]]],["STORY_topical_recirc",[[2147483648,"0_control",1],[2147483648,"1_variant",1]]],["HOME_timesExclusive",[[2147483648,"0_control",1],[2147483648,"1_variant",1]]],["ON_daily_digest_NL_0719",[[644245095,"0_control",1],[644245094,"1_daily_digest",1],[3006477107,null,0]]],["HOME_discovery_automation",[[2147483648,"0_control",1],[2147483648,"1_automation",1]]],["MKT_GateDockMsgTap",[[1431655766,"0_control",1],[1431655765,"2_BAUDockTapGate",1],[1431655765,"4_BAUDockRBGate",1]]],["FREEX_RegiWall_Messaging",[[214748365,"0_Control",1],[214748365,"1_Continue_Reading",1],[214748365,"2_For_Free",1],[214748365,"3_Keep_Reading",1],[214748364,"4_Continue_Reading_NoHeader",1],[214748365,"5_For_Free_NoHeader",1],[214748365,"6_Keep_Reading_NoHeader",1],[858993459,"0_Control",1],[858993459,"6_Keep_Reading_NoHeader",1],[1073741824,null,0]]],["MC_briefing_bar_anon_test_0519",[[1431655766,"0_control",1],[1431655765,"1_subscribe",1],[1431655765,"2_regi",1]]],["MC_briefing_bar_regi_test_0519",[[2147483648,"0_control",1],[2147483648,"1_subscribe",1]]],["SEARCH_FACET_DROPDOWN",[[2147483648,"0_FACET_MULTI_SELECT",1],[2147483648,"1_DYNAMIC_FACET_SELECT",1]]],["VG_gift_upsell_x_only",[[429496730,"0_control",1],[3865470566,"1_upsell",1]]],["ON_allocator_0719",[[356482286,"ON_login_interrupt_0819-0_control",0],[356482286,"ON_login_interrupt_0819-1_app_experience",0],[356482285,"ON_login_interrupt_0819-2_login_value",0],[356482286,"ON_login_interrupt_0819-3_login_return",0],[356482285,"ON_app_dl_getstarted_0819-0_control",0],[356482286,"ON_app_dl_getstarted_0819-1_appExperience",0],[356482285,"ON_app_dl_getstarted_0819-2_bestApp",0],[356482286,"ON_app_dl_getstarted_0819-3_magicLink",0],[236223201,"ON_app_dl_mc4-6_0819-0_control",0],[236223202,"ON_app_dl_mc4-6_0819-1_dockTrunc",0],[236223201,"ON_app_dl_mc4-6_0819-2_newDock",0],[236223201,"ON_app_dl_mc4-6_0819-3_stdNew",0],[236223201,"ON_app_dl_mc4-6_0819-4_stdDockTrunc",0],[236223202,"ON_app_dl_mc4-6_0819-5_truncator",0],[25769803,null,0]]],["MKT_dfp_ocean_bundle_light",[[1431655766,"0_control",1],[1431655765,"1_design",1],[1431655765,"2_design_light",1]]],["MKT_dfp_ocean_bundle_family",[[1431655766,"0_control",1],[1431655765,"1_design",1],[1431655765,"2_family",1]]],["HL_sample",[[2147483648,"0",1],[2147483648,"1",1]]],["HL_100000006614214",[[2147483648,"0",1],[2147483648,"1",1]]],["HL_100000006641840",[[2147483648,"0",1],[2147483648,"1",1]]]]}]); })();;(function () { var _f=function(e){var r=function(){var r=e.url;try{r+=window.location.search.slice(1).split("&").reduce(function(e,r){return"ip-override"===r.split("=")[0]?"?"+r:e},"")}catch(e){console.warn(e)}var n=new XMLHttpRequest;for(var t in n.withCredentials=!0,n.open("POST",r,!0),n.setRequestHeader("Content-Type","application/json"),e.headers)n.setRequestHeader(t,e.headers[t]);return n.send(e.body),n};window.userXhrObject=r(),window.userXhrRefresh=function(){return window.userXhrObject=r(),window.userXhrObject}};;_f.apply(null, [{"url":"https://samizdat-graphql.nytimes.com/graphql/v2","body":"{\"operationName\":\"UserQuery\",\"variables\":{},\"query\":\" query UserQuery { user { __typename profile { displayName } userInfo { regiId entitlements demographics { emailSubscriptions wat bundleSubscriptions { bundle inGrace promotion source } } } subscriptionDetails { graceStartDate graceEndDate isFreeTrial hasQueuedSub startDate endDate status entitlements } } } \"}","headers":{"nyt-app-type":"project-vi","nyt-app-version":"0.0.5","nyt-token":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs+/oUCTBmD/cLdmcecrnBMHiU/pxQCn2DDyaPKUOXxi4p0uUSZQzsuq1pJ1m5z1i0YGPd1U1OeGHAChWtqoxC7bFMCXcwnE1oyui9G1uobgpm1GdhtwkR7ta7akVTcsF8zxiXx7DNXIPd2nIJFH83rmkZueKrC4JVaNzjvD+Z03piLn5bHWU6+w+rA+kyJtGgZNTXKyPh6EC6o5N+rknNMG5+CdTq35p8f99WjFawSvYgP9V64kgckbTbtdJ6YhVP58TnuYgr12urtwnIqWP9KSJ1e5vmgf3tunMqWNm6+AnsqNj8mCLdCuc5cEB74CwUeQcP2HQQmbCddBy2y0mEwIDAQAB"}}]); })();;100*Math.random()<=vi.env.SENTRY_SAMPLE_RATE?(window.INSTALL_RAVEN=!0,window.nyt_errors={ravenInstalled:!1,list:[],tags:[]},window.onerror=function(n,r,o,w,i){if(!window.nyt_errors.ravenInstalled){var t={err:i,data:{}};window.nyt_errors.list.push(t)}}):window.INSTALL_RAVEN=!1;;(function () { var _f=function(t,e,n){var a=window,A=document,o=function(t){var e=A.createElement("style");e.appendChild(A.createTextNode(t)),A.querySelector("head").appendChild(e)},r=function(t,e,n,a,A){var r=new XMLHttpRequest;r.open("GET",t,!0),r.onreadystatechange=function(){if(4===r.readyState&&200===r.status){o(r.responseText);try{localStorage.setItem("nyt-fontFormat",e),localStorage.setItem(a,n)}catch(t){return}localStorage.setItem(A,r.responseText)}return!0},r.send(null)},c=function(e,n){var A;try{A=localStorage.getItem("nyt-fontFormat")}catch(t){}A||(A=function(){if(!("FontFace"in a))return!1;var t=new FontFace("t",'url("data:application/font-woff2;base64,d09GMgABAAAAAADcAAoAAAAAAggAAACWAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABk4ALAoUNAE2AiQDCAsGAAQgBSAHIBtvAcieB3aD8wURQ+TZazbRE9HvF5vde4KCYGhiCgq/NKPF0i6UIsZynbP+Xi9Ng+XLbNlmNz/xIBBqq61FIQRJhC/+QA/08PJQJ3sK5TZFMlWzC/iK5GUN40psgqvxwBjBOg6JUSJ7ewyKE2AAaXZrfUB4v+hze37ugJ9d+DeYqiDwVgCawviwVFGnuttkLqIMGivmDg") format("woff2")',{});return t.load().catch(function(){}),"loading"==t.status||"loaded"==t.status}()?"woff2":"woff");for(var c=0;c<e.length;c++){var i=e[c],l="shared"!==i?"-"+i:"",d="nyt-fontHash"+l,s="nyt-fontFace"+l,f=t[i][A],u=localStorage.getItem(d),g=localStorage.getItem(s);if(u===f.hash&&g)o(g);else{var h=function(t,e,n,a,A){return function(){r(t,e,n,a,A)}}(f.url,A,f.hash,d,s);n?h():document.addEventListener("DOMContentLoaded",h)}}};c(e),window.addEventListener("load",function(){c(n,!0)})};;_f.apply(null, [{"shared":{"woff":{"hash":"f2adc73415c5bbb437e993c14559e70e","url":"/vi-assets/static-assets/shared-woff.fonts-f2adc73415c5bbb437e993c14559e70e.css"},"woff2":{"hash":"22b34a6a6fd840943496b658184afdd3","url":"/vi-assets/static-assets/shared-woff2.fonts-22b34a6a6fd840943496b658184afdd3.css"}},"story":{"woff":{"hash":"3c668927c32fbefb440b4024d5da6351","url":"/vi-assets/static-assets/story-woff.fonts-3c668927c32fbefb440b4024d5da6351.css"},"woff2":{"hash":"acec1a902e1795b20a0204af82726cd2","url":"/vi-assets/static-assets/story-woff2.fonts-acec1a902e1795b20a0204af82726cd2.css"}},"opinion":{"woff":{"hash":"dfc5106c9c0aaa76688687e664474b04","url":"/vi-assets/static-assets/opinion-woff.fonts-dfc5106c9c0aaa76688687e664474b04.css"},"woff2":{"hash":"e2b27ff317927dfd77bdd429409627e0","url":"/vi-assets/static-assets/opinion-woff2.fonts-e2b27ff317927dfd77bdd429409627e0.css"}},"tmag":{"woff":{"hash":"4634f3c7ddebb9113b69d4578d9a0ba0","url":"/vi-assets/static-assets/tmag-woff.fonts-4634f3c7ddebb9113b69d4578d9a0ba0.css"},"woff2":{"hash":"8622c93c260fa93b229b7249df708fb1","url":"/vi-assets/static-assets/tmag-woff2.fonts-8622c93c260fa93b229b7249df708fb1.css"}},"mag":{"woff":{"hash":"109e6d301ed49c8078086b5892696adf","url":"/vi-assets/static-assets/mag-woff.fonts-109e6d301ed49c8078086b5892696adf.css"},"woff2":{"hash":"fb42c728dc70cc4ef6010a60cb10b0bd","url":"/vi-assets/static-assets/mag-woff2.fonts-fb42c728dc70cc4ef6010a60cb10b0bd.css"}},"well":{"woff":{"hash":"f0e613b89006e99b4622d88aa5563a81","url":"/vi-assets/static-assets/well-woff.fonts-f0e613b89006e99b4622d88aa5563a81.css"},"woff2":{"hash":"77806b85de524283fe742b916c9d0ee4","url":"/vi-assets/static-assets/well-woff2.fonts-77806b85de524283fe742b916c9d0ee4.css"}}},["shared","story"],["opinion","tmag","mag","well"]]); })();;(function () { function swgDataLayer(e){return!!window.dataLayer&&((window.dataLayer=window.dataLayer||[]).push({event:"impression",module:e}),!0)}function checkSwgOptOut(){if(!window.localStorage)return!1;var e=window.localStorage.getItem("nyt-swgOptOut");if(!e)return!1;var t=parseInt(e,10);return((new Date).getTime()-t)/864e5<1||(window.localStorage.removeItem("nyt-swgOptOut"),!1)}function swgDeferredAccount(e,t){return e.completeDeferredAccountCreation({entitlements:t,consent:!1}).then(function(e){var t=vi.env.AUTH_HOST+"/svc/account/auth/v1/swg-dal-web",n=e.purchaseData.raw.data?e.purchaseData.raw.data:e.purchaseData.raw,o=JSON.parse(n),a={package_name:o.packageName,product_id:o.productId,purchase_token:o.purchaseToken,google_id_token:e.userData.idToken,google_user_email:e.userData.email,google_user_id:e.userData.id,google_user_name:e.userData.name},r=new XMLHttpRequest;r.withCredentials=!0,r.open("POST",t,!0),r.setRequestHeader("Content-Type","application/json"),r.send(JSON.stringify(a)),r.onload=function(){200===r.status?(swgDataLayer({name:"swg",context:"Deferred",label:"Seamless Signin",region:"swg-modal"}),e.complete().then(function(){window.location.reload(!0)})):(e.complete(),window.location=encodeURI(vi.env.AUTH_HOST+"/get-started/swg-link?redirect="+window.location.href))}}).catch(function(){return!!window.localStorage&&(!window.localStorage.getItem("nyt-swgOptOut")&&(window.localStorage.setItem("nyt-swgOptOut",(new Date).getTime()),!0))}),!0}function loginWithGoogle(){return"undefined"!=typeof window&&(-1===document.cookie.indexOf("NYT-S")&&(!0!==checkSwgOptOut()&&(!!window.SWG&&((window.SWG=window.SWG||[]).push(function(e){return e.init(vi.env.SWG_PUBLICATION_ID),e.getEntitlements().then(function(t){if(void 0===t||!t.raw)return!1;var n={entitlements_token:t.raw};return window.swgUserInfoXhrObject.withCredentials=!0,window.swgUserInfoXhrObject.open("POST",vi.env.AUTH_HOST+"/svc/account/auth/v1/login-swg-web",!0),window.swgUserInfoXhrObject.setRequestHeader("Content-Type","application/json"),window.swgUserInfoXhrObject.send(JSON.stringify(n)),window.swgUserInfoXhrObject.onload=function(){switch(window.swgUserInfoXhrObject.status){case 200:return swgDataLayer({name:"swg",context:"Seamless",label:"Seamless Signin",region:"login"}),window.location.reload(!0),!0;case 412:return swgDeferredAccount(e,t);default:return!1}},t}).catch(function(){return!1}),!0}),!0))))}var _f=function(){if(window.swgUserInfoXhrObject.checkSwgResponse=!1,-1===document.cookie.indexOf("NYT-S")){var e=document.createElement("script");e.src="https://news.google.com/swg/js/v1/swg.js",e.setAttribute("subscriptions-control","manual"),e.setAttribute("async",!0),e.onload=function(){loginWithGoogle()},document.getElementsByTagName("head")[0].appendChild(e)}};;_f.apply(null, []); })();
+ </script>
+
+ <link data-rh="true" rel="shortcut icon" href="/vi-assets/static-assets/favicon-4bf96cb6a1093748bf5b3c429accb9b4.ico"/><link data-rh="true" rel="apple-touch-icon" href="/vi-assets/static-assets/apple-touch-icon-319373aaf4524d94d38aa599c56b8655.png"/><link data-rh="true" rel="apple-touch-icon-precomposed" sizes="144×144" href="/vi-assets/static-assets/ios-ipad-144x144-319373aaf4524d94d38aa599c56b8655.png"/><link data-rh="true" rel="apple-touch-icon-precomposed" sizes="114×114" href="/vi-assets/static-assets/ios-iphone-114x144-61d373c43aa8365d3940c5f1135f4597.png"/><link data-rh="true" rel="apple-touch-icon-precomposed" href="/vi-assets/static-assets/ios-default-homescreen-57x57-7cccbfb151c7db793e92ea58c30b9e72.png"/><link data-rh="true" rel="alternate" itemprop="mainEntityOfPage" hrefLang="en-US" href="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><link data-rh="true" rel="canonical" href="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"/><link data-rh="true" rel="alternate" href="android-app://com.nytimes.android/nytimes/reader/id/100000006583622"/><link data-rh="true" rel="amphtml" href="https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.amp.html"/><link data-rh="true" rel="alternate" type="application/json+oembed" href="https://www.nytimes.com/svc/oembed/json/?url=https%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html" title="She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database."/>
+ <script data-rh="true" >
+ if (typeof testCookie === 'undefined') {
+ var testCookie = function (name) {
+ var match = document.cookie.match(new RegExp(name + '=([^;]+)'));
+ if (match) return match[1];
+ }
+ }
+</script><script data-rh="true" >if (window.NYTD.Abra('dfp_story_toggle') !== '1_block') {
+
+ if (testCookie('nyt-gdpr') !== '1') {
+ var gptScript = document.createElement('script');
+ gptScript.async = 'async';
+ gptScript.src = '//securepubads.g.doubleclick.net/tag/js/gpt.js';
+ document.head.appendChild(gptScript);
+ }
+ }</script><script data-rh="true" >if (window.NYTD.Abra('dfp_story_toggle') !== '1_block') {
+
+ var googletag = googletag || {};
+ googletag.cmd = googletag.cmd || [];
+
+ if (testCookie('nyt-gdpr') == '1') {
+ googletag.cmd.push(function() {
+ googletag.pubads().setRequestNonPersonalizedAds(1);
+ });
+ }
+ }</script><script data-rh="true" >if (window.NYTD.Abra('dfp_story_toggle') !== '1_block') {
+ (function () { var _f=function(){var t,e,o=50,n=50;function i(t){if(!document.getElementById("3pCheckIframeId")){if(t||(t=1),!document.body){if(t>o)return;return t+=1,setTimeout(i.bind(null,t),n)}var e,a,r;e="https://static01.nyt.com/ads/tpc-check.html",a=document.body,(r=document.createElement("iframe")).src=e,r.id="3pCheckIframeId",r.style="display:none;",r.height=0,r.width=0,a.insertBefore(r,a.firstChild)}}function a(t){if("https://static01.nyt.com"===t.origin)try{"3PCookieSupported"===t.data&&googletag.cmd.push(function(){googletag.pubads().setTargeting("cookie","true")}),"3PCookieNotSupported"===t.data&&googletag.cmd.push(function(){googletag.pubads().setTargeting("cookie","false")})}catch(t){}}function r(){if(function(){if(Object.prototype.toString.call(window.HTMLElement).indexOf("Constructor")>0)return!0;if("[object SafariRemoteNotification]"===(!window.safari||safari.pushNotification).toString())return!0;try{return window.localStorage&&/Safari/.test(window.navigator.userAgent)}catch(t){return!1}}()){try{window.openDatabase(null,null,null,null)}catch(e){return t(),!0}try{localStorage.length?e():(localStorage.x=1,localStorage.removeItem("x"),e())}catch(o){navigator.cookieEnabled?t():e()}return!0}}!function(){try{googletag.cmd.push(function(){googletag.pubads().setTargeting("cookie","unknown")})}catch(t){}}(),t=function(){try{googletag.cmd.push(function(){googletag.pubads().setTargeting("cookie","private")})}catch(t){}}||function(){},e=function(){window.addEventListener("message",a,!1),i(0)}||function(){},function(){if(window.webkitRequestFileSystem)return window.webkitRequestFileSystem(window.TEMPORARY,1,e,t),!0}()||r()||function(){if(!window.indexedDB&&(window.PointerEvent||window.MSPointerEvent))return t(),!0}()||e()};;_f.apply(null, []); })();
+ }</script><script data-rh="true" >(function() {
+ var AdSlot4=function(){"use strict";function D(n,i,o){var t=document.getElementsByTagName("head")[0],e=document.createElement("script");i&&(e.onload=i),o&&(e.onerror=o),e.src=n,e.async=!0,t.appendChild(e)}return function(){var A=window.AdSlot4||{};A.cmd=A.cmd||[];var b=!1;if(A.loadScripts)return A;function z(t){"art, oak"!==t&&"art,oak"!==t||(t="art"),A.cmd.push(function(){A.events.subscribe({name:"AdDefined",scope:"all",callback:function(n){var o,i=[-1];n.sizes.forEach(function(n){n[0]<window.innerWidth&&n[0]>i[0]&&(i=[]).push(n)}),i[0][1]&&window.apstag.fetchBids({slots:[{slotID:n.id,slotName:"".concat(n.id,"_").concat(t,"_web"),sizes:(o=i[0][1],Array.isArray(o)?[[300,250],[728,90],[970,90],[970,250]].filter(function(i){return o.some(function(n){return n[0]===i[0]&&n[1]===i[1]})}):(console.warn("filterSizes() did not receive an array"),[]))}]},function(){window.googletag.cmd.push(function(){window.apstag.setDisplayBids()})})}})})}return A.loadScripts=function(n){var i,o,t,e,d,a,c,s,r=n||{},w=r.loadMnet,u=void 0===w||w,l=r.loadAmazon,p=void 0===l||l,f=r.loadBait,m=void 0===f||f,v=r.section,g=void 0===v?"none":v,h=r.pageViewId,y=void 0===h?"":h,B=r.pageType,x=void 0===B?"":B;b||("1"===(c="nyt-gdpr",(s=document.cookie.match(new RegExp("".concat(c,"=([^;]+)"))))?s[1]:"")||(d=document.referrer||"",(a=/([a-zA-Z0-9_\-.]+)(@|%40)([a-zA-Z0-9_\-.]+).([a-zA-Z]{2,5})/).test(d)||a.test(window.location.href))||(!u||window.advBidxc&&window.advBidxc.isLoaded||(t=y,e="8CU2553YN",window.innerWidth<740&&(e="8CULO58R6"),D("https://contextual.media.net/bidexchange.js?cid=".concat(e,"&dn=").concat("www.nytimes.com","&https=1"),function(){window.advBidxc&&window.advBidxc.isLoaded||console.warn("Media.net not loading properly")},function(){A.cmd.push(function(){A.events.publish({name:"BidderError",value:{type:"Mnet"}})})}),window.advBidxc=window.advBidxc||{},window.advBidxc.renderAd=function(){},window.advBidxc.startTime=(new Date).getTime(),window.advBidxc.customerId={mediaNetCID:e},window.advBidxc.misc={isGptDisabled:1},t&&(window.advBidxc.misc.keywords=t)),p&&!window.apstag&&(i=g,o=x,function(o,t){function n(n,i){t[o]._Q.push([n,i])}t[o]||(t[o]={init:function(){n("i",arguments)},fetchBids:function(){n("f",arguments)},setDisplayBids:function(){},targetingKeys:function(){return[]},_Q:[]})}("apstag",window),D("//c.amazon-adsystem.com/aax2/apstag.js",function(){window.apstag||console.warn("A9 not loading properly")},function(){A.cmd.push(function(){A.events.publish({name:"BidderError",value:{type:"A9"}})})}),window.apstag.init({pubID:"3030",adServer:"googletag",params:{si_section:i}}),z(o))),m&&D("https://static01.nyt.com/ads/google/adsbygoogle.js",function(){},function(){A.cmd.push(function(){A.events.publish({name:"AdEmpty",value:{type:"AdBlockOn"}})})}),b=!0)},window.AdSlot4=A}()}();
+ AdSlot4.loadScripts({
+ loadMnet: window.NYTD.Abra('medianet_toggle') !== '1_block',
+ loadAmazon: window.NYTD.Abra('amazon_toggle') !== '1_block',
+ section: 'nyregion',
+ pageType: 'art,oak',
+ pageViewId: window.NYTD.PageViewId.current,
+ });
+ (function () { var _f=function(e){var o=performance.navigation&&1===performance.navigation.type;function t(){return window.matchMedia("(max-width: 739px)").matches}function n(e){var n,r,i,d,a,p,u=function(){var e=window.userXhrObject&&""!==window.userXhrObject.responseText&&JSON.parse(window.userXhrObject.responseText).data||null,o=null;return e&&e.user&&e.user.userInfo&&(o=e.user.userInfo.demographics),o}();return u?(r=e,d=(n=u)&&n.emailSubscriptions,(a=n&&n.bundleSubscriptions)&&r&&(r.sub="reg",d&&d.length&&(r.em=d.toString().toLowerCase()),n.wat&&(r.wat=n.wat.toLowerCase()),a&&a.length&&a[0].bundle&&(i=a[0],r.sub=i.bundle.toLowerCase(),i.source&&(r.subsrc=i.source.toLowerCase()),i.promotion&&(r.subprm=i.promotion),i.in_grace&&(r.grace=i.in_grace.toString()))),e=r):e.sub="anon",t()?(e.prop="mnyt",e.plat="mweb",e.ver="mvi"):(e.prop="nyt",e.plat="web",e.ver="vi"),"hp"===e.typ&&(document.referrer&&(e.topref=document.referrer),o&&(e.refresh="manual")),e.abra_dfp=(p=document.documentElement.getAttribute("data-nyt-ab"))?p.split(" ").reduce(function(e,o){var t=o.split("="),n=t[0].toLowerCase(),r=t[1];return(n.indexOf("dfp")>-1||n.indexOf("redbird")>-1)&&e.push(n+"_"+r),e},[]):"",e.page_view_id=window.NYTD.PageViewId&&window.NYTD.PageViewId.current,e}var r=e||{},i=r.adTargeting||{},d=r.adUnitPath||"/29390238/nyt/homepage",a=r.offset||400,p=r.hideTopAd||t(),u=r.lockdownAds||!1,s=r.sizeMapping||{top:[[970,["fluid",[728,90],[970,90],[970,250],[1605,300]]],[728,["fluid",[728,90],[1605,300]]],[0,["fluid",[300,250],[300,420]]]],fp1:[[0,[195,250]]],fp2:[[0,[195,250]]],fp3:[[0,[195,250]]],interstitial:[[0,[[1,1],[640,480]]]],mktg:[[1020,[300,250]],[0,[]]],pencil:[[728,[[336,46]],[0,[]]]],pp_edpick:[[0,["fluid"]]],pp_morein:[[0,["fluid"],[210,218]]],ribbon:[[0,["fluid"]]],sponsor:[[765,[150,50]],[0,[320,25]]],supplemental:[[1020,[[300,250],[300,600]]],[0,[]]],default:[[970,["fluid",[728,90],[970,90],[970,250],[1605,300]]],[728,["fluid",[728,90],[300,250],[1605,300]]],[0,["fluid",[300,250],[300,420]]]]},l=r.dfpToggleName||"dfp_home_toggle";window.AdSlot4=window.AdSlot4||{},window.AdSlot4.cmd=window.AdSlot4.cmd||[],window.AdSlot4.cmd.push(function(){window.AdSlot4.init({adTargeting:n(i),adUnitPath:d,sizeMapping:s,offset:a,haltDFP:"1_block"===window.NYTD.Abra(l),hideTopAd:p,lockdownAds:u}),window.NYTD.Abra.reportExposure("dfp_adslot4v2")})};;_f.apply(null, [{"adTargeting":{"edn":"us","sov":"3","test":"projectvi","ver":"vi","hasVideo":false,"template":"article","als_test":"1565027040168","prop":"nyt","plat":"web","brandsensitive":"false","org":"policedepartmentnyc","geo":"newyorkcity","des":"juveniledelinquency,facialrecognitionsoftware,privacy,surveillanceofcitizensbygovern,police,civilrightsandliberties","auth":"aliwatkins,josephgoldstein","coll":"newyork,usnews,technology,techandsociety","artlen":"medium","ledemedsz":"none","typ":"art,oak","section":"nyregion","si_section":"nyregion","id":"100000006583622","pt":"nt10,nt15,nt16,nt18,nt3,nt4,nt9","gscat":"neg_mastercard,gs_law_misc,neg_chanel,gv_crime,neg_hearts,gs_tech,gs_law,gs_tech_computing,neg_ibmtest,gs_tech_phones,neg_samsung,gs_education"},"adUnitPath":"/29390238/nyt/nyregion/","dfpToggleName":"dfp_story_toggle"}]); })();
+ })();</script><script data-rh="true" id="als-svc">var alsVariant = window.NYTD.Abra('DFP_als');
+ if (alsVariant != null && alsVariant.match(/(0_control|1_als)/)) {
+ window.NYTD.Abra.reportExposure('DFP_als');
+ }
+ if (window.NYTD.Abra('DFP_als') === '1_als') {
+ (function () { var _f=function(){window.googletag=window.googletag||{},googletag.cmd=googletag.cmd||[];var e=new XMLHttpRequest,t="prd"===window.vi.env.ENVIRONMENT?"als-svc.nytimes.com":"als-svc.dev.nytimes.com",n=document.querySelector('[name="nyt_uri"]'),o=null==n?"":encodeURIComponent(n.content),l=document.querySelector('[name="template"]'),s=document.querySelector('[name="prop"]'),a=document.querySelector('[name="plat"]'),i=null==l||null==l.content?"":l.content,c=null==s||null==s.content?"nyt":s.content,r=null==a||null==a.content?"web":a.content;window.innerWidth<740&&(c="mnyt",r="mweb"),"/"===location.pathname&&(o=encodeURIComponent("https://www.nytimes.com/pages/index.html"));var d=window.localStorage.getItem("als_test_clientside");void 0!==d&&window.googletag.cmd.push(function(){googletag.pubads().setTargeting("als_test_clientside",d)}),e.open("GET","https://"+t+"/als?uri="+o+"&typ="+i+"&prop="+c+"&plat="+r),e.withCredentials=!0,e.send(),e.onreadystatechange=function(){if(4===e.readyState)if(200===e.status){var t=JSON.parse(e.responseText);window.googletag.cmd.push(function(){void 0!==t.als_test_clientside&&(googletag.pubads().setTargeting("als_test_clientside",t.als_test_clientside),window.localStorage.setItem("als_test_clientside","ls-"+t.als_test_clientside)),Object.keys(t).forEach(function(e){"User"===e&&void 0!==t[e]&&window.localStorage.setItem("UTS_User",JSON.stringify(t[e]))})})}else{console.error("Error "+e.responseText);(window.dataLayer=window.dataLayer||[]).push({event:"impression",module:{name:"timing",context:"script-load",label:"alsService-als-error"}})}}};;_f.apply(null, []); })();
+ }
+ </script>
+ <link rel="stylesheet" href="/vi-assets/static-assets/global-42db6c8821fec0e2b3837b2ea2ece8fe.css" />
+ <style>.css-1dv1kvn{border:0;-webkit-clip:rect(0 0 0 0);clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;}.css-v89234{overflow:hidden;height:100%;}.css-nuvmzp{font-size:14.25px;font-family:nyt-franklin,helvetica,arial,sans-serif;font-weight:700;text-transform:uppercase;-webkit-letter-spacing:0.7px;-moz-letter-spacing:0.7px;-ms-letter-spacing:0.7px;letter-spacing:0.7px;line-height:19px;}.css-nuvmzp:hover{-webkit-text-decoration:underline;text-decoration:underline;}.css-1gz70xg{border-left:1px solid #ccc;color:#326891;height:12px;margin-left:8px;padding-left:8px;}.css-9e9ivx{display:none;font-size:10px;margin-left:auto;text-transform:uppercase;}.hasLinks .css-9e9ivx{display:block;}@media (min-width:740px){.hasLinks .css-9e9ivx{margin:none;position:absolute;right:20px;}}@media (min-width:1024px){.hasLinks .css-9e9ivx{display:none;}}.css-2bwtzy{display:inline-block;padding:6px 4px 4px;margin-bottom:12px;font-size:12px;border-radius:3px;-webkit-transition:background 0.6s ease;transition:background 0.6s ease;}.css-2bwtzy:hover{background-color:#f7f7f7;}.css-1hyfx7x{display:none;}.css-6n7j50{display:inline;}.css-1kj7lfb{display:none;}@media (min-width:1024px){.css-1kj7lfb{display:inline-block;margin-right:7px;}}.css-10m9xeu{display:block;width:16px;height:16px;}.css-vz7hjd{border:0;-webkit-clip:rect(0 0 0 0);clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;}.css-1fe7a5q{display:inline-block;height:16px;vertical-align:sub;width:16px;}.css-1rn5q1r{border:0;-webkit-clip:rect(0 0 0 0);clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;background-color:transparent;color:#000;font-size:11px;font-weight:700;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;padding:7px 9px 9px;background:#fff;display:inline-block;left:44px;text-transform:uppercase;-webkit-transition:none;transition:none;}.css-1rn5q1r:active,.css-1rn5q1r:focus{-webkit-clip:auto;clip:auto;overflow:visible;width:auto;height:auto;}.css-1rn5q1r::-moz-focus-inner{padding:0;border:0;}.css-1rn5q1r:-moz-focusring{outline:1px dotted;}.css-1rn5q1r:disabled,.css-1rn5q1r.disabled{opacity:0.5;cursor:default;}.css-1rn5q1r:active,.css-1rn5q1r.active{background-color:#f7f7f7;}@media (min-width:740px){.css-1rn5q1r:hover{background-color:#f7f7f7;}}.css-1rn5q1r:focus{margin-top:3px;padding:8px 8px 6px;}@media (min-width:1024px){.css-1rn5q1r{left:112px;}}.css-10488qs{display:none;}@media (min-width:1024px){.css-10488qs{display:inline-block;position:relative;}}.css-1iruc8t{list-style:none;margin:0;padding:0;}.css-1ropbjl::before{background-color:$white;border-bottom:1px solid #e2e2e2;border-top:2px solid #e2e2e2;content:'';display:block;height:1px;margin-top:0;}@media (min-width:1150px){.css-1ropbjl{margin:0 auto;max-width:1200px;padding:0 3% 9px;}}.NYTApp .css-1ropbjl{display:none;}@media print{.css-1ropbjl{display:none;}}.css-uw59u{padding:0 20px;}@media (min-width:740px){.css-uw59u{padding:0 3%;}}@media (min-width:1150px){.css-uw59u{padding:0;}}.css-jxzr5i{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row;-ms-flex-flow:row;flex-flow:row;}.css-oylsik{display:block;height:44px;vertical-align:middle;width:184px;}.css-1otr2jl{margin:18px 0 0 auto;}.css-1c8n994{color:#6288a5;font-family:nyt-franklin;font-size:11px;font-style:normal;font-weight:400;line-height:11px;-webkit-text-decoration:none;text-decoration:none;}.css-qtw155{display:block;}@media (min-width:1150px){.css-qtw155{display:none;}}.css-v0l3hm{display:none;}@media (min-width:1150px){.css-v0l3hm{display:block;}}.css-g4gku8{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;margin-top:10px;min-width:600px;}.css-1rr4qq7{-webkit-flex:1;-ms-flex:1;flex:1;}.css-6xhk3s{border-left:1px solid #e2e2e2;-webkit-flex:1;-ms-flex:1;flex:1;padding-left:15px;}.css-rxqrcl{color:#333;font-size:13px;font-weight:700;font-family:nyt-franklin;height:25px;line-height:15px;margin:0;text-transform:uppercase;width:150px;}.css-tj0ten{margin-bottom:5px;white-space:nowrap;}.css-tj0ten:last-child{margin-bottom:10px;}.css-ist4u3.desktop{display:none;}@media (min-width:740px){.css-ist4u3.desktop{display:block;}.css-ist4u3.smartphone{display:none;}}.css-1gprdgz{list-style:none;margin:0;padding:0;-webkit-columns:2;columns:2;padding:0 0 15px;}.css-10t7hia{height:34px;line-height:34px;list-style-type:none;}.css-10t7hia.desktop{display:none;}@media (min-width:740px){.css-10t7hia.desktop{display:block;}.css-10t7hia.smartphone{display:none;}}.css-mzqdl{color:#333;display:block;font-family:nyt-franklin;font-size:15px;font-weight:500;height:34px;line-height:34px;-webkit-text-decoration:none;text-decoration:none;text-transform:capitalize;}.css-kwpx34{color:#000;display:inline-block;font-family:nyt-franklin;-webkit-text-decoration:none;text-decoration:none;text-transform:capitalize;width:150px;font-size:14px;font-weight:500;height:23px;line-height:16px;}.css-kwpx34:hover{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline;}body.dark .css-kwpx34{color:#fff;}.css-1k2cjfc{color:#000;display:inline-block;font-family:nyt-franklin;-webkit-text-decoration:none;text-decoration:none;text-transform:capitalize;width:150px;font-size:16px;font-weight:700;height:25px;line-height:15px;padding-bottom:0;}.css-1k2cjfc:hover{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline;}body.dark .css-1k2cjfc{color:#fff;}.css-1vhk1ks{color:#000;display:inline-block;font-family:nyt-franklin;-webkit-text-decoration:none;text-decoration:none;text-transform:capitalize;width:150px;font-size:11px;font-weight:500;height:23px;line-height:21px;}.css-1vhk1ks:hover{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline;}body.dark .css-1vhk1ks{color:#fff;}.css-6td9kr{list-style:none;margin:0;padding:0;border-top:1px solid #e2e2e2;margin-top:2px;padding-top:10px;}.css-r5ic95{display:inline-block;height:13px;width:13px;margin-right:7px;vertical-align:middle;}.css-15uy5yv{border-top:1px solid #ebebeb;padding-top:9px;}.css-1p8nkc0{color:#999;font-family:nyt-franklin,helvetica,arial,sans-serif;padding:10px 0;-webkit-text-decoration:none;text-decoration:none;white-space:nowrap;}.css-1p8nkc0:hover{-webkit-text-decoration:underline;text-decoration:underline;}@-webkit-keyframes animation-5j8bii{from{opacity:0;}to{opacity:1;}}@keyframes animation-5j8bii{from{opacity:0;}to{opacity:1;}}@-webkit-keyframes animation-1am0aiv{from{visibility:visible;opacity:1;}to{visibility:visible;opacity:0;}}@keyframes animation-1am0aiv{from{visibility:visible;opacity:1;}to{visibility:visible;opacity:0;}}.css-1g7m0tk{color:#326891;}.css-1g7m0tk:visited{color:#326891;}.css-d8bdto{color:#999;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:17px;margin-bottom:5px;}@media print{.css-d8bdto{display:none;}}.css-y8aj3r{padding:0;}.css-60hakz{color:#999;display:inline;margin-right:16px;padding:10px 0;width:100%;}.css-60hakz a{-webkit-text-decoration:none;text-decoration:none;}@media (max-width:659px){.css-60hakz:nth-of-type(3),.css-60hakz:nth-of-type(4){display:none;}}.css-60hakz:last-of-type{margin-right:0;}.css-i29ckm{width:calc(100% - 40px);max-width:600px;margin:1.5rem auto 2rem;}@media (min-width:1440px){.css-i29ckm{width:600px;max-width:600px;}}@media (min-width:600px){.css-i29ckm{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;}}@media (max-width:600px){.css-i29ckm .facebook,.css-i29ckm .twitter,.css-i29ckm .email{display:inline-block;}}.css-acwcvw{margin-bottom:1rem;}.css-1baulvz{display:inline-block;}@-webkit-keyframes animation-f8wsfj{0%{opacity:1;}50%{opacity:0;}100%{opacity:0;}}@keyframes animation-f8wsfj{0%{opacity:1;}50%{opacity:0;}100%{opacity:0;}}@-webkit-keyframes animation-mhvv8m{0%{opacity:0;}50%{opacity:0;}100%{opacity:1;}}@keyframes animation-mhvv8m{0%{opacity:0;}50%{opacity:0;}100%{opacity:1;}}@-webkit-keyframes animation-m6999o{100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg);-webkit-transform-origin:center;-ms-transform-origin:center;transform-origin:center;}}@keyframes animation-m6999o{100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg);-webkit-transform-origin:center;-ms-transform-origin:center;transform-origin:center;}}.css-i9gxme{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;}@-webkit-keyframes animation-1m9j9gf{from{background-color:#f7f7f5;}to{background-color:transparent;}}@keyframes animation-1m9j9gf{from{background-color:#f7f7f5;}to{background-color:transparent;}}.css-1sy8kpn{display:none;}@media (min-width:765px){.css-1sy8kpn{background-color:#f7f7f7;border-bottom:1px solid #f3f3f3;display:block;padding-bottom:15px;padding-top:15px;margin:0;min-height:90px;}}@media print{.css-1sy8kpn{display:none;}}.css-19vbshk{color:#ccc;display:none;font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:0.5625rem;font-weight:300;-webkit-letter-spacing:0.05rem;-moz-letter-spacing:0.05rem;-ms-letter-spacing:0.05rem;letter-spacing:0.05rem;line-height:0.5625rem;margin-left:auto;text-align:center;text-transform:uppercase;}@media (min-width:600px){.css-19vbshk{display:inline-block;}}.css-19vbshk p{margin-bottom:auto;margin-right:7px;margin-top:auto;text-transform:none;}.css-l9onyx{color:#ccc;font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:0.5625rem;font-weight:300;-webkit-letter-spacing:0.05rem;-moz-letter-spacing:0.05rem;-ms-letter-spacing:0.05rem;letter-spacing:0.05rem;line-height:0.5625rem;margin-bottom:9px;text-align:center;text-transform:uppercase;}.css-79elbk{position:relative;}@-webkit-keyframes animation-1q1yk17{to{width:11px;}}@keyframes animation-1q1yk17{to{width:11px;}}@-webkit-keyframes animation-g7rb99{0%{-webkit-transform:scale(1) rotate(0);-ms-transform:scale(1) rotate(0);transform:scale(1) rotate(0);}100%{-webkit-transform:scale(1.05) rotate(-90deg);-ms-transform:scale(1.05) rotate(-90deg);transform:scale(1.05) rotate(-90deg);}}@keyframes animation-g7rb99{0%{-webkit-transform:scale(1) rotate(0);-ms-transform:scale(1) rotate(0);transform:scale(1) rotate(0);}100%{-webkit-transform:scale(1.05) rotate(-90deg);-ms-transform:scale(1.05) rotate(-90deg);transform:scale(1.05) rotate(-90deg);}}.css-k008qs{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}.sizeSmall .css-bsn42l{width:50%;}@media (min-width:600px){.sizeSmall .css-bsn42l{width:300px;}}@media (min-width:1440px){.sizeSmall .css-bsn42l{width:300px;}}@media (max-width:600px){.sizeSmall .css-bsn42l{width:50%;}}.sizeSmall.sizeSmallNoCaption .css-bsn42l{margin-left:auto;margin-right:auto;}@media (min-width:740px){.sizeSmall.layoutVertical .css-bsn42l{max-width:250px;}}@media (min-width:1024px){.sizeSmall.layoutVertical .css-bsn42l{width:250px;}}@media (max-width:740px){.sizeSmall.layoutVertical .css-bsn42l{max-width:250px;}}@media (min-width:740px){.sizeSmall.layoutHorizontal .css-bsn42l{max-width:300px;}}@media (min-width:1024px){.sizeSmall.layoutHorizontal .css-bsn42l{width:300px;}}@media (max-width:740px){.sizeSmall.layoutHorizontal .css-bsn42l{max-width:300px;}}@media (min-width:600px){.sizeMedium.layoutVertical.verticalVideo .css-bsn42l{width:310px;}}.css-11cwn6f{width:100%;vertical-align:top;}.css-11cwn6f img{width:100%;vertical-align:top;}@-webkit-keyframes animation-ghw4n2{0%{opacity:0;-webkit-transform:translateY(100px);-ms-transform:translateY(100px);transform:translateY(100px);}100%{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0);}}@keyframes animation-ghw4n2{0%{opacity:0;-webkit-transform:translateY(100px);-ms-transform:translateY(100px);transform:translateY(100px);}100%{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0);}}@-webkit-keyframes animation-1c5cfvc{0%{-webkit-transform:translate(0px,0px) scale(1,1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,1) translate(0px,0px);transform:translate(0px,0px) scale(1,1) translate(0px,0px);}25%{-webkit-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);}50%{-webkit-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);}75%{-webkit-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);}100%{-webkit-transform:translate(0px,0px) scale(1,1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,1) translate(0px,0px);transform:translate(0px,0px) scale(1,1) translate(0px,0px);}}@keyframes animation-1c5cfvc{0%{-webkit-transform:translate(0px,0px) scale(1,1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,1) translate(0px,0px);transform:translate(0px,0px) scale(1,1) translate(0px,0px);}25%{-webkit-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);}50%{-webkit-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);}75%{-webkit-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);transform:translate(0px,0px) scale(1,0.95) translate(0px,0px);}100%{-webkit-transform:translate(0px,0px) scale(1,1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,1) translate(0px,0px);transform:translate(0px,0px) scale(1,1) translate(0px,0px);}}@-webkit-keyframes animation-htgkrt{0%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);}25%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);}50%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);}75%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);}100%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);}}@keyframes animation-htgkrt{0%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);}25%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);}50%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);}75%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);transform:translate(0px,0px) translate(0px,0px) translate(0px,5px);}100%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);transform:translate(0px,0px) translate(0px,0px) translate(0px,0px);}}@-webkit-keyframes animation-e64et{0%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}25%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);}50%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}75%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);}100%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}}@keyframes animation-e64et{0%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}25%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,1) translate(-11.329999923706055px,0px);}50%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}75%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);}100%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.95) translate(-11.329999923706055px,0px);}}@-webkit-keyframes animation-9zaqp9{0%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}25%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);}50%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}75%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);}100%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}}@keyframes animation-9zaqp9{0%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}25%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,0px);}50%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}75%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);}100%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,5px);}}@-webkit-keyframes animation-16fq4rz{0%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}25%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);}50%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);}75%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);}100%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}}@keyframes animation-16fq4rz{0%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}25%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);}50%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,1) translate(-22.670000076293945px,0px);}75%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.95) translate(-22.670000076293945px,0px);}100%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}}@-webkit-keyframes animation-1kjk1j2{0%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}25%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);}50%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);}75%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);}100%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}}@keyframes animation-1kjk1j2{0%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}25%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);}50%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,0px);}75%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,5px);}100%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}}@-webkit-keyframes animation-88g286{0%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}25%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);}50%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}75%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);}100%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}}@keyframes animation-88g286{0%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}25%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);}50%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}75%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,0px);}100%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,5px);}}@-webkit-keyframes animation-12yx39b{0%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}25%{-webkit-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);}50%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}75%{-webkit-transform:translate(34px,0px) scale(1,1) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,1) translate(-34px,0px);transform:translate(34px,0px) scale(1,1) translate(-34px,0px);}100%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}}@keyframes animation-12yx39b{0%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}25%{-webkit-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);}50%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}75%{-webkit-transform:translate(34px,0px) scale(1,1) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,1) translate(-34px,0px);transform:translate(34px,0px) scale(1,1) translate(-34px,0px);}100%{-webkit-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.95) translate(-34px,0px);}}@-webkit-keyframes animation-4hu8jm{0%{-webkit-transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);}33.33%{-webkit-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);}100%{-webkit-transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);}}@keyframes animation-4hu8jm{0%{-webkit-transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);transform:translate(0px,0px) scale(1,0.85) translate(0px,0px);}33.33%{-webkit-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);transform:translate(0px,0px) scale(1,0.9) translate(0px,0px);}100%{-webkit-transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);-ms-transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);transform:translate(0px,0px) scale(1,0.1) translate(0px,0px);}}@-webkit-keyframes animation-1wqz2f4{0%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);}100%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);}}@keyframes animation-1wqz2f4{0%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);transform:translate(0px,0px) translate(0px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);transform:translate(0px,0px) translate(0px,0px) translate(0px,10px);}100%{-webkit-transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);-ms-transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);transform:translate(0px,0px) translate(0px,0px) translate(0px,30px);}}@-webkit-keyframes animation-yl3z84{0%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);}33.33%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);}100%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);}}@keyframes animation-yl3z84{0%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.85) translate(-11.329999923706055px,0px);}33.33%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.9) translate(-11.329999923706055px,0px);}100%{-webkit-transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);-ms-transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);transform:translate(11.329999923706055px,0px) scale(1,0.1) translate(-11.329999923706055px,0px);}}@-webkit-keyframes animation-1q3gjvc{0%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);}100%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);}}@keyframes animation-1q3gjvc{0%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,10px);}100%{-webkit-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);-ms-transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);transform:translate(11.329999923706055px,0px) translate(-11.329999923706055px,0px) translate(0px,30px);}}@-webkit-keyframes animation-nc39ev{0%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);}33.33%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}100%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);}}@keyframes animation-nc39ev{0%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.85) translate(-22.670000076293945px,0px);}33.33%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.9) translate(-22.670000076293945px,0px);}100%{-webkit-transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);-ms-transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);transform:translate(22.670000076293945px,0px) scale(1,0.1) translate(-22.670000076293945px,0px);}}@-webkit-keyframes animation-amd09y{0%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}100%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);}}@keyframes animation-amd09y{0%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,10px);}100%{-webkit-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);-ms-transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);transform:translate(22.670000076293945px,0px) translate(-22.670000076293945px,0px) translate(0px,30px);}}@-webkit-keyframes animation-ru1vxe{0%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);}100%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);}}@keyframes animation-ru1vxe{0%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,15px);}33.33%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,10px);}100%{-webkit-transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);-ms-transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);transform:translate(34px,0px) translate(-34px,0px) translate(0px,30px);}}@-webkit-keyframes animation-ajnadh{0%{-webkit-transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);}33.33%{-webkit-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);}100%{-webkit-transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);}}@keyframes animation-ajnadh{0%{-webkit-transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.85) translate(-34px,0px);}33.33%{-webkit-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.9) translate(-34px,0px);}100%{-webkit-transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);-ms-transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);transform:translate(34px,0px) scale(1,0.1) translate(-34px,0px);}}.css-1ri25x2{display:none;}@media (min-width:740px){.css-1ri25x2{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;width:16px;height:31px;}}@media (min-width:1024px){.css-1ri25x2{display:none;}}.css-12fr9lp{height:23px;margin-top:6px;}.css-1hfdzay{display:none;}@media (min-width:1024px){.css-1hfdzay{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;margin-top:0;}}.css-4g4cvq{display:none;}@media (min-width:740px){.css-4g4cvq{position:fixed;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;opacity:0;z-index:1;-webkit-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out;width:100%;height:32.063px;background:white;padding:5px 0;top:0;text-align:center;font-family:nyt-cheltenham,georgia,'times new roman',times,serif;box-shadow:rgba(0,0,0,0.08) 0 0 5px 1px;border-bottom:1px solid #e2e2e2;}}.css-m6xlts{margin-left:20px;margin-right:20px;max-width:1605px;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;position:relative;width:100%;}@media (min-width:1360px){.css-m6xlts{margin-left:20px;margin-right:20px;}}@media (min-width:1780px){.css-m6xlts{margin-left:auto;margin-right:auto;}}.css-1ahhg7f{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;max-width:1605px;overflow:hidden;position:absolute;width:56%;margin-left:calc((100% - 56%) / 2);}@media (min-width:1024px){.css-1ahhg7f{width:56%;margin-left:calc((100% - 56%) / 2);}}@media (min-width:1024px){.css-1ahhg7f{width:53%;margin-left:calc((100% - 53%) / 2);}}.css-fwqvlz{font-family:nyt-cheltenham-small,georgia,'times new roman';font-weight:400;font-size:13px;-webkit-letter-spacing:0.015em;-moz-letter-spacing:0.015em;-ms-letter-spacing:0.015em;letter-spacing:0.015em;margin-top:10.5px;margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.css-17xtcya{font-family:nyt-franklin,helvetica,arial,sans-serif;font-weight:700;font-size:12.5px;text-transform:uppercase;-webkit-letter-spacing:0;-moz-letter-spacing:0;-ms-letter-spacing:0;letter-spacing:0;margin-top:12.5px;margin-bottom:auto;margin-left:auto;white-space:nowrap;}.css-17xtcya:hover{-webkit-text-decoration:underline;text-decoration:underline;}.css-x15j1o{display:inline-block;padding-left:7px;padding-right:7px;font-size:13px;margin-top:10px;margin-bottom:auto;color:#ccc;}.css-1705lsu{margin-top:auto;margin-bottom:auto;margin-left:auto;background-color:#fff;z-index:50;box-shadow:-14px 2px 7px -2px rgba(255,255,255,0.7);}@media (min-width:740px){.css-1iwv8en{margin-top:1px;}}@media (min-width:1024px){.css-1iwv8en{margin-top:0;}}@-webkit-keyframes animation-b7n1on{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg);}}@keyframes animation-b7n1on{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg);}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg);}}@-webkit-keyframes animation-1b9egsl{0%{-webkit-transform:rotate(20deg);-ms-transform:rotate(20deg);transform:rotate(20deg);}100%{-webkit-transform:rotate(380deg);-ms-transform:rotate(380deg);transform:rotate(380deg);}}@keyframes animation-1b9egsl{0%{-webkit-transform:rotate(20deg);-ms-transform:rotate(20deg);transform:rotate(20deg);}100%{-webkit-transform:rotate(380deg);-ms-transform:rotate(380deg);transform:rotate(380deg);}}.css-1rj8to8{display:inline-block;line-height:1em;}.css-1rj8to8 .css-0{-webkit-text-decoration:none;text-decoration:none;display:inline-block;}.css-4w91ra{display:inline-block;padding-left:3px;}.css-wg1cha{margin-left:20px;margin-right:20px;}@media (min-width:600px){.css-wg1cha{width:calc(100% - 40px);max-width:600px;margin:1.5rem auto 1em;}}@media (min-width:1440px){.css-wg1cha{width:600px;max-width:600px;margin:1.5rem auto 1em;}}.css-1ubp8k9{font-family:nyt-imperial,georgia,'times new roman',times,serif;font-style:italic;font-size:1.0625rem;line-height:1.5rem;width:calc(100% - 40px);max-width:600px;margin:1rem auto 0.75rem;}@media (min-width:740px){.css-1ubp8k9{font-size:1.1875rem;line-height:1.75rem;margin-bottom:1.25rem;}}@media (min-width:1440px){.css-1ubp8k9{width:600px;max-width:600px;}}@media print{.css-1ubp8k9{margin-left:0;margin-right:0;width:100%;max-width:100%;}}@-webkit-keyframes animation-1egl8em{from{opacity:0;-webkit-transform:translate3d(0,13%,0);-ms-transform:translate3d(0,13%,0);transform:translate3d(0,13%,0);}to{opacity:1;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0);}}@keyframes animation-1egl8em{from{opacity:0;-webkit-transform:translate3d(0,13%,0);-ms-transform:translate3d(0,13%,0);transform:translate3d(0,13%,0);}to{opacity:1;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0);}}.css-vdv0al{font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:0.75rem;line-height:1rem;width:calc(100% - 40px);max-width:600px;margin:0 auto 1em;color:#999;}.css-vdv0al a{color:#999;-webkit-text-decoration:none;text-decoration:none;}.css-vdv0al a:hover{-webkit-text-decoration:underline;text-decoration:underline;}@media (min-width:1440px){.css-vdv0al{width:600px;max-width:600px;}}@media print{.css-vdv0al{margin-left:0;margin-right:0;width:100%;max-width:100%;}}@media print{.css-vdv0al span{display:none;}}.css-1i2y565 .e6idgb70 + .e1h9rw200{margin-top:0;}.css-1i2y565 .eoo0vm40 + .e1gnsphs0{margin-top:-0.3em;}.css-1i2y565 .e6idgb70 + .eoo0vm40{margin-top:0;}.css-1i2y565 .eoo0vm40 + figure{margin-top:1.2rem;}.css-1i2y565 .e1gnsphs0 + figure{margin-top:1.2rem;}.css-o6xoe7{display:none;}@media (min-width:1024px){.css-o6xoe7{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;margin-right:0;margin-left:auto;width:130px;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;}}@media (min-width:1150px){.css-o6xoe7{width:210px;}}@media print{.css-o6xoe7{display:none;}}.css-1fanzo5{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;margin-bottom:1rem;}@media (min-width:1024px){.css-1fanzo5{-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;height:100%;width:945px;margin-left:auto;margin-right:auto;}}@media (min-width:1150px){.css-1fanzo5{width:1110px;margin-left:auto;margin-right:auto;}}@media (min-width:1280px){.css-1fanzo5{width:1170px;}}@media (min-width:1440px){.css-1fanzo5{width:1200px;}}@media print{.css-1fanzo5{margin-left:0;margin-right:0;width:100%;max-width:100%;}}@media print{.css-1fanzo5{margin-bottom:1em;display:block;}}.css-53u6y8{margin-left:auto;margin-right:auto;width:100%;}@media (min-width:1024px){.css-53u6y8{margin-left:calc((100% - 600px) / 2);margin-right:0;width:600px;}}@media (min-width:1440px){.css-53u6y8{max-width:600px;width:600px;margin-left:calc((100% - 600px) / 2);}}@media print{.css-53u6y8{margin-left:0;margin-right:0;width:100%;max-width:100%;}}.css-1m50asq{width:100%;vertical-align:top;}.css-z3e15g{position:fixed;opacity:0;-webkit-scroll-events:none;-moz-scroll-events:none;-ms-scroll-events:none;scroll-events:none;top:0;left:0;bottom:0;right:0;-webkit-transition:opacity 0.2s;transition:opacity 0.2s;background-color:#fff;pointer-events:none;}.css-uwwqev{width:100%;height:100%;}.css-1ly73wi{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;-webkit-clip:rect(0 0 0 0);clip:rect(0 0 0 0);overflow:hidden;}@-webkit-keyframes animation-7y3qfv{0%{opacity:0;}10%,90%{opacity:1;}100%{opacity:0;}}@keyframes animation-7y3qfv{0%{opacity:0;}10%,90%{opacity:1;}100%{opacity:0;}}.css-l72opv{color:#999;display:inline;margin-right:16px;padding:10px 0;width:100%;position:relative;right:3px;}.css-l72opv a{-webkit-text-decoration:none;text-decoration:none;}@media (max-width:659px){.css-l72opv:nth-of-type(3),.css-l72opv:nth-of-type(4){display:none;}}.css-l72opv:last-of-type{margin-right:0;}.css-4skfbu{color:#999;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:17px;margin-bottom:5px;margin-bottom:0;}@media print{.css-4skfbu{display:none;}}.css-1fcn4th{color:#999;display:inline;margin-right:16px;padding:10px 0;width:100%;}.css-1fcn4th a{-webkit-text-decoration:none;text-decoration:none;}@media (max-width:659px){.css-1fcn4th:nth-of-type(3),.css-1fcn4th:nth-of-type(4){display:none;}}.css-1fcn4th:last-of-type{margin-right:0;}@media (max-width:1150px){.css-1fcn4th:nth-of-type(1),.css-1fcn4th:nth-of-type(2),.css-1fcn4th:nth-of-type(3){display:none;}}.css-13zu7ev{display:inline-block;height:15px;vertical-align:middle;width:15px;background-color:#eee;border:1px #eee solid;border-radius:100%;padding:5px;width:14px;height:14px;}.css-13zu7ev.facebook{background-image:url(/vi-assets/static-assets/icon-fb-circle-2ec7780140bd9e8e8398bbcdf5661569.svg);}.css-13zu7ev.twitter{background-image:url(/vi-assets/static-assets/icon-twitter-circle-fc7c2748f5613c68963a0df203bffc05.svg);}.css-13zu7ev.email{background-image:url(/vi-assets/static-assets/icon-share-email-circle-a2acce7e23b21d47bb606b628a6c7e34.svg);}.css-13zu7ev.link{background-image:url(/vi-assets/static-assets/icon-share-permalink-circle-3ff3876a106221ee493d9542c0895863.svg);}.css-13zu7ev.linkedin{background-image:url(/vi-assets/static-assets/icon-share-linkedin-circle-4d7bcf236c5f3a086738746f41f46f7b.svg);}.css-13zu7ev.whatsapp{background-image:url(/vi-assets/static-assets/icon-whatsapp-video-a9503bf2b3c73111106c496d3ebc8c67.svg);}.css-13zu7ev.reddit{background-image:url(/vi-assets/static-assets/icon-share-reddit-f882338200fd1971b767627fa5f60124.svg);}.css-13zu7ev:hover{background-color:#fff;border:1px solid #ccc;}.css-f7l8cz{display:inline-block;height:15px;vertical-align:middle;width:15px;bottom:5px;position:relative;width:14px;height:14px;bottom:4px;}.css-f7l8cz.facebook{background-image:url(/vi-assets/static-assets/icon-fb-circle-2ec7780140bd9e8e8398bbcdf5661569.svg);}.css-f7l8cz.twitter{background-image:url(/vi-assets/static-assets/icon-twitter-circle-fc7c2748f5613c68963a0df203bffc05.svg);}.css-f7l8cz.email{background-image:url(/vi-assets/static-assets/icon-share-email-circle-a2acce7e23b21d47bb606b628a6c7e34.svg);}.css-f7l8cz.link{background-image:url(/vi-assets/static-assets/icon-share-permalink-circle-3ff3876a106221ee493d9542c0895863.svg);}.css-f7l8cz.linkedin{background-image:url(/vi-assets/static-assets/icon-share-linkedin-circle-4d7bcf236c5f3a086738746f41f46f7b.svg);}.css-f7l8cz.whatsapp{background-image:url(/vi-assets/static-assets/icon-whatsapp-video-a9503bf2b3c73111106c496d3ebc8c67.svg);}.css-f7l8cz.reddit{background-image:url(/vi-assets/static-assets/icon-share-reddit-f882338200fd1971b767627fa5f60124.svg);}.css-16ogagc{background:transparent;display:inline-block;height:20px;width:20px;background-color:#eee;border:1px #eee solid;border-radius:100%;padding:5px;width:27px;height:27px;}.css-16ogagc.hidden{opacity:0;visibility:hidden;}.css-16ogagc.hidden:focus{opacity:1;}.css-16ogagc:hover{background-color:#fff;border:1px solid #ccc;}.css-17ai7jg{color:#666;font-family:nyt-imperial,georgia,'times new roman',times,serif;margin:10px 20px 0;text-align:left;}.css-17ai7jg a{color:#326891;-webkit-text-decoration:none;text-decoration:none;}.css-17ai7jg a:hover,.css-17ai7jg a:focus{-webkit-text-decoration:underline;text-decoration:underline;}@media (min-width:600px){.css-17ai7jg{margin-left:0;}}.sizeSmall .css-17ai7jg{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;width:calc(50% - 15px);margin:auto 0 15px 15px;}@media (min-width:600px){.sizeSmall .css-17ai7jg{width:260px;margin-left:15px;}}@media (min-width:740px){.sizeSmall .css-17ai7jg{margin-left:15px;}}@media (min-width:1440px){.sizeSmall .css-17ai7jg{width:330px;margin-left:15px;}}@media (max-width:600px){.sizeSmall .css-17ai7jg{margin:auto 0 0 15px;}}.sizeSmall.sizeSmallNoCaption .css-17ai7jg{margin-left:auto;margin-right:auto;margin-top:10px;}.sizeMedium .css-17ai7jg{max-width:900px;}.sizeMedium.layoutVertical.verticalVideo .css-17ai7jg{margin-left:0;margin-right:0;}@media (min-width:600px){.sizeMedium.layoutVertical.verticalVideo .css-17ai7jg{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;width:255px;margin:auto 0 15px 15px;}}@media (min-width:1440px){.sizeMedium.layoutVertical.verticalVideo .css-17ai7jg{width:325px;}}.sizeLarge .css-17ai7jg{max-width:none;}@media (min-width:600px){.sizeLarge .css-17ai7jg{margin-left:20px;}}@media (min-width:740px){.sizeLarge .css-17ai7jg{margin-left:20px;max-width:900px;}}@media (min-width:1024px){.sizeLarge .css-17ai7jg{margin-left:0;max-width:720px;}}@media (min-width:1440px){.sizeLarge .css-17ai7jg{margin-left:0;max-width:900px;}}@media (min-width:740px){.sizeLarge.layoutVertical .css-17ai7jg{margin-left:0;}}.sizeFull .css-17ai7jg{margin-left:20px;}@media (min-width:600px){.sizeFull .css-17ai7jg{max-width:900px;}}@media (min-width:740px){.sizeFull .css-17ai7jg{max-width:900px;}}@media (min-width:1440px){.sizeFull .css-17ai7jg{max-width:900px;}}@media print{.css-17ai7jg{display:none;}}.css-8i9d0s{margin-right:7px;color:#666;font-family:nyt-imperial,georgia,'times new roman',times,serif;font-size:0.875rem;line-height:1.125rem;}@media (min-width:740px){.css-8i9d0s{font-size:0.9375rem;line-height:1.25rem;}}.css-8i9d0s strong{font-weight:700;}.css-8i9d0s em{font-style:italic;}.css-8i9d0s a{color:#326891;}.css-8i9d0s a:visited{color:#326891;}.css-1nwzsjy{display:inline-block;color:#888;font-family:nyt-imperial,georgia,'times new roman',times,serif;line-height:1.125rem;-webkit-letter-spacing:0.01em;-moz-letter-spacing:0.01em;-ms-letter-spacing:0.01em;letter-spacing:0.01em;font-size:0.75rem;}@media (min-width:740px){.css-1nwzsjy{font-size:0.75rem;}}@media (min-width:1150px){.css-1nwzsjy{font-size:0.8125rem;}}@media (min-width:600px){.sizeSmall.sizeSmallNoCaption .css-1nwzsjy{margin-left:5px;}}@media (min-width:1024px){.sizeSmall.sizeSmallNoCaption .css-1nwzsjy{margin-left:5px;}}@media (min-width:1440px){.sizeSmall.sizeSmallNoCaption .css-1nwzsjy{margin-left:40px;}}@media (max-width:600px){.sizeSmall.sizeSmallNoCaption .css-1nwzsjy{margin-left:-8px;}}@media print{.css-1nwzsjy{display:none;}}.css-10698na{text-align:center;}@media (min-width:740px){.css-10698na{padding-top:0;}}@media (min-width:1024px){}@media print{.css-10698na a[href]::after{content:'';}.css-10698na svg{fill:black;}}.css-nhjhh0{display:block;width:189px;height:26px;margin:5px auto 0;}@media (min-width:740px){.css-nhjhh0{width:225px;height:31px;margin:4px auto 0;}}@media (min-width:1024px){.css-nhjhh0{width:195px;height:26px;margin:6px auto 0;}}.css-1nuro5j{display:inline-block;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;font-size:0.875rem;line-height:1.125rem;margin:0;font-family:nyt-franklin,helvetica,arial,sans-serif;font-weight:700;color:#333;}@media (min-width:740px){.css-1nuro5j{font-size:0.9375rem;line-height:1.25rem;}}.css-1w5cs23{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin:0;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;}.css-1w5cs23 li{list-style:none;}.css-4brsb6{display:inline-block;height:15px;vertical-align:middle;width:15px;background-color:#eee;border:1px #eee solid;border-radius:100%;padding:5px;width:15px;height:15px;}.css-4brsb6.facebook{background-image:url(/vi-assets/static-assets/icon-fb-circle-2ec7780140bd9e8e8398bbcdf5661569.svg);}.css-4brsb6.twitter{background-image:url(/vi-assets/static-assets/icon-twitter-circle-fc7c2748f5613c68963a0df203bffc05.svg);}.css-4brsb6.email{background-image:url(/vi-assets/static-assets/icon-share-email-circle-a2acce7e23b21d47bb606b628a6c7e34.svg);}.css-4brsb6.link{background-image:url(/vi-assets/static-assets/icon-share-permalink-circle-3ff3876a106221ee493d9542c0895863.svg);}.css-4brsb6.linkedin{background-image:url(/vi-assets/static-assets/icon-share-linkedin-circle-4d7bcf236c5f3a086738746f41f46f7b.svg);}.css-4brsb6.whatsapp{background-image:url(/vi-assets/static-assets/icon-whatsapp-video-a9503bf2b3c73111106c496d3ebc8c67.svg);}.css-4brsb6.reddit{background-image:url(/vi-assets/static-assets/icon-share-reddit-f882338200fd1971b767627fa5f60124.svg);}.css-4brsb6:hover{background-color:#fff;border:1px solid #ccc;}.css-uhuo44{display:inline-block;height:15px;vertical-align:middle;width:15px;bottom:5px;position:relative;width:15px;height:15px;bottom:4px;}.css-uhuo44.facebook{background-image:url(/vi-assets/static-assets/icon-fb-circle-2ec7780140bd9e8e8398bbcdf5661569.svg);}.css-uhuo44.twitter{background-image:url(/vi-assets/static-assets/icon-twitter-circle-fc7c2748f5613c68963a0df203bffc05.svg);}.css-uhuo44.email{background-image:url(/vi-assets/static-assets/icon-share-email-circle-a2acce7e23b21d47bb606b628a6c7e34.svg);}.css-uhuo44.link{background-image:url(/vi-assets/static-assets/icon-share-permalink-circle-3ff3876a106221ee493d9542c0895863.svg);}.css-uhuo44.linkedin{background-image:url(/vi-assets/static-assets/icon-share-linkedin-circle-4d7bcf236c5f3a086738746f41f46f7b.svg);}.css-uhuo44.whatsapp{background-image:url(/vi-assets/static-assets/icon-whatsapp-video-a9503bf2b3c73111106c496d3ebc8c67.svg);}.css-uhuo44.reddit{background-image:url(/vi-assets/static-assets/icon-share-reddit-f882338200fd1971b767627fa5f60124.svg);}.css-exrw3m{margin-bottom:0.78125rem;margin-top:0;font-family:nyt-imperial,georgia,'times new roman',times,serif;font-size:1.125rem;line-height:1.5625rem;margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;}@media (min-width:740px){.css-exrw3m{margin-bottom:0.9375rem;margin-top:0;}}.css-exrw3m .css-1g7m0tk{-webkit-text-decoration:underline;text-decoration:underline;}.css-exrw3m .css-1g7m0tk:hover,.css-exrw3m .css-1g7m0tk:focus{-webkit-text-decoration:none;text-decoration:none;}@media (min-width:740px){.css-exrw3m{font-size:1.25rem;line-height:1.875rem;}}.css-exrw3m:first-child{margin-top:0;}.css-exrw3m:last-child{margin-bottom:0;}.css-exrw3m.e1h9rw200:last-child{margin-bottom:0.75rem;}@media (min-width:600px){.css-exrw3m{margin-left:auto;margin-right:auto;}}@media (min-width:1024px){.css-exrw3m{margin-left:0;margin-right:0;width:100%;max-width:100%;}}@media print{.css-exrw3m{margin-left:0;margin-right:0;width:100%;max-width:100%;}}.css-1a48zt4{opacity:1;-webkit-transition:opacity 0.3s 0.2s;transition:opacity 0.3s 0.2s;}.css-vuqh7u{display:inline-block;color:#888;font-family:nyt-imperial,georgia,'times new roman',times,serif;line-height:1.125rem;-webkit-letter-spacing:0.01em;-moz-letter-spacing:0.01em;-ms-letter-spacing:0.01em;letter-spacing:0.01em;font-size:0.75rem;}@media (min-width:740px){.css-vuqh7u{font-size:0.75rem;}}@media (min-width:1150px){.css-vuqh7u{font-size:0.8125rem;}}.css-1l44abu{font-family:nyt-imperial,georgia,'times new roman',times,serif;color:#666;margin:10px 20px 0 20px;text-align:left;}.css-1l44abu a{color:#326891;-webkit-text-decoration:none;text-decoration:none;}.css-1l44abu a:hover,.css-1l44abu a:focus{-webkit-text-decoration:underline;text-decoration:underline;}@media (min-width:600px){.css-1l44abu{margin-left:0;margin-right:20px;}}@media (min-width:1440px){.css-1l44abu{max-width:px;}}.css-jcw7oy{width:100%;max-width:600px;margin:2.3125rem auto;}@media (min-width:600px){.css-jcw7oy{width:calc(100% - 40px);}}@media (min-width:740px){.css-jcw7oy{width:auto;max-width:600px;}}@media (min-width:1440px){.css-jcw7oy{max-width:720px;}}.css-jcw7oy strong{font-weight:700;}.css-jcw7oy em{font-style:italic;}@media (min-width:740px){.css-jcw7oy{margin:2.6875rem auto;}}.css-10raysz{color:#999;display:inline;margin-right:16px;padding:10px 0;width:100%;width:calc(100% - 40px);max-width:600px;padding:0 1rem 0 0;}.css-10raysz a{-webkit-text-decoration:none;text-decoration:none;}@media (max-width:659px){.css-10raysz:nth-of-type(3),.css-10raysz:nth-of-type(4){display:none;}}.css-10raysz:last-of-type{margin-right:0;}.css-10raysz:nth-of-type(1),.css-10raysz:nth-of-type(2),.css-10raysz:nth-of-type(3),.css-10raysz:nth-of-type(4){display:inline;}@media (min-width:600px){.css-10raysz{width:330px;}}.css-ar1l6a{color:#999;display:inline;margin-right:16px;padding:10px 0;width:100%;}.css-ar1l6a a{-webkit-text-decoration:none;text-decoration:none;}@media (max-width:659px){.css-ar1l6a:nth-of-type(3),.css-ar1l6a:nth-of-type(4){display:none;}}.css-ar1l6a:last-of-type{margin-right:0;}.css-ar1l6a:nth-of-type(1),.css-ar1l6a:nth-of-type(2),.css-ar1l6a:nth-of-type(3),.css-ar1l6a:nth-of-type(4){display:inline;}.css-1ede5it{background-color:#f7f7f7;border-bottom:1px solid #f3f3f3;border-top:1px solid #f3f3f3;margin:37px auto;padding-bottom:30px;padding-top:12px;text-align:center;margin-top:60px;}@media (min-width:740px){.css-1ede5it{margin:43px auto;}}@media print{.css-1ede5it{display:none;}}@media (min-width:740px){.css-1ede5it{margin-bottom:0;margin-top:0;}}.css-mn5hq9{cursor:pointer;margin:0;border-top:1px solid #ebebeb;color:#333;font-family:nyt-franklin;font-size:13px;font-weight:700;height:44px;-webkit-letter-spacing:0.04rem;-moz-letter-spacing:0.04rem;-ms-letter-spacing:0.04rem;letter-spacing:0.04rem;line-height:44px;text-transform:uppercase;}.accordionExpanded .css-mn5hq9{color:#b3b3b3;}.css-1qmnftd{font-size:11px;text-align:center;}@media (max-width:600px){.css-1qmnftd{padding-bottom:25px;}}@media (min-width:600px){.css-1qmnftd{padding-bottom:25px;}}@media (min-width:1024px){.css-1qmnftd{padding:0 3% 9px;}}@media (min-width:1150px){.css-1qmnftd{margin:0 auto;max-width:1200px;}}.NYTApp .css-1qmnftd{display:none;}@media print{.css-1qmnftd{display:none;}}.css-1ho5u4o{list-style:none;margin:0 0 15px;padding:0;}@media (min-width:600px){.css-1ho5u4o{display:inline-block;}}.css-13o0c9t{list-style:none;line-height:8px;margin:0 0 35px;padding:0;}@media (min-width:600px){.css-13o0c9t{display:inline-block;}}.css-1yo489b{display:inline-block;line-height:20px;padding:0 10px;}.css-1yo489b:first-child{border-left:none;}.css-1yo489b.desktop{display:none;}@media (min-width:740px){.css-1yo489b.smartphone{display:none;}.css-1yo489b.desktop{display:inline-block;}}.css-ulr03x{opacity:1;visibility:visible;-webkit-animation-name:animation-5j8bii;animation-name:animation-5j8bii;-webkit-animation-duration:300ms;animation-duration:300ms;-webkit-animation-delay:0ms;animation-delay:0ms;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out;}@media print{.css-ulr03x{margin-bottom:15px;}}@media (min-width:1024px){.css-ulr03x{position:fixed;width:100%;top:0;left:0;z-index:200;background-color:#fff;border-bottom:none;-webkit-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out;}}@media (min-width:1024px){.css-1bymuyk{position:relative;border-bottom:1px solid #e2e2e2;}}.css-1waixk9{background:#fff;border-bottom:1px solid #e2e2e2;height:36px;padding:8px 15px 3px;position:relative;}@media (min-width:740px){.css-1waixk9{background:#fff;padding:10px 15px 6px;}}@media (min-width:1024px){.css-1waixk9{background:transparent;border-bottom:0;padding:4px 15px 2px;}}@media print{.css-1waixk9{background:transparent;}}@media (min-width:740px){}@media (min-width:1024px){.css-1waixk9{margin:0 auto;max-width:1605px;}}.css-1f7ibof{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:space-around;-webkit-justify-content:space-around;-ms-flex-pack:space-around;justify-content:space-around;left:10px;position:absolute;}@media (min-width:1024px){}@media print{.css-1f7ibof{display:none;}}.css-l2ztic{border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;background-color:transparent;color:#000;font-size:11px;font-weight:700;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;padding:7px 9px 9px;border:0;padding:8px 9px;text-transform:uppercase;}.css-l2ztic.hidden{opacity:0;visibility:hidden;}.css-l2ztic.hidden:focus{opacity:1;}.css-l2ztic::-moz-focus-inner{padding:0;border:0;}.css-l2ztic:-moz-focusring{outline:1px dotted;}.css-l2ztic:disabled,.css-l2ztic.disabled{opacity:0.5;cursor:default;}.css-l2ztic:active,.css-l2ztic.active{background-color:#f7f7f7;}@media (min-width:740px){.css-l2ztic:hover{background-color:#f7f7f7;}}@media (min-width:1024px){.css-l2ztic{display:none;}}.css-19lv58h{border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;-webkit-appearance:button;-moz-appearance:button;appearance:button;background-color:#fff;border:1px solid #ebebeb;color:#333;display:inline-block;font-size:11px;font-weight:500;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;line-height:13px;margin:0;padding:8px 9px;text-transform:uppercase;vertical-align:middle;display:none;}.css-19lv58h::-moz-focus-inner{padding:0;border:0;}.css-19lv58h:-moz-focusring{outline:1px dotted;}.css-19lv58h:disabled,.css-19lv58h.disabled{opacity:0.5;cursor:default;}.css-19lv58h:active,.css-19lv58h.active{background-color:#f7f7f7;}@media (min-width:740px){.css-19lv58h:hover{background-color:#f7f7f7;}}@media (min-width:1024px){.css-19lv58h{border:0;display:inline-block;margin-right:8px;}}.css-mgtjo2{border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;background-color:transparent;color:#000;font-size:11px;font-weight:700;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;padding:7px 9px 9px;border:0;}.css-mgtjo2::-moz-focus-inner{padding:0;border:0;}.css-mgtjo2:-moz-focusring{outline:1px dotted;}.css-mgtjo2:disabled,.css-mgtjo2.disabled{opacity:0.5;cursor:default;}.css-mgtjo2:active,.css-mgtjo2.active{background-color:#f7f7f7;}@media (min-width:740px){.css-mgtjo2:hover{background-color:#f7f7f7;}}.css-mgtjo2.activeSearchButton{background-color:#f7f7f7;}@media (min-width:1024px){.css-mgtjo2{padding:8px 9px 9px;}}.css-1wr3we4{display:none;}@media (min-width:1024px){.css-1wr3we4{display:block;position:absolute;left:105px;line-height:19px;top:10px;}}.css-y3sf94{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:space-around;-webkit-justify-content:space-around;-ms-flex-pack:space-around;justify-content:space-around;position:absolute;right:10px;top:9px;}@media (min-width:1024px){.css-y3sf94{top:4px;}}@media print{.css-y3sf94{display:none;}}.css-1bnxwmn{border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;background-color:#6288a5;border:1px solid #326891;color:#fff;font-size:11px;font-weight:700;-webkit-letter-spacing:0.05em;-moz-letter-spacing:0.05em;-ms-letter-spacing:0.05em;letter-spacing:0.05em;line-height:11px;padding:8px 9px 6px;text-transform:uppercase;}.css-1bnxwmn::-moz-focus-inner{padding:0;border:0;}.css-1bnxwmn:-moz-focusring{outline:1px dotted;}.css-1bnxwmn:disabled,.css-1bnxwmn.disabled{opacity:0.5;cursor:default;}@media (min-width:740px){.css-1bnxwmn:hover{background-color:#326891;}}@media (min-width:1024px){.css-1bnxwmn{padding:11px 12px 8px;}}.css-1bnxwmn:hover{border:1px solid #326891;}.css-1i8g3m4{border-radius:3px;cursor:pointer;font-family:nyt-franklin,helvetica,arial,sans-serif;-webkit-transition:ease 0.6s;transition:ease 0.6s;background-color:transparent;color:#000;font-size:11px;font-weight:700;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;padding:7px 9px 9px;border:0;display:block;}.css-1i8g3m4.hidden{opacity:0;visibility:hidden;}.css-1i8g3m4.hidden:focus{opacity:1;}.css-1i8g3m4::-moz-focus-inner{padding:0;border:0;}.css-1i8g3m4:-moz-focusring{outline:1px dotted;}.css-1i8g3m4:disabled,.css-1i8g3m4.disabled{opacity:0.5;cursor:default;}.css-1i8g3m4:active,.css-1i8g3m4.active{background-color:#f7f7f7;}@media (min-width:740px){.css-1i8g3m4:hover{background-color:#f7f7f7;}}@media (min-width:740px){.css-1i8g3m4{border:none;line-height:13px;padding:9px 9px 12px;}}@media (min-width:1024px){.css-1i8g3m4{display:none;}}@media (min-width:1150px){}.css-3qijnq{-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;font-family:nyt-franklin,helvetica,arial,sans-serif;font-size:11px;-webkit-box-pack:space-around;-webkit-justify-content:space-around;-ms-flex-pack:space-around;justify-content:space-around;padding:13px 20px 12px;}@media (min-width:740px){.css-3qijnq{position:relative;}}@media (min-width:1024px){.css-3qijnq{-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;border:none;padding:0;height:0;-webkit-transform:translateY(42px);-ms-transform:translateY(42px);transform:translateY(42px);}}@media print{.css-3qijnq{display:none;}}.css-uqyvli{color:#121212;font-size:13px;font-family:nyt-franklin,helvetica,arial,sans-serif;display:none;width:auto;}@media (min-width:740px){.css-uqyvli{text-align:center;width:100%;}}@media (min-width:1024px){.css-uqyvli{font-size:12px;margin-bottom:10px;width:auto;}}.css-1uqjmks{color:#121212;font-size:12px;font-family:nyt-franklin,helvetica,arial,sans-serif;font-weight:500;display:none;}@media (min-width:740px){.css-1uqjmks{margin:0;position:absolute;left:20px;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;top:0;bottom:0;}}@media (min-width:1024px){.css-1uqjmks{display:none;}}.css-1bvtpon{display:none;}@media (min-width:1024px){}.css-1vxca1d{position:relative;margin:0 auto;}@media (min-width:600px){.css-1vxca1d{margin:0 auto 20px;}}.css-1vxca1d .relatedcoverage + .recirculation{margin-top:20px;}.css-1vxca1d .wrap + .recirculation{margin-top:20px;}@media (min-width:1024px){.css-1vxca1d{padding-top:40px;}}.css-1ox9jel{margin:37px auto;margin-top:20px;margin-bottom:32px;}.css-1ox9jel strong{font-weight:700;}.css-1ox9jel em{font-style:italic;}.css-1ox9jel.sizeSmall{width:calc(100% - 40px);display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}@media (min-width:600px){.css-1ox9jel.sizeSmall{max-width:600px;margin-left:auto;margin-right:auto;}}@media (min-width:1024px){.css-1ox9jel.sizeSmall{width:100%;}}@media (min-width:1440px){.css-1ox9jel.sizeSmall{max-width:600px;}}.css-1ox9jel.sizeSmall.sizeSmallNoCaption{display:block;}@media print{.css-1ox9jel.sizeSmall.sizeSmallNoCaption{display:none;}}.css-1ox9jel.sizeMedium{width:100%;max-width:600px;margin-right:auto;margin-left:auto;}@media (min-width:600px){.css-1ox9jel.sizeMedium{width:calc(100% - 40px);}}@media (min-width:740px){.css-1ox9jel.sizeMedium{max-width:600px;}}@media (min-width:1440px){.css-1ox9jel.sizeMedium{max-width:720px;}}@media (min-width:600px){.css-1ox9jel.sizeMedium.layoutVertical{width:420px;}}@media (min-width:1440px){.css-1ox9jel.sizeMedium.layoutVertical{width:480px;}}.css-1ox9jel.sizeMedium.layoutVertical.verticalVideo{width:calc(100% - 40px);}@media (min-width:600px){.css-1ox9jel.sizeMedium.layoutVertical.verticalVideo{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;width:600px;}}@media (min-width:1440px){.css-1ox9jel.sizeMedium.layoutVertical.verticalVideo{width:600px;}}.css-1ox9jel.sizeLarge{width:100%;max-width:1200px;margin-left:auto;margin-right:auto;}@media (min-width:600px){.css-1ox9jel.sizeLarge{width:auto;}}@media (min-width:740px){.css-1ox9jel.sizeLarge.layoutVertical{width:600px;}.css-1ox9jel.sizeLarge.layoutVertical.verticalVideo{width:600px;}}@media (min-width:1024px){.css-1ox9jel.sizeLarge{width:945px;}}@media (min-width:1440px){.css-1ox9jel.sizeLarge{width:1200px;}.css-1ox9jel.sizeLarge.layoutVertical{width:720px;}.css-1ox9jel.sizeLarge.layoutVertical.verticalVideo{width:600px;}}@media (min-width:600px){.css-1ox9jel{margin:43px auto;}}@media print{.css-1ox9jel{display:none;}}@media (min-width:740px){.css-1ox9jel{margin-top:25px;}}.css-1riqqik{display:inline;color:#333;}.css-1riqqik span{-webkit-text-decoration:underline;text-decoration:underline;-webkit-text-decoration-color:#ccc;text-decoration-color:#ccc;}.css-1riqqik span:hover,.css-1riqqik span:focus{-webkit-text-decoration:none;text-decoration:none;}.css-2fg4z9{font-style:italic;}.css-11n4cex{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;margin-bottom:15px;margin-top:4px;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;}@media (min-width:600px){.css-11n4cex{margin-left:auto;margin-right:auto;}}@media (min-width:740px){.css-11n4cex{width:600px;}}.css-11n4cex .e6idgb70{font-size:14px;margin:0;}.css-1ifw933{font-style:normal;font-stretch:normal;margin-bottom:1.6rem;color:#333;font-family:nyt-cheltenham,georgia,'times new roman',times,serif;font-weight:300;-webkit-letter-spacing:0.005em;-moz-letter-spacing:0.005em;-ms-letter-spacing:0.005em;letter-spacing:0.005em;font-size:1.3125rem;line-height:1.6875rem;}@media (min-width:740px){.css-1ifw933{font-size:1.5rem;line-height:1.9375rem;}}.css-1rjmmt7{width:50px;vertical-align:bottom;margin-right:10px;}.css-rqb9bm{font-family:nyt-franklin,helvetica,arial,sans-serif;font-weight:500;color:#333;-webkit-letter-spacing:0.02em;-moz-letter-spacing:0.02em;-ms-letter-spacing:0.02em;letter-spacing:0.02em;font-size:0.875rem;line-height:1.125rem;margin-bottom:1rem;}.css-19hdyf3{font-family:nyt-franklin,helvetica,arial,sans-serif;color:#333;font-size:0.9375rem;line-height:1.25rem;font-family:nyt-franklin,helvetica,arial,sans-serif;color:#333;font-size:0.9375rem;line-height:1.25rem;}.css-19hdyf3 p{margin-bottom:0.75rem;}.css-19hdyf3 a,.css-19hdyf3 a:visited{color:#326891;-webkit-text-decoration:underline;text-decoration:underline;}.css-19hdyf3 a:hover,.css-19hdyf3 a:focus{color:#326891;-webkit-text-decoration:none;text-decoration:none;}@media print{.css-19hdyf3{margin-left:0;margin-right:0;width:100%;max-width:100%;}}@media (min-width:740px){.css-19hdyf3{font-size:1rem;line-height:1.375rem;}}.css-19hdyf3 p{margin-bottom:0.75rem;}.css-19hdyf3 a,.css-19hdyf3 a:visited{color:#326891;-webkit-text-decoration:underline;text-decoration:underline;}.css-19hdyf3 a:hover,.css-19hdyf3 a:focus{color:#326891;-webkit-text-decoration:none;text-decoration:none;}@media print{.css-19hdyf3{margin-left:0;margin-right:0;width:100%;max-width:100%;}}.css-15g2oxy{margin-top:1rem;}.css-2b3w4o{margin-bottom:1rem;}.css-2b3w4o .e16638kd0{margin-top:5px;margin-bottom:0;display:inline-block;color:#999;font-size:0.75rem;line-height:1.0625rem;}.css-2b3w4o:hover .e16ij5yr2,.css-2b3w4o:visited .e16ij5yr2{color:#666;}.css-2b3w4o .css-1g7m0tk{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;min-height:100px;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;}.css-14b9hti{font-weight:500;color:#a19d9d;font-family:nyt-cheltenham,georgia,'times new roman',times,serif;font-size:1.125rem;line-height:1.375rem;}@media (min-width:740px){.css-14b9hti{font-size:1.1875rem;line-height:1.4375rem;}}.css-1j8dw05{margin-right:10px;display:inline;font-weight:500;color:#121212;font-family:nyt-cheltenham,georgia,'times new roman',times,serif;font-size:1.125rem;line-height:1.375rem;}@media (min-width:740px){.css-1j8dw05{font-size:1.1875rem;line-height:1.4375rem;}}.css-1vm5oi9{margin-left:10px;width:120px;min-width:120px;}@media (min-width:740px){.css-1vm5oi9{width:165px;min-width:165px;}}.css-32rbo2{width:100%;min-width:120px;}.css-llk6mt{margin-top:5px;}@media (min-width:740px){.css-llk6mt{margin-top:45px;margin-bottom:0;}}.css-llk6mt .e6idgb70{margin-top:1.875rem;color:#121212;font-weight:700;line-height:0.75rem;margin-bottom:0.625rem;}@media print{.css-llk6mt .e6idgb70{margin-left:0;margin-right:0;width:100%;max-width:100%;}}.css-llk6mt .e1h9rw200{margin-bottom:16px;margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;margin-top:0;}@media (min-width:600px){.css-llk6mt .e1h9rw200{margin-left:auto;margin-right:auto;}}@media (min-width:740px){.css-llk6mt .e1h9rw200{width:600px;}}@media (min-width:740px){.css-llk6mt .e1h9rw200{max-width:none;margin-left:calc((100% - 600px) / 2);margin-right:auto;position:relative;width:660px;}}.css-llk6mt .euiyums3 .e6idgb70{margin:0;}.css-llk6mt .e1wiw3jv0{color:#333;margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;}@media (min-width:600px){.css-llk6mt .e1wiw3jv0{margin-left:auto;margin-right:auto;}}@media (min-width:740px){.css-llk6mt .e1wiw3jv0{width:600px;}}.css-llk6mt .e16638kd0{width:auto;margin-bottom:0;margin-left:0;display:inline-block;margin-top:0;margin-bottom:0;width:auto;}.css-llk6mt .eatfx1z0{margin-right:15px;font-weight:700;font-size:14px;line-height:1;}.css-llk6mt .section-kicker .opinion-bar{font-size:25px;}.css-llk6mt .epjyd6m0{margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;}@media (min-width:600px){.css-llk6mt .epjyd6m0{margin-left:auto;margin-right:auto;}}@media (min-width:740px){.css-llk6mt .epjyd6m0{width:600px;}}.css-llk6mt .e1g7ppur0{margin-bottom:32px;margin-top:20px;}@media (min-width:740px){.css-llk6mt .e1g7ppur0{margin-top:25px;}}@media (min-width:1024px){.css-llk6mt .e1g7ppur0{margin-bottom:43px;}}.css-llk6mt .e1q76eii0{margin-left:20px;margin-right:20px;width:calc(100% - 40px);max-width:600px;max-width:600px;}@media (min-width:600px){.css-llk6mt .e1q76eii0{margin-left:auto;margin-right:auto;}}@media (min-width:740px){.css-llk6mt .e1q76eii0{width:600px;}}.css-llk6mt .euiyums1{margin-bottom:20px;color:#121212;}@media print{.css-llk6mt .euiyums1{margin-left:0;margin-right:0;width:100%;max-width:100%;}}.css-1s4ffep{color:#121212;font-family:nyt-cheltenham,georgia,'times new roman',times,serif;font-weight:700;font-style:italic;font-size:1.9375rem;line-height:2.25rem;text-align:left;}@media (min-width:740px){.css-1s4ffep{font-size:2.5rem;line-height:3rem;}}.css-pdw9fk{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-bottom:15px;width:100%;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;}@media (min-width:1024px){.css-pdw9fk{-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;}}.css-pdw9fk > img,.css-pdw9fk a > img,.css-pdw9fk div > img{margin-right:10px;}.css-1txwxcy{min-width:100px;display:block;width:100%;margin-bottom:10px;margin-left:-3px;}@media (min-width:1024px){.css-1txwxcy{display:inline-block;width:auto;margin-bottom:0;}}.css-1soubk3{margin:0.5rem 0 1.5rem;padding-top:1rem;width:calc(100% - 40px);max-width:600px;margin-left:20px;margin-right:20px;}.css-1soubk3:before{content:'';display:block;width:100%;margin-bottom:0.5rem;border-bottom:1px solid #e2e2e2;}.css-1soubk3:after{content:'';display:block;width:100%;margin-top:0.5rem;border-bottom:1px solid #e2e2e2;}.css-1soubk3 .e16ij5yr6{border-top:none;}@media (min-width:600px){.css-1soubk3{margin-left:auto;margin-right:auto;}}@media (min-width:1024px){.css-1soubk3{width:600px;}}@media (min-width:1440px){.css-1soubk3{width:600px;max-width:600px;}}@media print{.css-1soubk3{margin-left:0;margin-right:0;width:100%;max-width:100%;}}</style>
+
+
+
+ <style>[data-timezone] { display: none }</style>
+
+ </head>
+ <body>
+ <div id="app"><div class="css-v89234" role="main"><div class=""><div><div class="css-ulr03x e1suatyy0"><header class="css-1bymuyk e1suatyy1"><section class="css-1waixk9 e1suatyy2"><div class="css-1f7ibof er09x8g0"><div class="css-6n7j50"><button aria-haspopup="true" aria-expanded="false" aria-label="Sections Navigation &amp; Search" class="er09x8g1 css-l2ztic" data-testid="nav-button"><svg class="css-1fe7a5q" viewBox="0 0 16 16"><rect x="1" y="3" fill="#333333" width="14" height="2"></rect><rect x="1" y="7" fill="#333333" width="14" height="2"></rect><rect x="1" y="11" fill="#333333" width="14" height="2"></rect></svg></button></div><button id="desktop-sections-button" aria-label="Sections Navigation" class="css-19lv58h er09x8g2"><span class="css-vz7hjd">Sections</span><svg class="css-1fe7a5q" viewBox="0 0 16 16"><rect x="1" y="3" fill="#333333" width="14" height="2"></rect><rect x="1" y="7" fill="#333333" width="14" height="2"></rect><rect x="1" y="11" fill="#333333" width="14" height="2"></rect></svg></button><div class="css-10488qs"><button class="css-mgtjo2 ewfai8r0" data-test-id="search-button"><span class="css-vz7hjd">SEARCH</span><svg class="css-1fe7a5q" viewBox="0 0 16 16"><path fill="#333" d="M11.3,9.2C11.7,8.4,12,7.5,12,6.5C12,3.5,9.5,1,6.5,1S1,3.5,1,6.5S3.5,12,6.5,12c1,0,1.9-0.3,2.7-0.7l3.3,3.3c0.3,0.3,0.7,0.4,1.1,0.4s0.8-0.1,1.1-0.4c0.6-0.6,0.6-1.5,0-2.1L11.3,9.2zM6.5,10.3c-2.1,0-3.8-1.7-3.8-3.8c0-2.1,1.7-3.8,3.8-3.8c2.1,0,3.8,1.7,3.8,3.8C10.3,8.6,8.6,10.3,6.5,10.3z"></path></svg></button></div><a class="css-1rn5q1r" href="#site-content">Skip to content</a><a class="css-1rn5q1r" href="#site-index">Skip to site index</a></div><div class="css-1wr3we4 eaxe0e00"><a href="https://www.nytimes.com/section/nyregion" class="css-nuvmzp">New York</a></div><div class="css-10698na e1huz5gh0"><a aria-label="New York Times Logo. Click to visit the homepage" class="css-nhjhh0 e1huz5gh1" href="/"><svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 184 25" fill="#000"><path d="M13.8 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8C6.2 1.4 5 1 4 1 2 1 .6 2.5.6 4.2c0 1.5 1.1 2 1.5 2.2l.1-.2c-.2-.2-.5-.4-.5-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8v3.1L9 10.2v.1l1.5 1.3v4.3c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2C3.6 6.9 4.7 6 5.8 5.4l-.1-.3c-3 .8-5.7 3.6-5.7 7 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1l-1.6-1.3V5.8c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7 0-1.5.2-2.1l2.1-.9v6.2zm10.6 2.3l-1.3 1 .2.2.6-.5 2.2 2 3-2-.1-.2-.8.5-1-1V9.4l.8-.6 1.7 1.4v6.1c0 3.8-.8 4.4-2.5 5v.3c2.8.1 5.4-.8 5.4-5.7V9.3l.9-.7-.2-.2-.8.6-2.5-2.1L18.5 9V.8h-.2l-3.5 2.4v.2c.4.2 1 .4 1 1.5l-.1 11.3zM34 15.1L31.5 17 29 15v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM53.1 2c0-.3-.1-.6-.2-.9h-.2c-.3.8-.7 1.2-1.7 1.2-.9 0-1.5-.5-1.9-.9l-2.9 3.3.2.2 1-.9c.6.5 1.1.9 2.5 1v8.3L44 3.2c-.5-.8-1.2-1.9-2.6-1.9-1.6 0-3 1.4-2.8 3.6h.3c.1-.6.4-1.3 1.1-1.3.5 0 1 .5 1.3 1v3.3c-1.8 0-3 .8-3 2.3 0 .8.4 2 1.6 2.3v-.2c-.2-.2-.3-.4-.3-.7 0-.5.4-.9 1.1-.9h.5v4.2c-2.1 0-3.8 1.2-3.8 3.2 0 1.9 1.6 2.8 3.4 2.7v-.2c-1.1-.1-1.6-.6-1.6-1.3 0-.9.6-1.3 1.4-1.3.8 0 1.5.5 2 1.1l2.9-3.2-.2-.2-.7.8c-1.1-1-1.7-1.3-3-1.5V5l8 14h.6V5c1.5-.1 2.9-1.3 2.9-3zm7.3 13.1L57.9 17l-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM76.7 8l-.7.5-1.9-1.6-2.2 2 .9.9v7.5l-2.4-1.5V9.6l.8-.5-2.3-2.2-2.2 2 .9.9V17l-.3.2-2.1-1.5v-6c0-1.4-.7-1.8-1.5-2.3-.7-.5-1.1-.8-1.1-1.5 0-.6.6-.9.9-1.1v-.2c-.8 0-2.9.8-2.9 2.7 0 1 .5 1.4 1 1.9s1 .9 1 1.8v5.8l-1.1.8.2.2 1-.8 2.3 2 2.5-1.7 2.8 1.7 5.3-3.1V9.2l1.3-1-.2-.2zm18.6-5.5l-1 .9-2.2-2-3.3 2.4V1.6h-.3l.1 16.2c-.3 0-1.2-.2-1.9-.4l-.2-13.5c0-1-.7-2.4-2.5-2.4s-3 1.4-3 2.8h.3c.1-.6.4-1.1 1-1.1s1.1.4 1.1 1.7v3.9c-1.8.1-2.9 1.1-2.9 2.4 0 .8.4 2 1.6 2V13c-.4-.2-.5-.5-.5-.7 0-.6.5-.8 1.3-.8h.4v6.2c-1.5.5-2.1 1.6-2.1 2.8 0 1.7 1.3 2.9 3.3 2.9 1.4 0 2.6-.2 3.8-.5 1-.2 2.3-.5 2.9-.5.8 0 1.1.4 1.1.9 0 .7-.3 1-.7 1.1v.2c1.6-.3 2.6-1.3 2.6-2.8s-1.5-2.4-3.1-2.4c-.8 0-2.5.3-3.7.5-1.4.3-2.8.5-3.2.5-.7 0-1.5-.3-1.5-1.3 0-.8.7-1.5 2.4-1.5.9 0 2 .1 3.1.4 1.2.3 2.3.6 3.3.6 1.5 0 2.8-.5 2.8-2.6V3.7l1.2-1-.2-.2zm-4.1 6.1c-.3.3-.7.6-1.2.6s-1-.3-1.2-.6V4.2l1-.7 1.4 1.3v3.8zm0 3c-.2-.2-.7-.5-1.2-.5s-1 .3-1.2.5V9c.2.2.7.5 1.2.5s1-.3 1.2-.5v2.6zm0 4.7c0 .8-.5 1.6-1.6 1.6h-.8V12c.2-.2.7-.5 1.2-.5s.9.3 1.2.5v4.3zm13.7-7.1l-3.2-2.3-4.9 2.8v6.5l-1 .8.1.2.8-.6 3.2 2.4 5-3V9.2zm-5.4 6.3V8.3l2.5 1.8v7.1l-2.5-1.7zm14.9-8.4h-.2c-.3.2-.6.4-.9.4-.4 0-.9-.2-1.1-.5h-.2l-1.7 1.9-1.7-1.9-3 2 .1.2.8-.5 1 1.1v6.3l-1.3 1 .2.2.6-.5 2.4 2 3.1-2.1-.1-.2-.9.5-1.2-1V9c.5.5 1.1 1 1.8 1 1.4.1 2.2-1.3 2.3-2.9zm12 9.6L123 19l-4.6-7 3.3-5.1h.2c.4.4 1 .8 1.7.8s1.2-.4 1.5-.8h.2c-.1 2-1.5 3.2-2.5 3.2s-1.5-.5-2.1-.8l-.3.5 5 7.4 1-.6v.1zm-11-.5l-1.3 1 .2.2.6-.5 2.2 2 3-2-.2-.2-.8.5-1-1V.8h-.1l-3.6 2.4v.2c.4.2 1 .3 1 1.5v11.3zM143 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8-1.3-.4-2.5-.8-3.5-.8-2 0-3.4 1.5-3.4 3.2 0 1.5 1.1 2 1.5 2.2l.1-.2c-.3-.2-.6-.4-.6-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8V9l-1.5 1.3v.1l1.5 1.3V16c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2c.5-1.3 1.6-2.2 2.6-2.9l-.1-.2c-3 .8-5.7 3.5-5.7 6.9 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1L140 8.8v-3c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7.1-1.5.3-2.1l2.1-.9-.1 6.2zm12.2-12h-.1l-2 1.7v.1l1.7 1.9h.2l2-1.7v-.1l-1.8-1.9zm3 14.8l-.8.5-1-1V9.3l1-.7-.2-.2-.7.6-1.8-2.1-2.9 2 .2.3.7-.5.9 1.1v6.5l-1.3 1 .1.2.7-.5 2.2 2 3-2-.1-.3zm16.7-.1l-.7.5-1.1-1V9.3l1-.8-.2-.2-.8.7-2.3-2.1-3 2.1-2.3-2.1L154 9l-1.8-2.1-2.9 2 .1.3.7-.5 1 1.1v6.5l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.9-.6 1.5 1.4v6l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.8-.5 1.6 1.4v6l-.7.7 2.3 2.1 3.1-2.1v-.3zm8.7-1.5l-2.5 1.9-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.8l3.5 2.5 4.5-3.6-.1-.3zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zm14.1-.9l-1.9-1.5c1.3-1.1 1.8-2.6 1.8-3.6v-.6h-.2c-.2.5-.6 1-1.4 1-.8 0-1.3-.4-1.8-1L176 9.3v3.6l1.7 1.3c-1.7 1.5-2 2.5-2 3.3 0 1 .5 1.7 1.3 2l.1-.2c-.2-.2-.4-.3-.4-.8 0-.3.4-.8 1.2-.8 1 0 1.6.7 1.9 1l4.3-2.6v-3.6h-.1zm-1.1-3c-.7 1.2-2.2 2.4-3.1 3l-1.1-.9V8.1c.4 1 1.5 1.8 2.6 1.8.7 0 1.1-.1 1.6-.4zm-1.7 8c-.5-1.1-1.7-1.9-2.9-1.9-.3 0-1.1 0-1.9.5.5-.8 1.8-2.2 3.5-3.2l1.2 1 .1 3.6z"></path></svg></a></div><div class="css-y3sf94 ez4a0qj1"><a href="https://myaccount.nytimes.com/auth/login?response_type=cookie&amp;client_id=vi" class="css-1kj7lfb"><button class="css-1bnxwmn ez4a0qj0" data-testid="login-button">Log In</button></a><div class="css-6n7j50"><button aria-haspopup="true" aria-expanded="false" aria-label="User Settings" class="ez4a0qj4 css-1i8g3m4" data-testid="user-settings-button"><svg class="css-10m9xeu" viewBox="0 0 16 16" fill="#333"><path d="M8,10c-2.5,0-7,1.1-7,3.5V16h14v-2.5C15,11.1,10.5,10,8,10z"></path><circle cx="8" cy="4" r="4"></circle></svg></button></div></div></section><section class="hasLinks css-3qijnq e1csuq9d3"><div class="css-uqyvli e1csuq9d0"></div><div class="css-1uqjmks e1csuq9d1"></div><div class="css-9e9ivx"><a href="https://myaccount.nytimes.com/auth/login?response_type=cookie&amp;client_id=vi" class="css-1gz70xg">Log In</a></div><div class="css-1bvtpon e1csuq9d2"><a href="https://www.nytimes.com/section/todayspaper" class="css-2bwtzy">Today’s Paper</a></div></section></header></div></div><div aria-hidden="false"><main id="site-content"><div><div class="css-4g4cvq" style="opacity:0.000000001;z-index:-1;visibility:hidden"><div class="css-m6xlts"><div class="css-1ahhg7f"><span class="css-17xtcya"><a href="/section/nyregion">New York</a></span><span class="css-x15j1o">|</span><span class="css-fwqvlz">She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.</span></div><div class="css-k008qs"><div class="css-1iwv8en"><a href="/"><svg class="css-1ri25x2" viewBox="0 0 16 22"><path d="M15.863 13.08c-.687 1.818-1.923 3.147-3.64 3.916v-3.917l2.129-1.958-2.129-1.889V6.505c1.923-.14 3.228-1.609 3.228-3.358C15.45.84 13.32 0 12.086 0c-.275 0-.55 0-.962.14v.14h.481c.824 0 1.51.42 1.51 1.189 0 .63-.48 1.189-1.304 1.189-2.129 0-4.6-1.749-7.279-1.749C2.13.91.481 2.728.481 4.546c0 1.819 1.03 2.448 2.128 2.798v-.14c-.343-.21-.618-.63-.618-1.189 0-.84.756-1.469 1.648-1.469 2.267 0 5.906 1.959 8.172 1.959h.206v2.727l-2.129 1.889 2.13 1.958v3.987c-.894.35-1.786.49-2.748.49-3.502 0-5.768-2.169-5.768-5.806 0-.839.137-1.678.344-2.518l1.785-.769v7.973l3.57-1.608V6.575L3.984 8.953c.55-1.61 1.648-2.728 2.953-3.358v-.07C3.433 6.295 0 9.023 0 13.08c0 4.686 3.914 7.974 8.446 7.974 4.807 0 7.485-3.288 7.554-7.974h-.137z" fill="#000"></path></svg></a><span class="css-1hfdzay"><div><a href="/" aria-label="New York Times Logo. Click to visit the homepage"><svg xmlns="http://www.w3.org/2000/svg" class="css-12fr9lp" viewBox="0 0 184 25" fill="#000"><path d="M13.8 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8C6.2 1.4 5 1 4 1 2 1 .6 2.5.6 4.2c0 1.5 1.1 2 1.5 2.2l.1-.2c-.2-.2-.5-.4-.5-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8v3.1L9 10.2v.1l1.5 1.3v4.3c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2C3.6 6.9 4.7 6 5.8 5.4l-.1-.3c-3 .8-5.7 3.6-5.7 7 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1l-1.6-1.3V5.8c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7 0-1.5.2-2.1l2.1-.9v6.2zm10.6 2.3l-1.3 1 .2.2.6-.5 2.2 2 3-2-.1-.2-.8.5-1-1V9.4l.8-.6 1.7 1.4v6.1c0 3.8-.8 4.4-2.5 5v.3c2.8.1 5.4-.8 5.4-5.7V9.3l.9-.7-.2-.2-.8.6-2.5-2.1L18.5 9V.8h-.2l-3.5 2.4v.2c.4.2 1 .4 1 1.5l-.1 11.3zM34 15.1L31.5 17 29 15v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM53.1 2c0-.3-.1-.6-.2-.9h-.2c-.3.8-.7 1.2-1.7 1.2-.9 0-1.5-.5-1.9-.9l-2.9 3.3.2.2 1-.9c.6.5 1.1.9 2.5 1v8.3L44 3.2c-.5-.8-1.2-1.9-2.6-1.9-1.6 0-3 1.4-2.8 3.6h.3c.1-.6.4-1.3 1.1-1.3.5 0 1 .5 1.3 1v3.3c-1.8 0-3 .8-3 2.3 0 .8.4 2 1.6 2.3v-.2c-.2-.2-.3-.4-.3-.7 0-.5.4-.9 1.1-.9h.5v4.2c-2.1 0-3.8 1.2-3.8 3.2 0 1.9 1.6 2.8 3.4 2.7v-.2c-1.1-.1-1.6-.6-1.6-1.3 0-.9.6-1.3 1.4-1.3.8 0 1.5.5 2 1.1l2.9-3.2-.2-.2-.7.8c-1.1-1-1.7-1.3-3-1.5V5l8 14h.6V5c1.5-.1 2.9-1.3 2.9-3zm7.3 13.1L57.9 17l-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM76.7 8l-.7.5-1.9-1.6-2.2 2 .9.9v7.5l-2.4-1.5V9.6l.8-.5-2.3-2.2-2.2 2 .9.9V17l-.3.2-2.1-1.5v-6c0-1.4-.7-1.8-1.5-2.3-.7-.5-1.1-.8-1.1-1.5 0-.6.6-.9.9-1.1v-.2c-.8 0-2.9.8-2.9 2.7 0 1 .5 1.4 1 1.9s1 .9 1 1.8v5.8l-1.1.8.2.2 1-.8 2.3 2 2.5-1.7 2.8 1.7 5.3-3.1V9.2l1.3-1-.2-.2zm18.6-5.5l-1 .9-2.2-2-3.3 2.4V1.6h-.3l.1 16.2c-.3 0-1.2-.2-1.9-.4l-.2-13.5c0-1-.7-2.4-2.5-2.4s-3 1.4-3 2.8h.3c.1-.6.4-1.1 1-1.1s1.1.4 1.1 1.7v3.9c-1.8.1-2.9 1.1-2.9 2.4 0 .8.4 2 1.6 2V13c-.4-.2-.5-.5-.5-.7 0-.6.5-.8 1.3-.8h.4v6.2c-1.5.5-2.1 1.6-2.1 2.8 0 1.7 1.3 2.9 3.3 2.9 1.4 0 2.6-.2 3.8-.5 1-.2 2.3-.5 2.9-.5.8 0 1.1.4 1.1.9 0 .7-.3 1-.7 1.1v.2c1.6-.3 2.6-1.3 2.6-2.8s-1.5-2.4-3.1-2.4c-.8 0-2.5.3-3.7.5-1.4.3-2.8.5-3.2.5-.7 0-1.5-.3-1.5-1.3 0-.8.7-1.5 2.4-1.5.9 0 2 .1 3.1.4 1.2.3 2.3.6 3.3.6 1.5 0 2.8-.5 2.8-2.6V3.7l1.2-1-.2-.2zm-4.1 6.1c-.3.3-.7.6-1.2.6s-1-.3-1.2-.6V4.2l1-.7 1.4 1.3v3.8zm0 3c-.2-.2-.7-.5-1.2-.5s-1 .3-1.2.5V9c.2.2.7.5 1.2.5s1-.3 1.2-.5v2.6zm0 4.7c0 .8-.5 1.6-1.6 1.6h-.8V12c.2-.2.7-.5 1.2-.5s.9.3 1.2.5v4.3zm13.7-7.1l-3.2-2.3-4.9 2.8v6.5l-1 .8.1.2.8-.6 3.2 2.4 5-3V9.2zm-5.4 6.3V8.3l2.5 1.8v7.1l-2.5-1.7zm14.9-8.4h-.2c-.3.2-.6.4-.9.4-.4 0-.9-.2-1.1-.5h-.2l-1.7 1.9-1.7-1.9-3 2 .1.2.8-.5 1 1.1v6.3l-1.3 1 .2.2.6-.5 2.4 2 3.1-2.1-.1-.2-.9.5-1.2-1V9c.5.5 1.1 1 1.8 1 1.4.1 2.2-1.3 2.3-2.9zm12 9.6L123 19l-4.6-7 3.3-5.1h.2c.4.4 1 .8 1.7.8s1.2-.4 1.5-.8h.2c-.1 2-1.5 3.2-2.5 3.2s-1.5-.5-2.1-.8l-.3.5 5 7.4 1-.6v.1zm-11-.5l-1.3 1 .2.2.6-.5 2.2 2 3-2-.2-.2-.8.5-1-1V.8h-.1l-3.6 2.4v.2c.4.2 1 .3 1 1.5v11.3zM143 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8-1.3-.4-2.5-.8-3.5-.8-2 0-3.4 1.5-3.4 3.2 0 1.5 1.1 2 1.5 2.2l.1-.2c-.3-.2-.6-.4-.6-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8V9l-1.5 1.3v.1l1.5 1.3V16c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2c.5-1.3 1.6-2.2 2.6-2.9l-.1-.2c-3 .8-5.7 3.5-5.7 6.9 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1L140 8.8v-3c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7.1-1.5.3-2.1l2.1-.9-.1 6.2zm12.2-12h-.1l-2 1.7v.1l1.7 1.9h.2l2-1.7v-.1l-1.8-1.9zm3 14.8l-.8.5-1-1V9.3l1-.7-.2-.2-.7.6-1.8-2.1-2.9 2 .2.3.7-.5.9 1.1v6.5l-1.3 1 .1.2.7-.5 2.2 2 3-2-.1-.3zm16.7-.1l-.7.5-1.1-1V9.3l1-.8-.2-.2-.8.7-2.3-2.1-3 2.1-2.3-2.1L154 9l-1.8-2.1-2.9 2 .1.3.7-.5 1 1.1v6.5l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.9-.6 1.5 1.4v6l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.8-.5 1.6 1.4v6l-.7.7 2.3 2.1 3.1-2.1v-.3zm8.7-1.5l-2.5 1.9-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.8l3.5 2.5 4.5-3.6-.1-.3zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zm14.1-.9l-1.9-1.5c1.3-1.1 1.8-2.6 1.8-3.6v-.6h-.2c-.2.5-.6 1-1.4 1-.8 0-1.3-.4-1.8-1L176 9.3v3.6l1.7 1.3c-1.7 1.5-2 2.5-2 3.3 0 1 .5 1.7 1.3 2l.1-.2c-.2-.2-.4-.3-.4-.8 0-.3.4-.8 1.2-.8 1 0 1.6.7 1.9 1l4.3-2.6v-3.6h-.1zm-1.1-3c-.7 1.2-2.2 2.4-3.1 3l-1.1-.9V8.1c.4 1 1.5 1.8 2.6 1.8.7 0 1.1-.1 1.6-.4zm-1.7 8c-.5-1.1-1.7-1.9-2.9-1.9-.3 0-1.1 0-1.9.5.5-.8 1.8-2.2 3.5-3.2l1.2 1 .1 3.6z"></path></svg></a></div></span></div><div class="css-1705lsu"><div class=""><div role="toolbar" aria-label="Social Media Share buttons, Save button, and Comments Panel with current comment count" class="css-4skfbu" data-testid="share-tools"><ul class="css-y8aj3r"><li class="css-1fcn4th"><a href="https://www.facebook.com/dialog/feed?app_id=9869919170&amp;link=https%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html&amp;smid=fb-share&amp;name=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;redirect_uri=https%3A%2F%2Fwww.facebook.com%2F" target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook"><svg class="css-13zu7ev" viewBox="0 0 7 15" width="7" height="15"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.775 14.163V7.08h1.923l.255-2.441H4.775l.004-1.222c0-.636.06-.977.958-.977H6.94V0H5.016c-2.31 0-3.123 1.184-3.123 3.175V4.64H.453v2.44h1.44v7.083h2.882z" fill="#000"></path></svg></a></li><li class="css-1fcn4th"><a href="https://twitter.com/intent/tweet?url=https%3A%2F%2Fnyti.ms%2F2GEzuZ8&amp;text=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database." target="_blank" rel="noopener noreferrer" aria-label="Share on Twitter"><svg viewBox="0 0 13 10" class="css-13zu7ev" width="13" height="10"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.987 2.772l.025.425-.429-.052c-1.562-.2-2.927-.876-4.086-2.011L.93.571.784.987c-.309.927-.111 1.906.533 2.565.343.364.266.416-.327.2-.206-.07-.386-.122-.403-.096-.06.06.146.85.309 1.161.223.434.678.858 1.176 1.11l.42.199-.497.009c-.481 0-.498.008-.447.19.172.564.85 1.162 1.606 1.422l.532.182-.464.277a4.833 4.833 0 0 1-2.3.641c-.387.009-.704.044-.704.07 0 .086 1.047.572 1.657.762 1.828.564 4 .32 5.631-.641 1.159-.685 2.318-2.045 2.859-3.363.292-.702.583-1.984.583-2.6 0-.398.026-.45.507-.927.283-.277.55-.58.6-.667.087-.165.078-.165-.36-.018-.73.26-.832.226-.472-.164.266-.278.584-.78.584-.928 0-.026-.129.018-.275.096a4.79 4.79 0 0 1-.755.294l-.464.148-.42-.286C9.66.467 9.335.293 9.163.24 8.725.12 8.055.137 7.66.276c-1.074.39-1.752 1.395-1.674 2.496z" fill="#000"></path></svg></a></li><li class="css-1fcn4th"><a href="mailto:?subject=NYTimes.com%3A%20She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;body=From%20The%20New%20York%20Times%3A%0A%0AShe%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.%0A%0AWith%20little%20oversight%2C%20the%20N.Y.P.D.%20has%20been%20using%20powerful%20surveillance%20technology%20on%20photos%20of%20children%20and%20teenagers.%0A%0Ahttps%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html" target="_blank" rel="noopener noreferrer" aria-label="Email"><svg viewBox="0 0 15 9" class="css-13zu7ev" width="15" height="9"><path fill-rule="evenodd" clip-rule="evenodd" d="M.906 8.418V0L5.64 4.76.906 8.419zm13 0L9.174 4.761 13.906 0v8.418zM7.407 6.539l-1.13-1.137L.907 9h13l-5.37-3.598-1.13 1.137zM1.297 0h12.22l-6.11 5.095L1.297 0z" fill="#000"></path></svg></a></li><li class="css-1fcn4th"><div class="css-6n7j50"><button aria-haspopup="true" aria-expanded="false" aria-label="More sharing options" class="css-16ogagc" data-testid=""><svg class="css-f7l8cz" viewBox="0 0 16 13" width="16" height="13"><path fill-rule="evenodd" clip-rule="evenodd" d="M15.406 5.359L8.978 0v3.215C3.82 3.215.406 8.107.406 12.66 1.653 9.133 4.29 7.517 8.978 7.517v3.2l6.428-5.358z" fill="#000"></path></svg></button></div></li><li class="css-60hakz"></li><li class="css-l72opv"></li></ul></div></div></div></div></div></div><meta itemProp="isAccessibleForFree" content="false"/><span itemProp="isPartOf" itemscope="" itemType="http://schema.org/CreativeWork http://schema.org/Product"><meta itemProp="name" content="The New York Times"/><meta itemProp="productID" content="nytimes.com:basic"/></span><article id="story" class="css-1vxca1d e1qksbhf0"><div id="top-wrapper" class="css-1sy8kpn"><div id="top-slug" class="css-l9onyx"><p>Advertisement</p></div><div class="ad top-wrapper" style="text-align:center;height:100%;display:block;min-height:250px"><div id="top" class="place-ad" data-position="top"></div></div></div><span itemProp="hasPart" itemscope="" itemType="http://schema.org/WebPageElement"><meta itemProp="isAccessibleForFree" content="False"/><meta itemProp="cssSelector" content=".meteredContent"/></span><div><header class="css-llk6mt euiyums4"><div id="sponsor-wrapper" class="css-1hyfx7x"><div id="sponsor-slug" class="css-19vbshk"><p>Supported by</p></div><div class="ad sponsor-wrapper" style="text-align:center;height:100%;display:block"><div id="sponsor" class="" data-position="sponsor"></div></div></div><div class="css-11n4cex euiyums3"></div><div class="css-1vkm6nb ehdk2mb0"><h1 itemProp="headline" class="css-1s4ffep e1h9rw200" id="link-2df79d6c"><span>She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.</span></h1></div><p class="css-1ifw933 e1wiw3jv0">With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.</p><div class="css-79elbk" data-testid="photoviewer-wrapper"><div class="css-z3e15g" data-testid="photoviewer-wrapper-hidden"></div><div data-testid="photoviewer-children" class="css-1a48zt4 ehw59r15"><figure class="sizeMedium layoutVertical css-1ox9jel" aria-label="media" role="group" itemscope="" itemProp="associatedMedia" itemID="https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg?quality=90&amp;auto=webp" itemType="http://schema.org/ImageObject"><div class="css-bsn42l"><span class="css-1dv1kvn">Image</span><img alt="“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, who pleaded guilty to an assault that occurred when she was 14." class="css-11cwn6f" src="https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg?quality=75&amp;auto=webp&amp;disable=upscale" srcSet="https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg?quality=90&amp;auto=webp 600w,https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-jumbo.jpg?quality=90&amp;auto=webp 683w,https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-superJumbo.jpg?quality=90&amp;auto=webp 1366w" sizes="((min-width: 600px) and (max-width: 1004px)) 84vw, (min-width: 1005px) 60vw, 100vw" itemProp="url" itemID="https://static01.nyt.com/images/2019/07/30/nyregion/00nypd-juveniles/merlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg?quality=75&amp;auto=webp&amp;disable=upscale"/></div><figcaption itemProp="caption description" class="css-17ai7jg emkp2hg0"><span aria-hidden="true" class="css-8i9d0s e13ogyst0">“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, who pleaded guilty to an assault that occurred when she was 14.</span><span itemProp="copyrightHolder" class="emkp2hg2 css-1nwzsjy e1z0qqy90"><span class="css-1ly73wi e1tej78p0">Credit</span><span><span class="css-1dv1kvn">Credit</span><span>Sarah Blesener for The New York Times</span></span></span></figcaption></figure></div></div><div class="css-acwcvw epjyd6m0"><div class="css-pdw9fk epjyd6m1"><div class="css-1txwxcy ey68jwv0"><a href="https://www.nytimes.com/by/joseph-goldstein" class="css-uwwqev"><img alt="Joseph Goldstein" title="Joseph Goldstein" src="https://static01.nyt.com/images/2018/07/16/multimedia/author-joseph-goldstein/author-joseph-goldstein-thumbLarge.png" class="css-1rjmmt7 ey68jwv2"/></a><a href="https://www.nytimes.com/by/ali-watkins" class="css-uwwqev"><img alt="Ali Watkins" title="Ali Watkins" src="https://static01.nyt.com/images/2019/02/20/multimedia/author-ali-watkins/author-ali-watkins-thumbLarge.png" class="css-1rjmmt7 ey68jwv2"/></a></div><div class="css-1baulvz"><p class="css-1nuro5j e1jsehar1" itemProp="author" itemscope="" itemType="http://schema.org/Person">By<!-- --> <a href="https://www.nytimes.com/by/joseph-goldstein" class="css-1riqqik e1jsehar0"><span class="css-1baulvz" itemProp="name">Joseph Goldstein</span></a> and <a href="https://www.nytimes.com/by/ali-watkins" class="css-1riqqik e1jsehar0"><span class="css-1baulvz last-byline" itemProp="name">Ali Watkins</span></a></p></div></div><ul class="css-1w5cs23 epjyd6m2"><li><time class="css-rqb9bm e16638kd0" dateTime="2019-08-01">Aug 1, 2019</time></li><li class="css-6n7j50"><div class=""><div role="toolbar" aria-label="Social Media Share buttons, Save button, and Comments Panel with current comment count" class="css-d8bdto" data-testid="share-tools"><ul class="css-y8aj3r"><li class="css-60hakz"><a href="https://www.facebook.com/dialog/feed?app_id=9869919170&amp;link=https%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html&amp;smid=fb-share&amp;name=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;redirect_uri=https%3A%2F%2Fwww.facebook.com%2F" target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook"><svg class="css-4brsb6" viewBox="0 0 7 15" width="7" height="15"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.775 14.163V7.08h1.923l.255-2.441H4.775l.004-1.222c0-.636.06-.977.958-.977H6.94V0H5.016c-2.31 0-3.123 1.184-3.123 3.175V4.64H.453v2.44h1.44v7.083h2.882z" fill="#000"></path></svg></a></li><li class="css-60hakz"><a href="https://twitter.com/intent/tweet?url=https%3A%2F%2Fnyti.ms%2F2GEzuZ8&amp;text=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database." target="_blank" rel="noopener noreferrer" aria-label="Share on Twitter"><svg viewBox="0 0 13 10" class="css-4brsb6" width="13" height="10"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.987 2.772l.025.425-.429-.052c-1.562-.2-2.927-.876-4.086-2.011L.93.571.784.987c-.309.927-.111 1.906.533 2.565.343.364.266.416-.327.2-.206-.07-.386-.122-.403-.096-.06.06.146.85.309 1.161.223.434.678.858 1.176 1.11l.42.199-.497.009c-.481 0-.498.008-.447.19.172.564.85 1.162 1.606 1.422l.532.182-.464.277a4.833 4.833 0 0 1-2.3.641c-.387.009-.704.044-.704.07 0 .086 1.047.572 1.657.762 1.828.564 4 .32 5.631-.641 1.159-.685 2.318-2.045 2.859-3.363.292-.702.583-1.984.583-2.6 0-.398.026-.45.507-.927.283-.277.55-.58.6-.667.087-.165.078-.165-.36-.018-.73.26-.832.226-.472-.164.266-.278.584-.78.584-.928 0-.026-.129.018-.275.096a4.79 4.79 0 0 1-.755.294l-.464.148-.42-.286C9.66.467 9.335.293 9.163.24 8.725.12 8.055.137 7.66.276c-1.074.39-1.752 1.395-1.674 2.496z" fill="#000"></path></svg></a></li><li class="css-60hakz"><a href="mailto:?subject=NYTimes.com%3A%20She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;body=From%20The%20New%20York%20Times%3A%0A%0AShe%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.%0A%0AWith%20little%20oversight%2C%20the%20N.Y.P.D.%20has%20been%20using%20powerful%20surveillance%20technology%20on%20photos%20of%20children%20and%20teenagers.%0A%0Ahttps%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html" target="_blank" rel="noopener noreferrer" aria-label="Email"><svg viewBox="0 0 15 9" class="css-4brsb6" width="15" height="9"><path fill-rule="evenodd" clip-rule="evenodd" d="M.906 8.418V0L5.64 4.76.906 8.419zm13 0L9.174 4.761 13.906 0v8.418zM7.407 6.539l-1.13-1.137L.907 9h13l-5.37-3.598-1.13 1.137zM1.297 0h12.22l-6.11 5.095L1.297 0z" fill="#000"></path></svg></a></li><li class="css-60hakz"><div class="css-6n7j50"><button aria-haspopup="true" aria-expanded="false" aria-label="More sharing options" class="css-16ogagc" data-testid=""><svg class="css-uhuo44" viewBox="0 0 16 13" width="16" height="13"><path fill-rule="evenodd" clip-rule="evenodd" d="M15.406 5.359L8.978 0v3.215C3.82 3.215.406 8.107.406 12.66 1.653 9.133 4.29 7.517 8.978 7.517v3.2l6.428-5.358z" fill="#000"></path></svg></button></div></li><li class="css-60hakz"></li><li class="css-l72opv"></li></ul></div></div></li></ul></div></header></div><section name="articleBody" itemProp="articleBody" class="meteredContent css-1i2y565"><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0"><em class="css-2fg4z9 e1gzwzxm0">[What you need to know to start the day: </em><a class="css-1g7m0tk" href="https://www.nytimes.com/newsletters/newyorktoday?module=inline" title=""><em class="css-2fg4z9 e1gzwzxm0">Get New York Today in your inbox</em></a><em class="css-2fg4z9 e1gzwzxm0">.]</em></p><p class="css-exrw3m evys1bk0">The New York Police Department has been loading <!-- -->thousands of arrest photos of children and teenagers<!-- --> into a facial recognition database despite evidence the technology has a higher risk of false matches in younger faces. </p><p class="css-exrw3m evys1bk0">For about four years, internal records show, the department has used the technology to compare crime scene images with its collection of juvenile mug <!-- -->shots<!-- -->, the photos that are taken at an arrest. Most of the photos are of teenagers, largely 13 to 16 years old, but children as young as 11 have been included. </p><p class="css-exrw3m evys1bk0">Elected officials and civil rights groups said the disclosure that the city was deploying a powerful surveillance tool on adolescents — whose privacy seems sacrosanct and whose status is protected in the criminal justice system — was a striking example of the Police Department’s ability to adopt advancing technology with little public scrutiny.</p><p class="css-exrw3m evys1bk0">Several members of the City Council as well as a range of civil liberties groups said they were unaware of the policy until they were contacted by The New York Times. </p></div><aside class="css-o6xoe7"></aside></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0">Police <!-- -->Department officials defended the decision, <!-- -->saying it was just the latest evolution of a longstanding policing technique: using arrest photos to identify suspects.</p><p class="css-exrw3m evys1bk0">“I don’t think this is any secret decision that’s made behind closed doors,” the city’s chief of detectives, Dermot F. Shea, said in an interview. “This is just process, and making sure we’re doing everything to fight crime.” </p><p class="css-exrw3m evys1bk0">Other cities have begun to debate whether law enforcement should use facial recognition, which relies on an algorithm to quickly pore through images and suggest matches. In May, <a class="css-1g7m0tk" href="https://www.nytimes.com/2019/05/14/us/facial-recognition-ban-san-francisco.html?module=inline" title="">San Francisco blocked city agencies, including the police</a>, from using the tool amid unease about potential government <!-- -->abuse<!-- -->. <a class="css-1g7m0tk" href="https://www.nytimes.com/2019/07/08/us/detroit-facial-recognition-cameras.html?module=inline" title="">Detroit is facing public resistance to a technology </a>that has been shown to have lower accuracy with people with darker skin. </p><p class="css-exrw3m evys1bk0">In New York, the state Education Department recently told the Lockport, N.Y., <!-- -->school district to delay a plan to use facial recognition on students, citing privacy concerns. </p><p class="css-exrw3m evys1bk0">“At the end <!-- -->of the day, it should be banned — no young people,” said <!-- -->Councilman Donovan Richards<!-- -->, a Queens Democrat who heads the Public Safety Committee, which oversees the Police Department. </p></div><aside class="css-o6xoe7"></aside></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0">The department said its legal bureau had approved using facial recognition on juveniles. <a class="css-1g7m0tk" href="https://www.nytimes.com/2019/06/09/opinion/facial-recognition-police-new-york-city.html?module=inline" title="">The algorithm may suggest a lead, but detectives would not make an arrest based solely on </a><a class="css-1g7m0tk" href="https://www.nytimes.com/2019/06/09/opinion/facial-recognition-police-new-york-city.html?module=inline" title="">that</a>, Chief Shea said.</p></div><aside class="css-o6xoe7"></aside></div><div class="css-79elbk" data-testid="photoviewer-wrapper"><div class="css-z3e15g" data-testid="photoviewer-wrapper-hidden"></div><div data-testid="photoviewer-children" class="css-1a48zt4 ehw59r15"><figure class="css-jcw7oy e1g7ppur0" aria-label="media" role="group" itemProp="associatedMedia" itemscope="" itemID="https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg?quality=90&amp;auto=webp" itemType="http://schema.org/ImageObject"><div class="css-1xdhyk6 erfvjey0"><span class="css-1ly73wi e1tej78p0">Image</span><img alt="Dermot Shea, the city&amp;rsquo;s chief of detectives, said investigators would not arrest anyone based solely on a facial recognition match." class="css-1m50asq" src="https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg?quality=75&amp;auto=webp&amp;disable=upscale" srcSet="https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg?quality=90&amp;auto=webp 600w,https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-jumbo.jpg?quality=90&amp;auto=webp 1024w,https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-superJumbo.jpg?quality=90&amp;auto=webp 2048w" sizes="((min-width: 600px) and (max-width: 1004px)) 84vw, (min-width: 1005px) 60vw, 100vw" itemProp="url" itemID="https://static01.nyt.com/images/2019/08/02/nyregion/02nypdjuveniles1-print/merlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg?quality=75&amp;auto=webp&amp;disable=upscale"/></div><figcaption itemProp="caption description" class="css-1l44abu e1xdpqjp0"><span aria-hidden="true" class="css-8i9d0s e13ogyst0">Dermot Shea, the city&rsquo;s chief of detectives, said investigators would not arrest anyone based solely on a facial recognition match.</span><span itemProp="copyrightHolder" class="css-vuqh7u e1z0qqy90"><span class="css-1ly73wi e1tej78p0">Credit</span><span>Chang W. Lee/The New York Times</span></span></figcaption></figure></div></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0">Still, facial recognition has not been widely tested on children. Most algorithms are taught to “think” based on adult faces, and there is growing evidence that they do not work as well on children. </p><p class="css-exrw3m evys1bk0">The National Institute of Standards and Technology<!-- -->, which is part of the Commerce Department and <a class="css-1g7m0tk" href="https://nvlpubs.nist.gov/nistpubs/ir/2018/NIST.IR.8238.pdf" title="" rel="noopener noreferrer" target="_blank">evaluates facial recognition </a><a class="css-1g7m0tk" href="https://nvlpubs.nist.gov/nistpubs/ir/2018/NIST.IR.8238.pdf" title="" rel="noopener noreferrer" target="_blank">algorithms</a> for accuracy, recently found the <!-- -->vast majority of more than 100 <!-- -->facial recognition <!-- -->algorithms had a higher rate of mistaken matches among children. The <!-- -->e<!-- -->rror rate was most pronounced in young children but was also seen in those aged 10 to 16.</p><p class="css-exrw3m evys1bk0">Aging poses another problem:<!-- --> The appearance of children and adolescents can change <!-- --> drastically as bones stretch and shift, altering the underlying facial structure. </p><p class="css-exrw3m evys1bk0">“I would use extreme caution in using those algorithms,” said <!-- -->Karl Ricanek Jr.<!-- -->, a computer science professor and <!-- -->co-founder of the Face Aging Group at the University of North Carolina-<!-- -->Wilmington<!-- -->. </p><p class="css-exrw3m evys1bk0">Technology that can match an image of a younger teenager to a recent arrest photo may be less effective at finding the same person even one or two years later, he said. </p></div><aside class="css-o6xoe7"></aside></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0"> “The systems do not have the capacity to understand the dynamic changes that occur to a child’s face,” Dr. Ricanek said. </p><p class="css-exrw3m evys1bk0">Idemia<!-- --> and <!-- -->DataWorks Plus<!-- -->, the two companies that provide facial recognition software to the Police Department, did not respond to requests for comment. </p><p class="css-exrw3m evys1bk0">The New York Police Department can take arrest photos of minors as young as <!-- -->11<!-- --> who are charged with a felony, depending on the severity of the charge. </p><p class="css-exrw3m evys1bk0">And in many cases, the department keeps the photos for years, making facial recognition comparisons to what may have effectively become outdated images. There are photos of 5,500 individuals in the juvenile database, 4,100 of whom are no longer 16 or under, the department said. Teenagers 17 and older are considered adults in the criminal justice system. </p><p class="css-exrw3m evys1bk0">Police officials declined to provide statistics on how often their facial recognition systems provide false matches, or to explain how they evaluate the system’s effectiveness.</p><p class="css-exrw3m evys1bk0">“We are comfortable with this technology because it has proved to be a valuable investigative method,” Chief Shea said. Facial recognition has helped lead to thousands of arrests of both adults and juveniles, the department has said. </p><p class="css-exrw3m evys1bk0">Mayor Bill de Blasio had been aware the department was using the technology on minors, said Freddi Goldstein, a spokeswoman for the mayor. </p></div><aside class="css-o6xoe7"></aside></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0">She said the Police Department followed “strict guidelines” in applying the technology and City Hall monitored the agency’s compliance with the policies. </p><p class="css-exrw3m evys1bk0">The Times learned details of the department’s use of facial recognition by reviewing documents posted online earlier this year by <a class="css-1g7m0tk" href="https://www.flawedfacedata.com/#footnote5" title="" rel="noopener noreferrer" target="_blank">Clare Garvie, a senior associate</a><a class="css-1g7m0tk" href="https://www.flawedfacedata.com/#footnote5" title="" rel="noopener noreferrer" target="_blank"> at the Center on Privacy and Technology at Georgetown Law</a>. Ms. Garvie received the documents as part of an open records lawsuit. </p><p class="css-exrw3m evys1bk0">It could not be determined whether other large police departments used facial recognition with juveniles because very few have written policies governing the use of the technology, Ms. Garvie said. </p><p class="css-exrw3m evys1bk0">New York detectives rely on a vast network<!-- --> of surveillance cameras throughout the city to provide images of people believed to have committed a crime. Since 2011, the department has had a dedicated unit of officers who use facial recognition to compare those images against millions of photos, usually mug shots. The software proposes matches, which have led to thousands of arrests, the department said. </p><p class="css-exrw3m evys1bk0">By 2013, top police officials were meeting to discuss including juveniles in the program, the documents reviewed by The Times showed. </p><p class="css-exrw3m evys1bk0">The documents showed that the juvenile database had been integrated into the system by 2015. </p><p class="css-exrw3m evys1bk0">“We have these photos. It makes sense,” Chief Shea said in the interview. </p><p class="css-exrw3m evys1bk0">State law requires that arrest photos be destroyed if the case is resolved in the juvenile’s favor, or if the child is found to have committed only a misdemeanor, rather than a felony. The photos also must be destroyed if a person reaches age 21 without a criminal record. <!-- --> </p></div><aside class="css-o6xoe7"></aside></div><div class="css-1fanzo5 StoryBodyCompanionColumn"><div class="css-53u6y8"><p class="css-exrw3m evys1bk0">When children are charged with crimes, the court system usually takes some steps to prevent their acts from defining them in later years. Children who are 16 and under, for instance, are generally sent to Family Court, where records are not public. </p><p class="css-exrw3m evys1bk0">Yet including their photos in a facial recognition database runs the risk that an imperfect algorithm identifies them as possible suspects in later crimes, civil rights advocates said. A mistaken match could lead investigators to focus on the wrong person from the outset, they said. </p><p class="css-exrw3m evys1bk0">“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, a 17-year-old Brooklyn girl who had admitted guilt in Family Court to a group attack that happened when she was 14. She said she was present at the attack but did not participate. </p><p class="css-exrw3m evys1bk0">Bailey, who asked that she be identified only by her <!-- -->last name<!-- --> because she did not want her juvenile arrest to be public, has not been arrested again and is now a student at John Jay College of Criminal Justice. </p><p class="css-exrw3m evys1bk0">R<!-- -->ecent studies indicate that people of color, as well as children and women, have a greater risk of misidentification than their counterparts, sai<!-- -->d <!-- -->Joy Buolamwini<!-- -->, <!-- -->the founder of the Algorithmic Justice League and graduate researcher at the M.I.T. Media Lab<!-- -->, who has examined how human biases are built into artificial intelligence. </p><p class="css-exrw3m evys1bk0">The racial disparities in the juvenile justice system are stark: In New York, black and Latino juveniles were charged with crimes at far higher rates than whites in 2017, the most recent year for which numbers were available. Black juveniles outnumbered white juveniles <a class="css-1g7m0tk" href="https://www.criminaljustice.ny.gov/crimnet/ojsa/jj-reports/newyorkcity.pdf" title="" rel="noopener noreferrer" target="_blank">more than 15 to 1</a>.</p><p class="css-exrw3m evys1bk0">“If the facial recognition algorithm has a negative bias toward a black population, that will get magnified more toward children,” Dr. Ricanek said, adding that in terms of diminished accuracy, “you’re now putting yourself in unknown territory.”</p></div><aside class="css-o6xoe7"></aside></div><div class="css-1soubk3 epkadsg3"><div class="css-15g2oxy epkadsg2"><div class="css-2b3w4o e16ij5yr6"><a class="css-1g7m0tk" href="https://www.nytimes.com/2019/06/09/opinion/facial-recognition-police-new-york-city.html?action=click&amp;module=RelatedLinks&amp;pgtype=Article"><div class="css-i9gxme e16ij5yr4"><div class="css-14b9hti e16ij5yr5">Opinion | James O’Neill</div><div class="css-1j8dw05 e16ij5yr2">How Facial Recognition Makes You Safer</div><time class="css-rqb9bm e16638kd0" dateTime="2019-06-09">Jun 9, 2019</time></div><div class="css-1vm5oi9 e16ij5yr0"><img src="https://static01.nyt.com/images/2019/06/07/opinion/sunday/07Oneill/07Oneill-threeByTwoSmallAt2X.jpg" class="css-32rbo2 e16ij5yr1"/></div></a></div><div class="css-2b3w4o e16ij5yr6"><a class="css-1g7m0tk" href="https://www.nytimes.com/2019/06/07/opinion/lockport-facial-recognition-schools.html?action=click&amp;module=RelatedLinks&amp;pgtype=Article"><div class="css-i9gxme e16ij5yr4"><div class="css-14b9hti e16ij5yr5">Opinion | Jim Shultz</div><div class="css-1j8dw05 e16ij5yr2">Spying on Children Won’t Keep Them Safe</div><time class="css-rqb9bm e16638kd0" dateTime="2019-06-07">Jun 7, 2019</time></div><div class="css-1vm5oi9 e16ij5yr0"><img src="https://static01.nyt.com/images/2019/06/07/opinion/07shultz-privacy/07shultz-privacy-threeByTwoSmallAt2X.jpg" class="css-32rbo2 e16ij5yr1"/></div></a></div></div></div></section><div class="bottom-of-article"><div class="css-1ubp8k9"></div><div class="css-wg1cha"><div class="css-19hdyf3 e1e7j8ap0"><div><p>Joseph Goldstein writes about policing and the criminal justice system. He has been a reporter at The Times since 2011, and is based in New York. He also worked for a year in the Kabul bureau, reporting on Afghanistan. <span class="css-4w91ra"> <a href="https://twitter.com/JoeKGoldstein" class="css-1rj8to8" rel="noopener noreferrer" target="_blank"><span class="css-0">@</span>JoeKGoldstein</a> </span></p></div></div><div class="css-19hdyf3 e1e7j8ap0"><div><p>Ali Watkins is a reporter on the Metro Desk, covering courts and social services. Previously, she covered national security in Washington for The Times, BuzzFeed and McClatchy Newspapers. <span class="css-4w91ra"> <a href="https://twitter.com/AliWatkins" class="css-1rj8to8" rel="noopener noreferrer" target="_blank"><span class="css-0">@</span>AliWatkins</a> </span></p></div></div></div><div class="css-vdv0al">A version of this article appears in print on <!-- -->, Section <!-- -->A<!-- -->, Page <!-- -->1<!-- --> of the New York edition<!-- --> with the headline: <!-- -->In New York, Police Computers Scan Faces, Some as Young as 11<span>. <a href="http://www.nytreprints.com/">Order Reprints</a> | <a href="http://www.nytimes.com/pages/todayspaper/index.html">Today’s Paper</a> | <a href="https://www.nytimes.com/subscriptions/Multiproduct/lp8HYKU.html?campaignId=48JQY">Subscribe</a></span></div><div class="css-i29ckm"><div class="css-10raysz"></div><div role="toolbar" aria-label="Social Media Share buttons, Save button, and Comments Panel with current comment count" class="css-d8bdto" data-testid="share-tools"><ul class="css-y8aj3r"><li class="css-ar1l6a"><a href="https://www.facebook.com/dialog/feed?app_id=9869919170&amp;link=https%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html&amp;smid=fb-share&amp;name=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;redirect_uri=https%3A%2F%2Fwww.facebook.com%2F" target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook"><svg class="css-4brsb6" viewBox="0 0 7 15" width="7" height="15"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.775 14.163V7.08h1.923l.255-2.441H4.775l.004-1.222c0-.636.06-.977.958-.977H6.94V0H5.016c-2.31 0-3.123 1.184-3.123 3.175V4.64H.453v2.44h1.44v7.083h2.882z" fill="#000"></path></svg></a></li><li class="css-ar1l6a"><a href="https://twitter.com/intent/tweet?url=https%3A%2F%2Fnyti.ms%2F2GEzuZ8&amp;text=She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database." target="_blank" rel="noopener noreferrer" aria-label="Share on Twitter"><svg viewBox="0 0 13 10" class="css-4brsb6" width="13" height="10"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.987 2.772l.025.425-.429-.052c-1.562-.2-2.927-.876-4.086-2.011L.93.571.784.987c-.309.927-.111 1.906.533 2.565.343.364.266.416-.327.2-.206-.07-.386-.122-.403-.096-.06.06.146.85.309 1.161.223.434.678.858 1.176 1.11l.42.199-.497.009c-.481 0-.498.008-.447.19.172.564.85 1.162 1.606 1.422l.532.182-.464.277a4.833 4.833 0 0 1-2.3.641c-.387.009-.704.044-.704.07 0 .086 1.047.572 1.657.762 1.828.564 4 .32 5.631-.641 1.159-.685 2.318-2.045 2.859-3.363.292-.702.583-1.984.583-2.6 0-.398.026-.45.507-.927.283-.277.55-.58.6-.667.087-.165.078-.165-.36-.018-.73.26-.832.226-.472-.164.266-.278.584-.78.584-.928 0-.026-.129.018-.275.096a4.79 4.79 0 0 1-.755.294l-.464.148-.42-.286C9.66.467 9.335.293 9.163.24 8.725.12 8.055.137 7.66.276c-1.074.39-1.752 1.395-1.674 2.496z" fill="#000"></path></svg></a></li><li class="css-ar1l6a"><a href="mailto:?subject=NYTimes.com%3A%20She%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.&amp;body=From%20The%20New%20York%20Times%3A%0A%0AShe%20Was%20Arrested%20at%2014.%20Then%20Her%20Photo%20Went%20to%20a%20Facial%20Recognition%20Database.%0A%0AWith%20little%20oversight%2C%20the%20N.Y.P.D.%20has%20been%20using%20powerful%20surveillance%20technology%20on%20photos%20of%20children%20and%20teenagers.%0A%0Ahttps%3A%2F%2Fwww.nytimes.com%2F2019%2F08%2F01%2Fnyregion%2Fnypd-facial-recognition-children-teenagers.html" target="_blank" rel="noopener noreferrer" aria-label="Email"><svg viewBox="0 0 15 9" class="css-4brsb6" width="15" height="9"><path fill-rule="evenodd" clip-rule="evenodd" d="M.906 8.418V0L5.64 4.76.906 8.419zm13 0L9.174 4.761 13.906 0v8.418zM7.407 6.539l-1.13-1.137L.907 9h13l-5.37-3.598-1.13 1.137zM1.297 0h12.22l-6.11 5.095L1.297 0z" fill="#000"></path></svg></a></li><li class="css-ar1l6a"><div class="css-6n7j50"><button aria-haspopup="true" aria-expanded="false" aria-label="More sharing options" class="css-16ogagc" data-testid=""><svg class="css-uhuo44" viewBox="0 0 16 13" width="16" height="13"><path fill-rule="evenodd" clip-rule="evenodd" d="M15.406 5.359L8.978 0v3.215C3.82 3.215.406 8.107.406 12.66 1.653 9.133 4.29 7.517 8.978 7.517v3.2l6.428-5.358z" fill="#000"></path></svg></button></div></li></ul></div></div></div><div></div><div><div id="bottom-wrapper" class="css-1ede5it"><div id="bottom-slug" class="css-l9onyx"><p>Advertisement</p></div><div class="ad bottom-wrapper" style="text-align:center;height:100%;display:block;min-height:90px"><div id="bottom" class="" data-position="bottom"></div></div></div></div></article></div></main><nav class="css-1ropbjl" id="site-index" aria-labelledby="site-index-label" data-testid="site-index"><div class="css-uw59u"><header class="css-jxzr5i" data-testid="site-index-header"><h2 class="css-vz7hjd" id="site-index-label">Site Index</h2><a href="/"><svg xmlns="http://www.w3.org/2000/svg" class="css-oylsik" viewBox="0 0 184 25" fill="#000"><path d="M13.8 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8C6.2 1.4 5 1 4 1 2 1 .6 2.5.6 4.2c0 1.5 1.1 2 1.5 2.2l.1-.2c-.2-.2-.5-.4-.5-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8v3.1L9 10.2v.1l1.5 1.3v4.3c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2C3.6 6.9 4.7 6 5.8 5.4l-.1-.3c-3 .8-5.7 3.6-5.7 7 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1l-1.6-1.3V5.8c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7 0-1.5.2-2.1l2.1-.9v6.2zm10.6 2.3l-1.3 1 .2.2.6-.5 2.2 2 3-2-.1-.2-.8.5-1-1V9.4l.8-.6 1.7 1.4v6.1c0 3.8-.8 4.4-2.5 5v.3c2.8.1 5.4-.8 5.4-5.7V9.3l.9-.7-.2-.2-.8.6-2.5-2.1L18.5 9V.8h-.2l-3.5 2.4v.2c.4.2 1 .4 1 1.5l-.1 11.3zM34 15.1L31.5 17 29 15v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM53.1 2c0-.3-.1-.6-.2-.9h-.2c-.3.8-.7 1.2-1.7 1.2-.9 0-1.5-.5-1.9-.9l-2.9 3.3.2.2 1-.9c.6.5 1.1.9 2.5 1v8.3L44 3.2c-.5-.8-1.2-1.9-2.6-1.9-1.6 0-3 1.4-2.8 3.6h.3c.1-.6.4-1.3 1.1-1.3.5 0 1 .5 1.3 1v3.3c-1.8 0-3 .8-3 2.3 0 .8.4 2 1.6 2.3v-.2c-.2-.2-.3-.4-.3-.7 0-.5.4-.9 1.1-.9h.5v4.2c-2.1 0-3.8 1.2-3.8 3.2 0 1.9 1.6 2.8 3.4 2.7v-.2c-1.1-.1-1.6-.6-1.6-1.3 0-.9.6-1.3 1.4-1.3.8 0 1.5.5 2 1.1l2.9-3.2-.2-.2-.7.8c-1.1-1-1.7-1.3-3-1.5V5l8 14h.6V5c1.5-.1 2.9-1.3 2.9-3zm7.3 13.1L57.9 17l-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.6l-1 .8.2.2.9-.7 3.4 2.5 4.5-3.6-.1-.4zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zM76.7 8l-.7.5-1.9-1.6-2.2 2 .9.9v7.5l-2.4-1.5V9.6l.8-.5-2.3-2.2-2.2 2 .9.9V17l-.3.2-2.1-1.5v-6c0-1.4-.7-1.8-1.5-2.3-.7-.5-1.1-.8-1.1-1.5 0-.6.6-.9.9-1.1v-.2c-.8 0-2.9.8-2.9 2.7 0 1 .5 1.4 1 1.9s1 .9 1 1.8v5.8l-1.1.8.2.2 1-.8 2.3 2 2.5-1.7 2.8 1.7 5.3-3.1V9.2l1.3-1-.2-.2zm18.6-5.5l-1 .9-2.2-2-3.3 2.4V1.6h-.3l.1 16.2c-.3 0-1.2-.2-1.9-.4l-.2-13.5c0-1-.7-2.4-2.5-2.4s-3 1.4-3 2.8h.3c.1-.6.4-1.1 1-1.1s1.1.4 1.1 1.7v3.9c-1.8.1-2.9 1.1-2.9 2.4 0 .8.4 2 1.6 2V13c-.4-.2-.5-.5-.5-.7 0-.6.5-.8 1.3-.8h.4v6.2c-1.5.5-2.1 1.6-2.1 2.8 0 1.7 1.3 2.9 3.3 2.9 1.4 0 2.6-.2 3.8-.5 1-.2 2.3-.5 2.9-.5.8 0 1.1.4 1.1.9 0 .7-.3 1-.7 1.1v.2c1.6-.3 2.6-1.3 2.6-2.8s-1.5-2.4-3.1-2.4c-.8 0-2.5.3-3.7.5-1.4.3-2.8.5-3.2.5-.7 0-1.5-.3-1.5-1.3 0-.8.7-1.5 2.4-1.5.9 0 2 .1 3.1.4 1.2.3 2.3.6 3.3.6 1.5 0 2.8-.5 2.8-2.6V3.7l1.2-1-.2-.2zm-4.1 6.1c-.3.3-.7.6-1.2.6s-1-.3-1.2-.6V4.2l1-.7 1.4 1.3v3.8zm0 3c-.2-.2-.7-.5-1.2-.5s-1 .3-1.2.5V9c.2.2.7.5 1.2.5s1-.3 1.2-.5v2.6zm0 4.7c0 .8-.5 1.6-1.6 1.6h-.8V12c.2-.2.7-.5 1.2-.5s.9.3 1.2.5v4.3zm13.7-7.1l-3.2-2.3-4.9 2.8v6.5l-1 .8.1.2.8-.6 3.2 2.4 5-3V9.2zm-5.4 6.3V8.3l2.5 1.8v7.1l-2.5-1.7zm14.9-8.4h-.2c-.3.2-.6.4-.9.4-.4 0-.9-.2-1.1-.5h-.2l-1.7 1.9-1.7-1.9-3 2 .1.2.8-.5 1 1.1v6.3l-1.3 1 .2.2.6-.5 2.4 2 3.1-2.1-.1-.2-.9.5-1.2-1V9c.5.5 1.1 1 1.8 1 1.4.1 2.2-1.3 2.3-2.9zm12 9.6L123 19l-4.6-7 3.3-5.1h.2c.4.4 1 .8 1.7.8s1.2-.4 1.5-.8h.2c-.1 2-1.5 3.2-2.5 3.2s-1.5-.5-2.1-.8l-.3.5 5 7.4 1-.6v.1zm-11-.5l-1.3 1 .2.2.6-.5 2.2 2 3-2-.2-.2-.8.5-1-1V.8h-.1l-3.6 2.4v.2c.4.2 1 .3 1 1.5v11.3zM143 2.9c0-2-1.9-2.5-3.4-2.5v.3c.9 0 1.6.3 1.6 1 0 .4-.3 1-1.2 1-.7 0-2.2-.4-3.3-.8-1.3-.4-2.5-.8-3.5-.8-2 0-3.4 1.5-3.4 3.2 0 1.5 1.1 2 1.5 2.2l.1-.2c-.3-.2-.6-.4-.6-1 0-.4.4-1.1 1.4-1.1.9 0 2.1.4 3.7.9 1.4.4 2.9.7 3.7.8V9l-1.5 1.3v.1l1.5 1.3V16c-.8.5-1.7.6-2.5.6-1.5 0-2.8-.4-3.9-1.6l4.1-2V6l-5 2.2c.5-1.3 1.6-2.2 2.6-2.9l-.1-.2c-3 .8-5.7 3.5-5.7 6.9 0 4 3.3 7 7 7 4 0 6.6-3.2 6.6-6.5h-.2c-.6 1.3-1.5 2.5-2.6 3.1v-4.1l1.6-1.3v-.1L140 8.8v-3c1.5 0 3-1 3-2.9zm-8.7 11l-1.2.6c-.7-.9-1.1-2.1-1.1-3.8 0-.7.1-1.5.3-2.1l2.1-.9-.1 6.2zm12.2-12h-.1l-2 1.7v.1l1.7 1.9h.2l2-1.7v-.1l-1.8-1.9zm3 14.8l-.8.5-1-1V9.3l1-.7-.2-.2-.7.6-1.8-2.1-2.9 2 .2.3.7-.5.9 1.1v6.5l-1.3 1 .1.2.7-.5 2.2 2 3-2-.1-.3zm16.7-.1l-.7.5-1.1-1V9.3l1-.8-.2-.2-.8.7-2.3-2.1-3 2.1-2.3-2.1L154 9l-1.8-2.1-2.9 2 .1.3.7-.5 1 1.1v6.5l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.9-.6 1.5 1.4v6l-.8.8 2.3 1.9 2.2-2-.9-.9V9.3l.8-.5 1.6 1.4v6l-.7.7 2.3 2.1 3.1-2.1v-.3zm8.7-1.5l-2.5 1.9-2.5-2v-1.2l4.7-3.2v-.1l-2.4-3.6-5.2 2.8v6.8l3.5 2.5 4.5-3.6-.1-.3zm-5-1.7V8.5l.2-.1 2.2 3.5-2.4 1.5zm14.1-.9l-1.9-1.5c1.3-1.1 1.8-2.6 1.8-3.6v-.6h-.2c-.2.5-.6 1-1.4 1-.8 0-1.3-.4-1.8-1L176 9.3v3.6l1.7 1.3c-1.7 1.5-2 2.5-2 3.3 0 1 .5 1.7 1.3 2l.1-.2c-.2-.2-.4-.3-.4-.8 0-.3.4-.8 1.2-.8 1 0 1.6.7 1.9 1l4.3-2.6v-3.6h-.1zm-1.1-3c-.7 1.2-2.2 2.4-3.1 3l-1.1-.9V8.1c.4 1 1.5 1.8 2.6 1.8.7 0 1.1-.1 1.6-.4zm-1.7 8c-.5-1.1-1.7-1.9-2.9-1.9-.3 0-1.1 0-1.9.5.5-.8 1.8-2.2 3.5-3.2l1.2 1 .1 3.6z"></path></svg></a><div class="css-1otr2jl" data-testid="go-to-homepage"><a class="css-1c8n994" href="/">Go to Home Page »</a></div></header><div class="css-qtw155" data-testid="site-index-accordion"><div class=" " role="tablist" aria-multiselectable="true" data-testid="accordion"><div class="" data-testid="accordion-item"><header aria-controls="body-siteindex-0" id="item-siteindex-0" class="css-mn5hq9" role="tab" tabindex="0" aria-expanded="false" data-testid="accordion-item-header">news</header><div class="css-1hyfx7x" id="body-siteindex-0" aria-labelledby="item-siteindex-0" aria-expanded="false" role="tabpanel" data-testid="accordion-item-body"><ul class="css-1gprdgz" data-testid="site-index-accordion-list"><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com" data-testid="accordion-item-list-link">home page</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/world" data-testid="accordion-item-list-link">world</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/us" data-testid="accordion-item-list-link">U.S.</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/politics" data-testid="accordion-item-list-link">politics</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/news-event/2020-election" data-testid="accordion-item-list-link">Election 2020</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/nyregion" data-testid="accordion-item-list-link">New York</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/business" data-testid="accordion-item-list-link">business</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/technology" data-testid="accordion-item-list-link">tech</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/science" data-testid="accordion-item-list-link">science</a></li><li class="css-10t7hia smartphone"><a class="css-mzqdl" href="https://www.nytimes.com/section/climate" data-testid="accordion-item-list-link">climate</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/sports" data-testid="accordion-item-list-link">sports</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/obituaries" data-testid="accordion-item-list-link">obituaries</a></li><li class="css-10t7hia smartphone"><a class="css-mzqdl" href="https://www.nytimes.com/section/upshot" data-testid="accordion-item-list-link">the upshot</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/todayspaper" data-testid="accordion-item-list-link">today&#x27;s paper</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/corrections" data-testid="accordion-item-list-link">corrections</a></li></ul></div></div><div class="" data-testid="accordion-item"><header aria-controls="body-siteindex-1" id="item-siteindex-1" class="css-mn5hq9" role="tab" tabindex="0" aria-expanded="false" data-testid="accordion-item-header">opinion</header><div class="css-1hyfx7x" id="body-siteindex-1" aria-labelledby="item-siteindex-1" aria-expanded="false" role="tabpanel" data-testid="accordion-item-body"><ul class="css-1gprdgz" data-testid="site-index-accordion-list"><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion" data-testid="accordion-item-list-link">today&#x27;s opinion</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion/columnists" data-testid="accordion-item-list-link">op-ed columnists</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion/editorials" data-testid="accordion-item-list-link">editorials</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion/contributors" data-testid="accordion-item-list-link">op-ed Contributors</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion/letters" data-testid="accordion-item-list-link">letters</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/opinion/sunday" data-testid="accordion-item-list-link">sunday review</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/video/opinion" data-testid="accordion-item-list-link">video: opinion</a></li></ul></div></div><div class="" data-testid="accordion-item"><header aria-controls="body-siteindex-2" id="item-siteindex-2" class="css-mn5hq9" role="tab" tabindex="0" aria-expanded="false" data-testid="accordion-item-header">arts</header><div class="css-1hyfx7x" id="body-siteindex-2" aria-labelledby="item-siteindex-2" aria-expanded="false" role="tabpanel" data-testid="accordion-item-body"><ul class="css-1gprdgz" data-testid="site-index-accordion-list"><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/arts" data-testid="accordion-item-list-link">today&#x27;s arts</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/arts/design" data-testid="accordion-item-list-link">art &amp; design</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/books" data-testid="accordion-item-list-link">books</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/arts/dance" data-testid="accordion-item-list-link">dance</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/movies" data-testid="accordion-item-list-link">movies</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/arts/music" data-testid="accordion-item-list-link">music</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/spotlight/pop-culture" data-testid="accordion-item-list-link">Pop Culture</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/arts/television" data-testid="accordion-item-list-link">television</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/theater" data-testid="accordion-item-list-link">theater</a></li><li class="css-10t7hia smartphone"><a class="css-mzqdl" href="https://www.nytimes.com/watching" data-testid="accordion-item-list-link">watching</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/video/arts" data-testid="accordion-item-list-link">video: arts</a></li></ul></div></div><div class="" data-testid="accordion-item"><header aria-controls="body-siteindex-3" id="item-siteindex-3" class="css-mn5hq9" role="tab" tabindex="0" aria-expanded="false" data-testid="accordion-item-header">living</header><div class="css-1hyfx7x" id="body-siteindex-3" aria-labelledby="item-siteindex-3" aria-expanded="false" role="tabpanel" data-testid="accordion-item-body"><ul class="css-1gprdgz" data-testid="site-index-accordion-list"><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/automobiles" data-testid="accordion-item-list-link">automobiles</a></li><li class="css-10t7hia smartphone"><a class="css-mzqdl" href="https://cooking.nytimes.com/" data-testid="accordion-item-list-link">Cooking</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/crosswords" data-testid="accordion-item-list-link">crossword</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/education" data-testid="accordion-item-list-link">education</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/food" data-testid="accordion-item-list-link">food</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/health" data-testid="accordion-item-list-link">health</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/jobs" data-testid="accordion-item-list-link">jobs</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/magazine" data-testid="accordion-item-list-link">magazine</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://parenting.nytimes.com/" data-testid="accordion-item-list-link">parenting</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/realestate" data-testid="accordion-item-list-link">real estate</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/style" data-testid="accordion-item-list-link">style</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/t-magazine" data-testid="accordion-item-list-link">t magazine</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/travel" data-testid="accordion-item-list-link">travel</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/fashion/weddings" data-testid="accordion-item-list-link">love</a></li></ul></div></div><div class="" data-testid="accordion-item"><header aria-controls="body-siteindex-4" id="item-siteindex-4" class="css-mn5hq9" role="tab" tabindex="0" aria-expanded="false" data-testid="accordion-item-header">listings &amp; more</header><div class="css-1hyfx7x" id="body-siteindex-4" aria-labelledby="item-siteindex-4" aria-expanded="false" role="tabpanel" data-testid="accordion-item-body"><ul class="css-1gprdgz" data-testid="site-index-accordion-list"><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/reader-center" data-testid="accordion-item-list-link">Reader Center</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://thewirecutter.com" data-testid="accordion-item-list-link">Wirecutter</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="http://nytconferences.com/" data-testid="accordion-item-list-link">Live Events</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/learning" data-testid="accordion-item-list-link">The Learning Network</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="http://www.nytimes.com/marketing/tools-and-services" data-testid="accordion-item-list-link">tools &amp; services</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/events" data-testid="accordion-item-list-link">N.Y.C. events guide</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/multimedia" data-testid="accordion-item-list-link">multimedia</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/section/lens" data-testid="accordion-item-list-link">photography</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/video" data-testid="accordion-item-list-link">video</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/newsletters" data-testid="accordion-item-list-link">Newsletters</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/store" data-testid="accordion-item-list-link">NYT store</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://www.nytimes.com/times-journeys" data-testid="accordion-item-list-link">times journeys</a></li><li class="css-10t7hia"><a class="css-mzqdl" href="https://myaccount.nytimes.com/membercenter/myaccount.html" data-testid="accordion-item-list-link">manage my account</a></li></ul></div></div></div></div><div class="css-v0l3hm" data-testid="site-index-sections"><div class="css-g4gku8" data-testid="site-index-section"><section class="css-1rr4qq7" aria-labelledby="site-index-section-label-0"><h3 class="css-rxqrcl" id="site-index-section-label-0">news</h3><ul class="css-1iruc8t" data-testid="site-index-section-list"><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com" data-testid="site-index-section-list-link">home page</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/world" data-testid="site-index-section-list-link">world</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/us" data-testid="site-index-section-list-link">U.S.</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/politics" data-testid="site-index-section-list-link">politics</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/news-event/2020-election" data-testid="site-index-section-list-link">Election 2020</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/nyregion" data-testid="site-index-section-list-link">New York</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/business" data-testid="site-index-section-list-link">business</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/technology" data-testid="site-index-section-list-link">tech</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/science" data-testid="site-index-section-list-link">science</a></li><li class="css-ist4u3 smartphone"><a class="css-kwpx34" href="https://www.nytimes.com/section/climate" data-testid="site-index-section-list-link">climate</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/sports" data-testid="site-index-section-list-link">sports</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/obituaries" data-testid="site-index-section-list-link">obituaries</a></li><li class="css-ist4u3 smartphone"><a class="css-kwpx34" href="https://www.nytimes.com/section/upshot" data-testid="site-index-section-list-link">the upshot</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/todayspaper" data-testid="site-index-section-list-link">today&#x27;s paper</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/corrections" data-testid="site-index-section-list-link">corrections</a></li></ul></section><section class="css-1rr4qq7" aria-labelledby="site-index-section-label-1"><h3 class="css-rxqrcl" id="site-index-section-label-1">opinion</h3><ul class="css-1iruc8t" data-testid="site-index-section-list"><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion" data-testid="site-index-section-list-link">today&#x27;s opinion</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion/columnists" data-testid="site-index-section-list-link">op-ed columnists</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion/editorials" data-testid="site-index-section-list-link">editorials</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion/contributors" data-testid="site-index-section-list-link">op-ed Contributors</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion/letters" data-testid="site-index-section-list-link">letters</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/opinion/sunday" data-testid="site-index-section-list-link">sunday review</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/video/opinion" data-testid="site-index-section-list-link">video: opinion</a></li></ul></section><section class="css-1rr4qq7" aria-labelledby="site-index-section-label-2"><h3 class="css-rxqrcl" id="site-index-section-label-2">arts</h3><ul class="css-1iruc8t" data-testid="site-index-section-list"><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/arts" data-testid="site-index-section-list-link">today&#x27;s arts</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/arts/design" data-testid="site-index-section-list-link">art &amp; design</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/books" data-testid="site-index-section-list-link">books</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/arts/dance" data-testid="site-index-section-list-link">dance</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/movies" data-testid="site-index-section-list-link">movies</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/arts/music" data-testid="site-index-section-list-link">music</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/spotlight/pop-culture" data-testid="site-index-section-list-link">Pop Culture</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/arts/television" data-testid="site-index-section-list-link">television</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/theater" data-testid="site-index-section-list-link">theater</a></li><li class="css-ist4u3 smartphone"><a class="css-kwpx34" href="https://www.nytimes.com/watching" data-testid="site-index-section-list-link">watching</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/video/arts" data-testid="site-index-section-list-link">video: arts</a></li></ul></section><section class="css-1rr4qq7" aria-labelledby="site-index-section-label-3"><h3 class="css-rxqrcl" id="site-index-section-label-3">living</h3><ul class="css-1iruc8t" data-testid="site-index-section-list"><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/automobiles" data-testid="site-index-section-list-link">automobiles</a></li><li class="css-ist4u3 smartphone"><a class="css-kwpx34" href="https://cooking.nytimes.com/" data-testid="site-index-section-list-link">Cooking</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/crosswords" data-testid="site-index-section-list-link">crossword</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/education" data-testid="site-index-section-list-link">education</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/food" data-testid="site-index-section-list-link">food</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/health" data-testid="site-index-section-list-link">health</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/jobs" data-testid="site-index-section-list-link">jobs</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/magazine" data-testid="site-index-section-list-link">magazine</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://parenting.nytimes.com/" data-testid="site-index-section-list-link">parenting</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/realestate" data-testid="site-index-section-list-link">real estate</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/style" data-testid="site-index-section-list-link">style</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/t-magazine" data-testid="site-index-section-list-link">t magazine</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/travel" data-testid="site-index-section-list-link">travel</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/fashion/weddings" data-testid="site-index-section-list-link">love</a></li></ul></section><section class="css-1rr4qq7" aria-labelledby="site-index-section-label-4"><h3 class="css-rxqrcl" id="site-index-section-label-4">more</h3><ul class="css-1iruc8t" data-testid="site-index-section-list"><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/reader-center" data-testid="site-index-section-list-link">Reader Center</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://thewirecutter.com" data-testid="site-index-section-list-link">Wirecutter</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="http://nytconferences.com/" data-testid="site-index-section-list-link">Live Events</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/learning" data-testid="site-index-section-list-link">The Learning Network</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="http://www.nytimes.com/marketing/tools-and-services" data-testid="site-index-section-list-link">tools &amp; services</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/events" data-testid="site-index-section-list-link">N.Y.C. events guide</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/multimedia" data-testid="site-index-section-list-link">multimedia</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/section/lens" data-testid="site-index-section-list-link">photography</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/video" data-testid="site-index-section-list-link">video</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/newsletters" data-testid="site-index-section-list-link">Newsletters</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/store" data-testid="site-index-section-list-link">NYT store</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://www.nytimes.com/times-journeys" data-testid="site-index-section-list-link">times journeys</a></li><li class="css-ist4u3"><a class="css-kwpx34" href="https://myaccount.nytimes.com/membercenter/myaccount.html" data-testid="site-index-section-list-link">manage my account</a></li></ul></section><div class="css-6xhk3s" aria-labelledby="site-index-subscribe-label"><h3 class="css-rxqrcl" id="site-index-subscribe-label">Subscribe</h3><ul class="css-1iruc8t" data-testid="site-index-subscribe-list"><li class="css-tj0ten"><a class="css-1k2cjfc" href="https://www.nytimes.com/hdleftnav" data-testid="site-index-subscribe-list-link"><svg class="css-r5ic95" viewBox="0 0 14 13" fill="#000"><path d="M13.1,11.7H3.5V1.2h9.6V11.7zM13.1,0.4H3.5C3,0.4,2.6,0.8,2.6,1.2v2.2H0.9C0.4,3.4,0,3.8,0,4.3v5.2v1.5c0,0.8,0.8,1.5,1.8,1.5h1.7h0h7.4h2.2c0.5,0,0.9-0.4,0.9-0.9V1.2C14,0.8,13.6,0.4,13.1,0.4"></path><polygon points="10.9,3 5.2,3 5.2,3.9 11.4,3.9 11.4,3"></polygon><rect x="5.2" y="4.7" width="6.1" height="0.9"></rect><rect x="5.2" y="6.5" width="6.1" height="0.9"></rect></svg>home delivery</a></li><li class="css-tj0ten"><a class="css-1k2cjfc" href="https://www.nytimes.com/digitalleftnav" data-testid="site-index-subscribe-list-link"><svg class="css-r5ic95" viewBox="0 0 10 13"><path fill="#000" d="M9.9,8c-0.4,1.1-1.2,1.9-2.3,2.4V8l1.3-1.2L7.6,5.7V4c1.2-0.1,2-1,2-2c0-1.4-1.3-1.9-2.1-1.9c-0.2,0-0.3,0-0.6,0.1v0.1c0.1,0,0.2,0,0.3,0c0.5,0,0.9,0.2,0.9,0.7c0,0.4-0.3,0.7-0.8,0.7C6,1.7,4.5,0.6,2.8,0.6c-1.5,0-2.5,1.1-2.5,2.2C0.3,4,1,4.3,1.6,4.6l0-0.1C1.4,4.4,1.3,4.1,1.3,3.8c0-0.5,0.5-0.9,1-0.9C3.7,2.9,6,4,7.4,4h0.1v1.7L6.2,6.8L7.5,8v2.4c-0.5,0.2-1.1,0.3-1.7,0.3c-2.2,0-3.6-1.3-3.6-3.5c0-0.5,0.1-1,0.2-1.5l1.1-0.5V10l2.2-1v-5L2.5,5.5c0.3-1,1-1.7,1.8-2l0,0C2.2,3.9,0.1,5.6,0.1,8c0,2.9,2.4,4.8,5.2,4.8C8.2,12.9,9.9,10.9,9.9,8L9.9,8z"></path></svg>digital subscriptions</a></li><li class="css-tj0ten"><a class="css-1k2cjfc" href="https://www.nytimes.com/subscription/games/lp897H9.html" data-testid="site-index-subscribe-list-link"><svg class="css-r5ic95" viewBox="0 0 13 13" fill="#000"><polygon points="0,-93.6 0,-86.9 6.6,-93.6"></polygon><polygon points="0.9,-86 7.5,-86 7.5,-92.6"></polygon><polygon points="0,-98 0,-94.8 8.8,-94.8 8.8,-86 12,-86 12,-98"></polygon><path d="M11.9-40c-0.4,1.1-1.2,1.9-2.3,2.4V-40l1.3-1.2l-1.3-1.2V-44c1.2-0.1,2-1,2-2c0-1.4-1.3-1.9-2.1-1.9c-0.2,0-0.3,0-0.6,0.1v0.1c0.1,0,0.2,0,0.3,0c0.5,0,0.9,0.2,0.9,0.7c0,0.4-0.3,0.7-0.8,0.7c-1.3,0-2.8-1.1-4.5-1.1c-1.5,0-2.5,1.1-2.5,2.2c0,1.1,0.6,1.5,1.3,1.7l0-0.1c-0.2-0.1-0.4-0.4-0.4-0.7c0-0.5,0.5-0.9,1-0.9C5.7-45.1,8-44,9.4-44h0.1v1.7l-1.3,1.1L9.5-40v2.4c-0.5,0.2-1.1,0.3-1.7,0.3c-2.2,0-3.6-1.3-3.6-3.5c0-0.5,0.1-1,0.2-1.5l1.1-0.5v4.9l2.2-1v-5l-3.3,1.5c0.3-1,1-1.7,1.8-2l0,0c-2.2,0.5-4.3,2.1-4.3,4.6c0,2.9,2.4,4.8,5.2,4.8C10.2-35.1,11.9-37.1,11.9-40L11.9-40z"></path><path d="M12.2-23.7c-0.2,0-0.4,0.2-0.4,0.4v0.4L0.4-19.1v2.3l3,1l-0.2,0.6c-0.3,0.8,0.1,1.8,0.9,2.1l1.7,0.7c0.2,0.1,0.4,0.1,0.6,0.1c0.6,0,1.3-0.4,1.5-1l0.4-0.9l3.5,1.2v0.4c0,0.2,0.2,0.4,0.4,0.4c0.2,0,0.4-0.2,0.4-0.4v-10.7C12.6-23.5,12.4-23.7,12.2-23.7M7.1-13.6c-0.2,0.4-0.6,0.6-1,0.4l-1.7-0.7c-0.4-0.2-0.6-0.6-0.4-1l0.3-0.7l3.3,1.1L7.1-13.6z"></path><path d="M13.1-60.3H3.5v-10.5h9.6V-60.3zM13.1-71.6H3.5c-0.5,0-0.9,0.4-0.9,0.9v2.2H0.9c-0.5,0-0.9,0.4-0.9,0.9v5.2v1.5c0,0.8,0.8,1.5,1.8,1.5h1.7h0h7.4h2.2c0.5,0,0.9-0.4,0.9-0.9v-10.5C14-71.2,13.6-71.6,13.1-71.6"></path><polygon points="10.9,-69 5.2,-69 5.2,-68.1 11.4,-68.1 11.4,-69"></polygon><rect x="5.2" y="-67.3" width="6.1" height="0.9"></rect><rect x="5.2" y="-65.5" width="6.1" height="0.9"></rect><path d="M12,6.5H6.5V12H1V6.5h5.5V1H12V6.5zM12,0H1C0.4,0,0,0.5,0,1v11c0,0.6,0.4,1,1,1h11c0.5,0,1-0.4,1-1V1C13,0.5,12.5,0,12,0"></path></svg>Crossword</a></li><li class="css-tj0ten"><a class="css-1k2cjfc" href="https://www.nytimes.com/subscriptions/Multiproduct/lp8R3WU.html" data-testid="site-index-subscribe-list-link"><svg class="css-r5ic95" viewBox="0 0 13 13" fill="#000"><path d="M12,2.9L9.6,5.2c-0.1,0.1-0.3,0.1-0.4,0C9.1,5.2,9.1,5,9.3,4.9l2.4-2.4c-0.2-0.2-0.3-0.3-0.5-0.5L8.7,4.3c-0.1,0.1-0.3,0.1-0.4,0C8.2,4.3,8.2,4.1,8.4,4l2.4-2.4c-0.3-0.3-0.5-0.5-0.5-0.5L7.6,3.4C7.1,4,6.8,5.1,7.1,5.8c-1.4,1-4.6,3.5-5.1,4c-0.8,0.8-0.4,1.8-0.3,1.9c0,0,0,0,0,0c0,0,0,0,0,0c0.1,0.1,1.1,0.5,1.9-0.3c0.4-0.4,2.9-3.6,3.9-5C8.4,6.9,9.6,6.6,10.2,6l2.3-2.6C12.5,3.4,12.3,3.2,12,2.9z"></path><path d="M0.8,1.9l0.3-0.3c0.9-0.9,3.2,1.1,3.8,1.7s0.9,1.8,0.4,2.6c1.4,1.1,4.6,3.5,5,3.9c0.8,0.8,0.4,1.8,0.3,1.9c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-1.1,0.5-1.9-0.3c-0.4-0.4-2.9-3.7-4-5.1C3.9,6.7,2.9,6.4,2.3,5.8S-0.2,2.9,0.8,1.9z"></path></svg>Cooking</a></li></ul><ul class="css-1iruc8t" data-testid="site-index-corporate-links"><li><a class="css-1vhk1ks" href="https://www.nytimes.com/marketing/newsletters">email newsletters</a></li><li><a class="css-1vhk1ks" href="https://www.nytimes.com/corporateleftnav">corporate subscriptions</a></li><li><a class="css-1vhk1ks" href="https://www.nytimes.com/educationleftnav">education rate</a></li></ul><ul class="css-6td9kr" data-testid="site-index-alternate-links"><li><a class="css-1vhk1ks" href="https://www.nytimes.com/services/mobile/index.html">mobile applications</a></li><li><a class="css-1vhk1ks" href="http://eedition.nytimes.com/cgi-bin/signup.cgi?cc=37FYY">replica edition</a></li></ul></div></div></div></div></nav><footer role="contentinfo" class="css-1qmnftd e5u916q0"><nav data-testid="footer" class="css-15uy5yv"><h2 class="css-vz7hjd">Site Information Navigation</h2><ul class="css-1ho5u4o e5u916q1"><li data-testid="copyright"><a class="css-1p8nkc0" href="https://www.nytimes.com/content/help/rights/copyright/copyright-notice.html">© <span itemProp="copyrightYear">2019</span><span itemProp="publisher copyrightHolder provider sourceOrganization" itemscope="" itemType="http://schema.org/NewsMediaOrganization" itemID="https://www.nytimes.com"> <meta itemProp="diversityPolicy" content="https://www.nytco.com/diversity-and-inclusion-at-the-new-york-times/"/><meta itemProp="ethicsPolicy" content="https://www.nytco.com/who-we-are/culture/standards-and-ethics/"/><meta itemProp="foundingDate" content="1851-09-18"/><span itemProp="logo" itemscope="" itemType="https://schema.org/ImageObject"><meta itemProp="url" content="https://static01.nyt.com/images/misc/NYT_logo_rss_250x40.png"/></span><meta itemProp="url" content="https://www.nytimes.com/"/><meta itemProp="masthead" content="https://www.nytimes.com/interactive/2018/09/28/admin/the-new-york-times-masthead.html"/><meta itemProp="name" content="The New York Times"/><span>The New York Times Company</span></span></a></li></ul><ul class="css-13o0c9t e5u916q2"><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://myaccount.nytimes.com/membercenter/feedback.html">Contact Us</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="http://www.nytco.com/careers">Work with us</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="http://nytmediakit.com/">Advertise</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="http://www.tbrandstudio.com/">T Brand Studio</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/content/help/rights/privacy/policy/privacy-policy.html#pp">Your Ad Choices</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/privacy">Privacy</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/ref/membercenter/help/agree.html">Terms of Service</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/content/help/rights/sale/terms-of-sale.html">Terms of Sale</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="http://spiderbites.nytimes.com">Site Map</a></li><li class="smartphone css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://mobile.nytimes.com/help">Help</a></li><li class="desktop css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/membercenter/sitehelp.html">Help</a></li><li class="css-1yo489b e5u916q3"><a data-testid="footer-link" class="css-1p8nkc0" href="https://www.nytimes.com/subscription/multiproduct/lp8HYKU?campaignId=37WXW">Subscriptions</a></li></ul></nav></footer></div></div></div></div>
+ <script>window.__preloadedData = {"initialState":{"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==":{"__typename":"Article","id":"QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==","compatibility":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.compatibility","typename":"CompatibilityFeatures"},"archiveProperties":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.archiveProperties","typename":"ArticleArchiveProperties"},"collections@filterEmpty":[{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzE5ZjY2OTk4LWY1NjItNWVjNi1iM2Y5LTI5OGYxYzc2ZGQ4NA==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzExZjcyYWI0LTdjZDAtNTQwYS05M2NjLWYzNWIzMmNkMDEzZA==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzU4ZWNlMGQwLTNjMzUtNWZhOS1iNTM1LTk0OTk3YTdjOGMwZg==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzkwODhjZmU2LTg1ZTMtNTJmYi05OTNlLTYyODk3MDJhMTFmZg==","typename":"LegacyCollection"}],"tone":"NEWS","section":{"type":"id","generated":false,"id":"Section:U2VjdGlvbjpueXQ6Ly9zZWN0aW9uLzM5NDgwMzc0LTY2ZDMtNTYwMy05Y2UxLTU4Y2ZhMTI5ODhlMg==","typename":"Section"},"subsection":null,"sprinkledBody":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody","typename":"DocumentBlock"},"url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F08\u002F01\u002Fnyregion\u002Fnypd-facial-recognition-children-teenagers.html","adTargetingParams({\"clientAdParams\":{\"edn\":\"us\",\"plat\":\"web\",\"prop\":\"nyt\"}})":[{"type":"id","generated":false,"id":"AdTargetingParam:als_test1565027040168","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:propnyt","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:platweb","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:ednus","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:brandsensitivefalse","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:per","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:orgpolicedepartmentnyc","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:geonewyorkcity","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:desjuveniledelinquency,facialrecognitionsoftware,privacy,surveillanceofcitizensbygovern,police,civilrightsandliberties","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:spon","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:authaliwatkins,josephgoldstein","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:col","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:collnewyork,usnews,technology,techandsociety","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:artlenmedium","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:ledemedsznone","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:gui","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:templatearticle","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:typart,oak","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:sectionnyregion","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:si_sectionnyregion","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:id100000006583622","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:trend","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:ptnt10,nt15,nt16,nt18,nt3,nt4,nt9","typename":"AdTargetingParam"},{"type":"id","generated":false,"id":"AdTargetingParam:gscatneg_mastercard,gs_law_misc,neg_chanel,gv_crime,neg_hearts,gs_tech,gs_law,gs_tech_computing,neg_ibmtest,gs_tech_phones,neg_samsung,gs_education","typename":"AdTargetingParam"}],"sourceId":"100000006583622","type":"article","wordCount":1357,"bylines":[{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0","typename":"Byline"}],"displayProperties":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.displayProperties","typename":"CreativeWorkDisplayProperties"},"typeOfMaterials":{"type":"json","json":["News"]},"collections":[{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzE5ZjY2OTk4LWY1NjItNWVjNi1iM2Y5LTI5OGYxYzc2ZGQ4NA==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzExZjcyYWI0LTdjZDAtNTQwYS05M2NjLWYzNWIzMmNkMDEzZA==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzU4ZWNlMGQwLTNjMzUtNWZhOS1iNTM1LTk0OTk3YTdjOGMwZg==","typename":"LegacyCollection"},{"type":"id","generated":false,"id":"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzkwODhjZmU2LTg1ZTMtNTJmYi05OTNlLTYyODk3MDJhMTFmZg==","typename":"LegacyCollection"}],"timesTags@filterEmpty":[{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.0","typename":"Organization"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.1","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.2","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.3","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.4","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.5","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.6","typename":"Subject"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.7","typename":"Location"}],"language":null,"desk":"Metro","kicker":"","headline":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.headline","typename":"CreativeWorkHeadline"},"commentStatus":"ACCEPT_AND_DISPLAY_COMMENTS","firstPublished":"2019-08-01T17:15:31.000Z","lastModified":"2019-08-02T17:26:37.071Z","originalDesk":"Metro","source":{"type":"id","generated":false,"id":"Organization:T3JnYW5pemF0aW9uOm55dDovL29yZ2FuaXphdGlvbi9jMjc5MTM4OC02YjE2LTVmZmQtYTExOS05NmVhY2IxOTg5YzE=","typename":"Organization"},"printInformation":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.printInformation","typename":"PrintInformation"},"sprinkled":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled","typename":"SprinkledContent"},"followChannels":[{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.0","typename":"ChannelMetadata"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.1","typename":"ChannelMetadata"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.2","typename":"ChannelMetadata"}],"dfpTaxonomyException":null,"advertisingProperties":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.advertisingProperties","typename":"CreativeWorkAdvertisingProperties"},"translations":[],"summary":"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.","lastMajorModification":"2019-08-02T09:30:23.000Z","uri":"nyt:\u002F\u002Farticle\u002F9da58246-2495-505f-9abd-b5fda8e67b56","eventId":"pubp:\u002F\u002Fevent\u002F47a657bafa8a476bb36832f90ee5ac6e","promotionalMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia","typename":"Image"},"newsStatus":"DEFAULT","episodeProperties":null,"column":null,"reviewItems":[],"shortUrl":"https:\u002F\u002Fnyti.ms\u002F2GEzuZ8","promotionalHeadline":"She Was Arrested at 14. Her Photo Went to a Facial Recognition Database.","promotionalSummary":"With little oversight, the police have been using the powerful surveillance technology on photos of children and teenagers.","reviewSummary":"","legacy":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.legacy","typename":"ArticleLegacyData"},"addendums":[],"related@filterEmpty":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.compatibility":{"isOak":true,"__typename":"CompatibilityFeatures","hasVideo":false,"hasOakConversionError":false,"isArtReview":false,"isBookReview":false,"isDiningReview":false,"isMovieReview":false,"isTheaterReview":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.archiveProperties":{"timesMachineUrl":"","lede@stripHtml":"","thumbnails":[],"__typename":"ArticleArchiveProperties"},"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzE5ZjY2OTk4LWY1NjItNWVjNi1iM2Y5LTI5OGYxYzc2ZGQ4NA==":{"id":"TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzE5ZjY2OTk4LWY1NjItNWVjNi1iM2Y5LTI5OGYxYzc2ZGQ4NA==","slug":"nyregion","__typename":"LegacyCollection","name":"New York","collectionType":"SECTION","uri":"nyt:\u002F\u002Flegacycollection\u002F19f66998-f562-5ec6-b3f9-298f1c76dd84","type":"legacycollection","header":"","showCollectionStories":false},"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzExZjcyYWI0LTdjZDAtNTQwYS05M2NjLWYzNWIzMmNkMDEzZA==":{"id":"TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzExZjcyYWI0LTdjZDAtNTQwYS05M2NjLWYzNWIzMmNkMDEzZA==","slug":"us","__typename":"LegacyCollection","name":"U.S. News","collectionType":"SECTION","uri":"nyt:\u002F\u002Flegacycollection\u002F11f72ab4-7cd0-540a-93cc-f35b32cd013d","type":"legacycollection","header":"","showCollectionStories":false},"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzU4ZWNlMGQwLTNjMzUtNWZhOS1iNTM1LTk0OTk3YTdjOGMwZg==":{"id":"TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzU4ZWNlMGQwLTNjMzUtNWZhOS1iNTM1LTk0OTk3YTdjOGMwZg==","slug":"technology","__typename":"LegacyCollection","name":"Technology","collectionType":"SECTION","uri":"nyt:\u002F\u002Flegacycollection\u002F58ece0d0-3c35-5fa9-b535-94997a7c8c0f","type":"legacycollection","header":"","showCollectionStories":false},"LegacyCollection:TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzkwODhjZmU2LTg1ZTMtNTJmYi05OTNlLTYyODk3MDJhMTFmZg==":{"id":"TGVnYWN5Q29sbGVjdGlvbjpueXQ6Ly9sZWdhY3ljb2xsZWN0aW9uLzkwODhjZmU2LTg1ZTMtNTJmYi05OTNlLTYyODk3MDJhMTFmZg==","slug":"experience-tech-and-society","__typename":"LegacyCollection","name":"Tech and Society","collectionType":"SPOTLIGHT","uri":"nyt:\u002F\u002Flegacycollection\u002F9088cfe6-85e3-52fb-993e-6289702a11ff","type":"legacycollection","header":"","showCollectionStories":false},"Section:U2VjdGlvbjpueXQ6Ly9zZWN0aW9uLzM5NDgwMzc0LTY2ZDMtNTYwMy05Y2UxLTU4Y2ZhMTI5ODhlMg==":{"id":"U2VjdGlvbjpueXQ6Ly9zZWN0aW9uLzM5NDgwMzc0LTY2ZDMtNTYwMy05Y2UxLTU4Y2ZhMTI5ODhlMg==","name":"nyregion","displayName":"New York","url":"\u002Fsection\u002Fnyregion","uri":"nyt:\u002F\u002Fsection\u002F39480374-66d3-5603-9ce1-58cfa12988e2","__typename":"Section"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0":{"__typename":"HeaderBasicBlock","label":null,"headline":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.headline","typename":"Heading1Block"},"summary":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.summary","typename":"SummaryBlock"},"ledeMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.ledeMedia","typename":"ImageBlock"},"byline":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline","typename":"BylineBlock"},"timestampBlock":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.timestampBlock","typename":"TimestampBlock"}},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.3":{"__typename":"Dropzone","index":0,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.5":{"__typename":"Dropzone","index":1,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.6":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.6.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.7":{"__typename":"Dropzone","index":2,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.8":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.8.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.9":{"__typename":"Dropzone","index":3,"bad":false,"adsMobile":true,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.11":{"__typename":"Dropzone","index":4,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.12":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.12.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.13":{"__typename":"Dropzone","index":5,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.3","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.4","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.5","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.6","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.15":{"__typename":"Dropzone","index":6,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16.content.1","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.17":{"__typename":"Dropzone","index":7,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.3","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.19":{"__typename":"Dropzone","index":8,"bad":false,"adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.3","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.21":{"__typename":"Dropzone","index":9,"bad":true,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.22":{"__typename":"ImageBlock","size":"MEDIUM","media":{"type":"id","generated":false,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm","typename":"Image"}},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.23":{"__typename":"Dropzone","index":10,"bad":true,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.24":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.24.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.25":{"__typename":"Dropzone","index":11,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.3","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.4","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.5","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.6","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.7","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.8","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.9","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.27":{"__typename":"Dropzone","index":12,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.29":{"__typename":"Dropzone","index":13,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.3","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.4","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.5","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.31":{"__typename":"Dropzone","index":14,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.32":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.32.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.33":{"__typename":"Dropzone","index":15,"bad":false,"adsMobile":true,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.34":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.34.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.35":{"__typename":"Dropzone","index":16,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.3","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.37":{"__typename":"Dropzone","index":17,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.39":{"__typename":"Dropzone","index":18,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.40":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.40.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.41":{"__typename":"Dropzone","index":19,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.42":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.42.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.43":{"__typename":"Dropzone","index":20,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.44":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.44.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.45":{"__typename":"Dropzone","index":21,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.46":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.46.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.47":{"__typename":"Dropzone","index":22,"bad":false,"adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.48":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.48.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.49":{"__typename":"Dropzone","index":23,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.3","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.51":{"__typename":"Dropzone","index":24,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.52":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.52.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.53":{"__typename":"Dropzone","index":25,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54.content.1","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.55":{"__typename":"Dropzone","index":26,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.56":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.56.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.57":{"__typename":"Dropzone","index":27,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.58":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.58.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.59":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.59.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.60":{"__typename":"Dropzone","index":28,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61.content.1","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.62":{"__typename":"Dropzone","index":29,"bad":false,"adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.63":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.63.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.64":{"__typename":"Dropzone","index":30,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.65":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.65.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.66":{"__typename":"Dropzone","index":31,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.67":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.67.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.68":{"__typename":"Dropzone","index":32,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.70":{"__typename":"Dropzone","index":33,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.2","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.3","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.4","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.5","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.6","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.72":{"__typename":"Dropzone","index":34,"bad":false,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.0","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.1","typename":"TextInline"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.2","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.74":{"__typename":"Dropzone","index":35,"bad":false,"adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.75":{"__typename":"ParagraphBlock","textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.75.content.0","typename":"TextInline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.76":{"__typename":"Dropzone","index":36,"bad":true,"adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77":{"__typename":"RelatedLinksBlock","displayStyle":"STANDARD","title":[],"description":[],"related@filterEmpty":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0","typename":"Article"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1","typename":"Article"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody":{"content@filterEmpty":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0","typename":"HeaderBasicBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.3","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.5","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.6","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.7","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.8","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.9","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.11","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.12","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.13","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.15","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.17","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.19","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.21","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.22","typename":"ImageBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.23","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.24","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.25","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.27","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.29","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.31","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.32","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.33","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.34","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.35","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.37","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.39","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.40","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.41","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.42","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.43","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.44","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.45","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.46","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.47","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.48","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.49","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.51","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.52","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.53","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.55","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.56","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.57","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.58","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.59","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.60","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.62","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.63","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.64","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.65","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.66","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.67","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.68","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.70","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.72","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.74","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.75","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.76","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77","typename":"RelatedLinksBlock"}],"__typename":"DocumentBlock","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.0","typename":"HeaderBasicBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.1","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.2","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.3","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.4","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.5","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.6","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.7","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.8","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.9","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.10","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.11","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.12","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.13","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.14","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.15","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.16","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.17","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.18","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.19","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.20","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.21","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.22","typename":"ImageBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.23","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.24","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.25","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.26","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.27","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.28","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.29","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.30","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.31","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.32","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.33","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.34","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.35","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.36","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.37","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.38","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.39","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.40","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.41","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.42","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.43","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.44","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.45","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.46","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.47","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.48","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.49","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.50","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.51","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.52","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.53","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.54","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.55","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.56","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.57","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.58","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.59","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.60","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.61","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.62","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.63","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.64","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.65","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.66","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.67","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.68","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.69","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.70","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.71","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.72","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.73","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.74","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.75","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.76","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.77","typename":"RelatedLinksBlock"}],"content@take({\"first\":10000})@filterEmpty":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.0","typename":"HeaderBasicBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.1","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.2","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.3","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.4","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.5","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.6","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.7","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.8","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.9","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.10","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.11","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.12","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.13","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.14","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.15","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.16","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.17","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.18","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.19","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.20","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.21","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.22","typename":"ImageBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.23","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.24","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.25","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.26","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.27","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.28","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.29","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.30","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.31","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.32","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.33","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.34","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.35","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.36","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.37","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.38","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.39","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.40","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.41","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.42","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.43","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.44","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.45","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.46","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.47","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.48","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.49","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.50","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.51","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.52","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.53","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.54","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.55","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.56","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.57","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.58","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.59","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.60","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.61","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.62","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.63","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.64","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.65","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.66","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.67","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.68","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.69","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.70","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.71","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.72","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.73","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.74","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.75","typename":"ParagraphBlock"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.76","typename":"Dropzone"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.77","typename":"RelatedLinksBlock"}]},"AdTargetingParam:als_test1565027040168":{"key":"als_test","value":"1565027040168","__typename":"AdTargetingParam"},"AdTargetingParam:propnyt":{"key":"prop","value":"nyt","__typename":"AdTargetingParam"},"AdTargetingParam:platweb":{"key":"plat","value":"web","__typename":"AdTargetingParam"},"AdTargetingParam:ednus":{"key":"edn","value":"us","__typename":"AdTargetingParam"},"AdTargetingParam:brandsensitivefalse":{"key":"brandsensitive","value":"false","__typename":"AdTargetingParam"},"AdTargetingParam:per":{"key":"per","value":"","__typename":"AdTargetingParam"},"AdTargetingParam:orgpolicedepartmentnyc":{"key":"org","value":"policedepartmentnyc","__typename":"AdTargetingParam"},"AdTargetingParam:geonewyorkcity":{"key":"geo","value":"newyorkcity","__typename":"AdTargetingParam"},"AdTargetingParam:desjuveniledelinquency,facialrecognitionsoftware,privacy,surveillanceofcitizensbygovern,police,civilrightsandliberties":{"key":"des","value":"juveniledelinquency,facialrecognitionsoftware,privacy,surveillanceofcitizensbygovern,police,civilrightsandliberties","__typename":"AdTargetingParam"},"AdTargetingParam:spon":{"key":"spon","value":"","__typename":"AdTargetingParam"},"AdTargetingParam:authaliwatkins,josephgoldstein":{"key":"auth","value":"aliwatkins,josephgoldstein","__typename":"AdTargetingParam"},"AdTargetingParam:col":{"key":"col","value":"","__typename":"AdTargetingParam"},"AdTargetingParam:collnewyork,usnews,technology,techandsociety":{"key":"coll","value":"newyork,usnews,technology,techandsociety","__typename":"AdTargetingParam"},"AdTargetingParam:artlenmedium":{"key":"artlen","value":"medium","__typename":"AdTargetingParam"},"AdTargetingParam:ledemedsznone":{"key":"ledemedsz","value":"none","__typename":"AdTargetingParam"},"AdTargetingParam:gui":{"key":"gui","value":"","__typename":"AdTargetingParam"},"AdTargetingParam:templatearticle":{"key":"template","value":"article","__typename":"AdTargetingParam"},"AdTargetingParam:typart,oak":{"key":"typ","value":"art,oak","__typename":"AdTargetingParam"},"AdTargetingParam:sectionnyregion":{"key":"section","value":"nyregion","__typename":"AdTargetingParam"},"AdTargetingParam:si_sectionnyregion":{"key":"si_section","value":"nyregion","__typename":"AdTargetingParam"},"AdTargetingParam:id100000006583622":{"key":"id","value":"100000006583622","__typename":"AdTargetingParam"},"AdTargetingParam:trend":{"key":"trend","value":"","__typename":"AdTargetingParam"},"AdTargetingParam:ptnt10,nt15,nt16,nt18,nt3,nt4,nt9":{"key":"pt","value":"nt10,nt15,nt16,nt18,nt3,nt4,nt9","__typename":"AdTargetingParam"},"AdTargetingParam:gscatneg_mastercard,gs_law_misc,neg_chanel,gv_crime,neg_hearts,gs_tech,gs_law,gs_tech_computing,neg_ibmtest,gs_tech_phones,neg_samsung,gs_education":{"key":"gscat","value":"neg_mastercard,gs_law_misc,neg_chanel,gv_crime,neg_hearts,gs_tech,gs_law,gs_tech_computing,neg_ibmtest,gs_tech_phones,neg_samsung,gs_education","__typename":"AdTargetingParam"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0":{"displayName":"Joseph Goldstein","__typename":"Person","url":"","contactDetails":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails","typename":"ContactDetails"},"legacyData":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.legacyData","typename":"PersonLegacyData"}},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1":{"displayName":"Ali Watkins","__typename":"Person","url":"","contactDetails":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails","typename":"ContactDetails"},"legacyData":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.legacyData","typename":"PersonLegacyData"}},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0":{"creators":[{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0","typename":"Person"},{"type":"id","generated":true,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1","typename":"Person"}],"__typename":"Byline","renderedRepresentation":"By Joseph Goldstein and Ali Watkins"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.displayProperties":{"fullBleedDisplayStyle":"","__typename":"CreativeWorkDisplayProperties","serveAsNyt4":false},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.0":{"__typename":"Organization","vernacular":"NYPD","isAdvertisingBrandSensitive":false,"displayName":"Police Department (NYC)"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.1":{"__typename":"Subject","vernacular":"Juvenile delinquency","isAdvertisingBrandSensitive":false,"displayName":"Juvenile Delinquency"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.2":{"__typename":"Subject","vernacular":"Facial Recognition","isAdvertisingBrandSensitive":false,"displayName":"Facial Recognition Software"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.3":{"__typename":"Subject","vernacular":"Privacy","isAdvertisingBrandSensitive":false,"displayName":"Privacy"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.4":{"__typename":"Subject","vernacular":"Government Surveillance","isAdvertisingBrandSensitive":false,"displayName":"Surveillance of Citizens by Government"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.5":{"__typename":"Subject","vernacular":"Police","isAdvertisingBrandSensitive":false,"displayName":"Police"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.6":{"__typename":"Subject","vernacular":"Civil Rights","isAdvertisingBrandSensitive":false,"displayName":"Civil Rights and Liberties"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.timesTags@filterEmpty.7":{"__typename":"Location","vernacular":"NYC","isAdvertisingBrandSensitive":false,"displayName":"New York City"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.headline":{"default":"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.","__typename":"CreativeWorkHeadline","default@stripHtml":"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.","seo@stripHtml":""},"Organization:T3JnYW5pemF0aW9uOm55dDovL29yZ2FuaXphdGlvbi9jMjc5MTM4OC02YjE2LTVmZmQtYTExOS05NmVhY2IxOTg5YzE=":{"id":"T3JnYW5pemF0aW9uOm55dDovL29yZ2FuaXphdGlvbi9jMjc5MTM4OC02YjE2LTVmZmQtYTExOS05NmVhY2IxOTg5YzE=","displayName":"New York Times","__typename":"Organization"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.printInformation":{"page":"1","section":"A","publicationDate":"2019-08-02T04:00:00.000Z","__typename":"PrintInformation","edition":"NewYork","headline@stripHtml":"In New York, Police Computers Scan Faces, Some as Young as 11"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.0":{"name":"mobile","stride":4,"threshold":3,"__typename":"SprinkledConfig"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.1":{"name":"desktop","stride":7,"threshold":3,"__typename":"SprinkledConfig"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.2":{"name":"mobileHoldout","stride":6,"threshold":3,"__typename":"SprinkledConfig"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.3":{"name":"desktopHoldout","stride":8,"threshold":3,"__typename":"SprinkledConfig"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.4":{"name":"hybrid","stride":4,"threshold":3,"__typename":"SprinkledConfig"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled":{"configs":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.0","typename":"SprinkledConfig"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.1","typename":"SprinkledConfig"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.2","typename":"SprinkledConfig"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.3","typename":"SprinkledConfig"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkled.configs.4","typename":"SprinkledConfig"}],"__typename":"SprinkledContent"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.0":{"__typename":"HeaderBasicBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.1":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.2":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.3":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.4":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.5":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.6":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.7":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.8":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.9":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.10":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.11":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.12":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.13":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.14":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.15":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.16":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.17":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.18":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.19":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.20":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.21":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.22":{"__typename":"ImageBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.23":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.24":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.25":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.26":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.27":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.28":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.29":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.30":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.31":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.32":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.33":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.34":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.35":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.36":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.37":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.38":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.39":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.40":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.41":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.42":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.43":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.44":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.45":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.46":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.47":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.48":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.49":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.50":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.51":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.52":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.53":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.54":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.55":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.56":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.57":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.58":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.59":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.60":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.61":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.62":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":true},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.63":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.64":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.65":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.66":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.67":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.68":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.69":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.70":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.71":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.72":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.73":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.74":{"__typename":"Dropzone","adsMobile":true,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.75":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.76":{"__typename":"Dropzone","adsMobile":false,"adsDesktop":false},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content.77":{"__typename":"RelatedLinksBlock"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.0":{"uri":"nyt:\u002F\u002Fchannel\u002Fdd1a5725-c3be-4673-be2c-9055eb12c10f","__typename":"ChannelMetadata"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.1":{"uri":"nyt:\u002F\u002Fchannel\u002F7cf18b43-f1c6-4946-8a9c-4e24bad34c5c","__typename":"ChannelMetadata"},"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.followChannels.2":{"uri":"nyt:\u002F\u002Fchannel\u002F679a17bb-20e6-40a7-a589-e7742a2a52ed","__typename":"ChannelMetadata"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.0":{"__typename":"HeaderBasicBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.1":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.2":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.3":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.4":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.5":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.6":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.7":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.8":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.9":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.10":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.11":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.12":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.13":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.14":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.15":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.16":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.17":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.18":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.19":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.20":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.21":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.22":{"__typename":"ImageBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.23":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.24":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.25":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.26":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.27":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.28":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.29":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.30":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.31":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.32":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.33":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.34":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.35":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.36":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.37":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.38":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.39":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.40":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.41":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.42":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.43":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.44":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.45":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.46":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.47":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.48":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.49":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.50":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.51":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.52":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.53":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.54":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.55":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.56":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.57":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.58":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.59":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.60":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.61":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.62":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.63":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.64":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.65":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.66":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.67":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.68":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.69":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.70":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.71":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.72":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.73":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.74":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.75":{"__typename":"ParagraphBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.76":{"__typename":"Dropzone"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@take({\"first\":10000})@filterEmpty.77":{"__typename":"RelatedLinksBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.advertisingProperties":{"sensitivity":"SHOW_ADS","__typename":"CreativeWorkAdvertisingProperties"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).0":{"name":"MASTER","renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-articleLarge.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-superJumbo.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-articleLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-articleLarge.jpg","height":400,"width":600,"name":"articleLarge","__typename":"ImageRendition"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-superJumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-superJumbo.jpg","height":1365,"width":2048,"name":"superJumbo","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).1":{"name":"SMALL_SQUARE","renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-thumbStandard.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-thumbLarge.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-thumbStandard.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-thumbStandard.jpg","height":75,"width":75,"name":"thumbStandard","__typename":"ImageRendition"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-thumbLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-thumbLarge.jpg","height":150,"width":150,"name":"thumbLarge","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).2":{"name":"SIXTEEN_BY_NINE","renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg","height":900,"width":1600,"name":"videoSixteenByNineJumbo1600","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).3":{"name":"FACEBOOK","renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-facebookJumbo.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-facebookJumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-facebookJumbo.jpg","height":550,"width":1050,"name":"facebookJumbo","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).4":{"name":"WATCH","renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-watch308.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190801nyregion01nypd-juveniles-promo01nypd-juveniles-promo-watch308.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F01nypd-juveniles-promo\u002F01nypd-juveniles-promo-watch308.jpg","height":348,"width":312,"name":"watch308","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia":{"crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]})":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).0","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).1","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).2","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).3","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.crops({\"renditionNames\":[\"thumbStandard\",\"thumbLarge\",\"watch308\",\"facebookJumbo\",\"videoSixteenByNineJumbo1600\",\"articleLarge\",\"superJumbo\"]}).4","typename":"ImageCrop"}],"caption":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.caption","typename":"TextOnlyDocumentBlock"},"__typename":"Image"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.promotionalMedia.caption":{"text":"","__typename":"TextOnlyDocumentBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.headline":{"textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.headline.content.0","typename":"TextInline"}],"__typename":"Heading1Block"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.headline.content.0":{"__typename":"TextInline","text@stripHtml":"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.summary":{"textAlign":"LEFT","content":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.summary.content.0","typename":"TextInline"}],"__typename":"SummaryBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.summary.content.0":{"__typename":"TextInline","text":"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.ledeMedia":{"__typename":"ImageBlock","size":"MEDIUM","media":{"type":"id","generated":false,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz","typename":"Image"}},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz":{"id":"SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz","imageType":"photo","url":"\u002Fimagepages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F00nypd-juveniles.html","uri":"nyt:\u002F\u002Fimage\u002F2ad4fe36-f59f-5211-a12e-6b1f5bce2fa3","credit":"Sarah Blesener for The New York Times","legacyHtmlCaption":"“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, who pleaded guilty to an assault that occurred when she was 14.","crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]})":[{"type":"id","generated":true,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).0","typename":"ImageCrop"},{"type":"id","generated":true,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).1","typename":"ImageCrop"}],"caption":{"type":"id","generated":true,"id":"$Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.caption","typename":"TextOnlyDocumentBlock"},"__typename":"Image"},"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F07\u002F30\u002Fnyregion\u002F00nypd-juveniles\u002Fmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg","name":"articleLarge","width":600,"height":900,"__typename":"ImageRendition"},"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-popup.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F07\u002F30\u002Fnyregion\u002F00nypd-juveniles\u002Fmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-popup.jpg","name":"popup","width":334,"height":500,"__typename":"ImageRendition"},"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-jumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F07\u002F30\u002Fnyregion\u002F00nypd-juveniles\u002Fmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-jumbo.jpg","name":"jumbo","width":683,"height":1024,"__typename":"ImageRendition"},"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-superJumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F07\u002F30\u002Fnyregion\u002F00nypd-juveniles\u002Fmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-superJumbo.jpg","name":"superJumbo","width":1366,"height":2048,"__typename":"ImageRendition"},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleLarge.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-popup.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-jumbo.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-superJumbo.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleInline.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F07\u002F30\u002Fnyregion\u002F00nypd-juveniles\u002Fmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleInline.jpg","name":"articleInline","width":190,"height":285,"__typename":"ImageRendition"},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).1":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190730nyregion00nypd-juvenilesmerlin_157762122_e2d17c00-b58b-4baf-b9cb-4326ac7cbcef-articleInline.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvMmFkNGZlMzYtZjU5Zi01MjExLWExMmUtNmIxZjViY2UyZmEz.caption":{"text":"“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, who pleaded guilty to an assault that occurred when she was 14.","__typename":"TextOnlyDocumentBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline":{"textAlign":"LEFT","hideHeadshots":false,"bylines":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0","typename":"Byline"}],"role@filterEmpty":[],"__typename":"BylineBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0":{"prefix":"By","creators":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0","typename":"Person"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1","typename":"Person"}],"renderedRepresentation":null,"__typename":"Byline"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0":{"displayName":"Joseph Goldstein","bioUrl":"https:\u002F\u002Fwww.nytimes.com\u002Fby\u002Fjoseph-goldstein","promotionalMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia","typename":"Image"},"__typename":"Person"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-articleLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-articleLarge.png","name":"articleLarge","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-popup.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-popup.png","name":"popup","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog480.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blog480.png","name":"blog480","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog533.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blog533.png","name":"blog533","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog427.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blog427.png","name":"blog427","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-tmagSF.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-tmagSF.png","name":"tmagSF","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-tmagArticle.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-tmagArticle.png","name":"tmagArticle","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-slide.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-slide.png","name":"slide","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-jumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-jumbo.png","name":"jumbo","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-superJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-superJumbo.png","name":"superJumbo","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blog225.png","name":"blog225","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master675.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-master675.png","name":"master675","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master495.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-master495.png","name":"master495","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master180.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-master180.png","name":"master180","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master315.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-master315.png","name":"master315","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master768.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-master768.png","name":"master768","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-articleLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-popup.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog480.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog533.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog427.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-tmagSF.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-tmagArticle.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-slide.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-jumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-superJumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blog225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master675.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master495.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master180.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master315.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-master768.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbStandard.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-thumbStandard.png","name":"thumbStandard","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blogSmallThumb.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blogSmallThumb.png","name":"blogSmallThumb","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-thumbLarge.png","name":"thumbLarge","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-smallSquare168.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-smallSquare168.png","name":"smallSquare168","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-smallSquare252.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-smallSquare252.png","name":"smallSquare252","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.1":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbStandard.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blogSmallThumb.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-smallSquare168.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-smallSquare252.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-square320.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-square320.png","name":"square320","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-moth.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-moth.png","name":"moth","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-filmstrip.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-filmstrip.png","name":"filmstrip","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-square640.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-square640.png","name":"square640","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumSquare149.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumSquare149.png","name":"mediumSquare149","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.2":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-square320.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-moth.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-filmstrip.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-square640.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumSquare149.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-sfSpan.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-sfSpan.png","name":"sfSpan","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeHorizontal375.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-largeHorizontal375.png","name":"largeHorizontal375","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeHorizontalJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-largeHorizontalJumbo.png","name":"largeHorizontalJumbo","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-horizontalMediumAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-horizontalMediumAt2X.png","name":"horizontalMediumAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.3":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-sfSpan.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeHorizontal375.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeHorizontalJumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-horizontalMediumAt2X.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-hpLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-hpLarge.png","name":"hpLarge","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeWidescreen573.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-largeWidescreen573.png","name":"largeWidescreen573","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.4":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-hpLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-largeWidescreen573.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbWide.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-thumbWide.png","name":"thumbWide","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoThumb.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoThumb.png","name":"videoThumb","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoLarge.png","name":"videoLarge","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo210.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumThreeByTwo210.png","name":"mediumThreeByTwo210","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumThreeByTwo225.png","name":"mediumThreeByTwo225","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo440.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumThreeByTwo440.png","name":"mediumThreeByTwo440","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo252.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumThreeByTwo252.png","name":"mediumThreeByTwo252","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo378.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumThreeByTwo378.png","name":"mediumThreeByTwo378","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoLargeAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-threeByTwoLargeAt2X.png","name":"threeByTwoLargeAt2X","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoMediumAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-threeByTwoMediumAt2X.png","name":"threeByTwoMediumAt2X","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoSmallAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-threeByTwoSmallAt2X.png","name":"threeByTwoSmallAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.5":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-thumbWide.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoThumb.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo210.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo440.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo252.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumThreeByTwo378.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoLargeAt2X.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoMediumAt2X.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-threeByTwoSmallAt2X.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-articleInline.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-articleInline.png","name":"articleInline","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-hpSmall.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-hpSmall.png","name":"hpSmall","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blogSmallInline.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-blogSmallInline.png","name":"blogSmallInline","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumFlexible177.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-mediumFlexible177.png","name":"mediumFlexible177","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.6":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-articleInline.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-hpSmall.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-blogSmallInline.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-mediumFlexible177.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSmall.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSmall.png","name":"videoSmall","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoHpMedium.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoHpMedium.png","name":"videoHpMedium","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine600.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine600.png","name":"videoSixteenByNine600","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine540.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine540.png","name":"videoSixteenByNine540","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine495.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine495.png","name":"videoSixteenByNine495","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine390.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine390.png","name":"videoSixteenByNine390","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine480.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine480.png","name":"videoSixteenByNine480","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine310.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine310.png","name":"videoSixteenByNine310","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine225.png","name":"videoSixteenByNine225","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine96.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine96.png","name":"videoSixteenByNine96","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine768.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine768.png","name":"videoSixteenByNine768","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine150.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNine150.png","name":"videoSixteenByNine150","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNineJumbo1600.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-videoSixteenByNineJumbo1600.png","name":"videoSixteenByNineJumbo1600","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.7":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSmall.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoHpMedium.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine600.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine540.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine495.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine390.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine480.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine310.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine96.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine768.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNine150.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-videoSixteenByNineJumbo1600.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-miniMoth.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-miniMoth.png","name":"miniMoth","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-windowsTile336H.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-windowsTile336H.png","name":"windowsTile336H","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.8":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-miniMoth.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-windowsTile336H.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.9":{"renditions":[],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-facebookJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-facebookJumbo.png","name":"facebookJumbo","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.10":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-facebookJumbo.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-watch308.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-watch308.png","name":"watch308","__typename":"ImageRendition"},"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-watch268.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2018\u002F07\u002F16\u002Fmultimedia\u002Fauthor-joseph-goldstein\u002Fauthor-joseph-goldstein-watch268.png","name":"watch268","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.11":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-watch308.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20180716multimediaauthor-joseph-goldsteinauthor-joseph-goldstein-watch268.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.12":{"renditions":[],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia":{"crops":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.0","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.1","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.2","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.3","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.4","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.5","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.6","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.7","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.8","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.9","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.10","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.11","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.0.promotionalMedia.crops.12","typename":"ImageCrop"}],"__typename":"Image"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1":{"displayName":"Ali Watkins","bioUrl":"https:\u002F\u002Fwww.nytimes.com\u002Fby\u002Fali-watkins","promotionalMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia","typename":"Image"},"__typename":"Person"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-articleLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-articleLarge.png","name":"articleLarge","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-popup.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-popup.png","name":"popup","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog480.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blog480.png","name":"blog480","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog533.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blog533.png","name":"blog533","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog427.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blog427.png","name":"blog427","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-tmagSF.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-tmagSF.png","name":"tmagSF","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-tmagArticle.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-tmagArticle.png","name":"tmagArticle","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-slide.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-slide.png","name":"slide","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-jumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-jumbo.png","name":"jumbo","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-superJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-superJumbo.png","name":"superJumbo","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blog225.png","name":"blog225","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master675.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-master675.png","name":"master675","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master495.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-master495.png","name":"master495","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master180.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-master180.png","name":"master180","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master315.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-master315.png","name":"master315","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master768.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-master768.png","name":"master768","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-articleLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-popup.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog480.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog533.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog427.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-tmagSF.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-tmagArticle.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-slide.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-jumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-superJumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blog225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master675.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master495.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master180.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master315.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-master768.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbStandard.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-thumbStandard.png","name":"thumbStandard","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blogSmallThumb.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blogSmallThumb.png","name":"blogSmallThumb","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-thumbLarge.png","name":"thumbLarge","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-smallSquare168.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-smallSquare168.png","name":"smallSquare168","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-smallSquare252.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-smallSquare252.png","name":"smallSquare252","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.1":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbStandard.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blogSmallThumb.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-smallSquare168.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-smallSquare252.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-square320.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-square320.png","name":"square320","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-moth.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-moth.png","name":"moth","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-filmstrip.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-filmstrip.png","name":"filmstrip","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-square640.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-square640.png","name":"square640","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumSquare149.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumSquare149.png","name":"mediumSquare149","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.2":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-square320.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-moth.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-filmstrip.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-square640.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumSquare149.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-sfSpan.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-sfSpan.png","name":"sfSpan","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeHorizontal375.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-largeHorizontal375.png","name":"largeHorizontal375","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeHorizontalJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-largeHorizontalJumbo.png","name":"largeHorizontalJumbo","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-horizontalMediumAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-horizontalMediumAt2X.png","name":"horizontalMediumAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.3":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-sfSpan.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeHorizontal375.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeHorizontalJumbo.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-horizontalMediumAt2X.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-hpLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-hpLarge.png","name":"hpLarge","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeWidescreen573.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-largeWidescreen573.png","name":"largeWidescreen573","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeWidescreen1050.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-largeWidescreen1050.png","name":"largeWidescreen1050","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.4":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-hpLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeWidescreen573.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-largeWidescreen1050.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbWide.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-thumbWide.png","name":"thumbWide","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoThumb.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoThumb.png","name":"videoThumb","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoLarge.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoLarge.png","name":"videoLarge","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo210.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumThreeByTwo210.png","name":"mediumThreeByTwo210","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumThreeByTwo225.png","name":"mediumThreeByTwo225","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo440.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumThreeByTwo440.png","name":"mediumThreeByTwo440","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo252.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumThreeByTwo252.png","name":"mediumThreeByTwo252","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo378.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumThreeByTwo378.png","name":"mediumThreeByTwo378","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoLargeAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-threeByTwoLargeAt2X.png","name":"threeByTwoLargeAt2X","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoMediumAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-threeByTwoMediumAt2X.png","name":"threeByTwoMediumAt2X","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoSmallAt2X.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-threeByTwoSmallAt2X.png","name":"threeByTwoSmallAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.5":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-thumbWide.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoThumb.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoLarge.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo210.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo440.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo252.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumThreeByTwo378.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoLargeAt2X.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoMediumAt2X.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-threeByTwoSmallAt2X.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-articleInline.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-articleInline.png","name":"articleInline","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-hpSmall.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-hpSmall.png","name":"hpSmall","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blogSmallInline.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-blogSmallInline.png","name":"blogSmallInline","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumFlexible177.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-mediumFlexible177.png","name":"mediumFlexible177","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.6":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-articleInline.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-hpSmall.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-blogSmallInline.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-mediumFlexible177.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSmall.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSmall.png","name":"videoSmall","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoHpMedium.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoHpMedium.png","name":"videoHpMedium","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine600.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine600.png","name":"videoSixteenByNine600","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine540.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine540.png","name":"videoSixteenByNine540","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine495.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine495.png","name":"videoSixteenByNine495","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine390.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine390.png","name":"videoSixteenByNine390","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine1050.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine1050.png","name":"videoSixteenByNine1050","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine480.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine480.png","name":"videoSixteenByNine480","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine310.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine310.png","name":"videoSixteenByNine310","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine225.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine225.png","name":"videoSixteenByNine225","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine96.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine96.png","name":"videoSixteenByNine96","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine768.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine768.png","name":"videoSixteenByNine768","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine150.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNine150.png","name":"videoSixteenByNine150","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNineJumbo1600.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoSixteenByNineJumbo1600.png","name":"videoSixteenByNineJumbo1600","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.7":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSmall.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoHpMedium.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine600.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine540.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine495.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine390.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine1050.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine480.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine310.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine225.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine96.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine768.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNine150.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoSixteenByNineJumbo1600.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-miniMoth.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-miniMoth.png","name":"miniMoth","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-windowsTile336H.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-windowsTile336H.png","name":"windowsTile336H","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoFifteenBySeven1305.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-videoFifteenBySeven1305.png","name":"videoFifteenBySeven1305","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.8":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-miniMoth.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-windowsTile336H.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-videoFifteenBySeven1305.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.9":{"renditions":[],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-facebookJumbo.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-facebookJumbo.png","name":"facebookJumbo","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.10":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-facebookJumbo.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-watch308.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-watch308.png","name":"watch308","__typename":"ImageRendition"},"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-watch268.png":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F02\u002F20\u002Fmultimedia\u002Fauthor-ali-watkins\u002Fauthor-ali-watkins-watch268.png","name":"watch268","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.11":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-watch308.png","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190220multimediaauthor-ali-watkinsauthor-ali-watkins-watch268.png","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.12":{"renditions":[],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia":{"crops":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.0","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.1","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.2","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.3","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.4","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.5","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.6","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.7","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.8","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.9","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.10","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.11","typename":"ImageCrop"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.byline.bylines.0.creators.1.promotionalMedia.crops.12","typename":"ImageCrop"}],"__typename":"Image"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.0.timestampBlock":{"timestamp":"2019-08-01T17:15:31.000Z","align":"LEFT","__typename":"TimestampBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.0":{"__typename":"TextInline","text":"[What you need to know to start the day: ","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.0.formats.0","typename":"ItalicFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.0.formats.0":{"__typename":"ItalicFormat","type":null},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1":{"__typename":"TextInline","text":"Get New York Today in your inbox","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1.formats.0","typename":"ItalicFormat"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1.formats.1","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1.formats.0":{"__typename":"ItalicFormat","type":null},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.1.formats.1":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.nytimes.com\u002Fnewsletters\u002Fnewyorktoday?module=inline","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.2":{"__typename":"TextInline","text":".]","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.2.formats.0","typename":"ItalicFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.1.content.2.formats.0":{"__typename":"ItalicFormat","type":null},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.0":{"__typename":"TextInline","text":"The New York Police Department has been loading ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.1":{"__typename":"TextInline","text":"thousands of arrest photos of children and teenagers","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.2.content.2":{"__typename":"TextInline","text":" into a facial recognition database despite evidence the technology has a higher risk of false matches in younger faces. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.0":{"__typename":"TextInline","text":"For about four years, internal records show, the department has used the technology to compare crime scene images with its collection of juvenile mug ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.1":{"__typename":"TextInline","text":"shots","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.4.content.2":{"__typename":"TextInline","text":", the photos that are taken at an arrest. Most of the photos are of teenagers, largely 13 to 16 years old, but children as young as 11 have been included. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.6.content.0":{"__typename":"TextInline","text":"Elected officials and civil rights groups said the disclosure that the city was deploying a powerful surveillance tool on adolescents — whose privacy seems sacrosanct and whose status is protected in the criminal justice system — was a striking example of the Police Department’s ability to adopt advancing technology with little public scrutiny.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.8.content.0":{"__typename":"TextInline","text":"Several members of the City Council as well as a range of civil liberties groups said they were unaware of the policy until they were contacted by The New York Times. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.0":{"__typename":"TextInline","text":"Police ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.1":{"__typename":"TextInline","text":"Department officials defended the decision, ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.10.content.2":{"__typename":"TextInline","text":"saying it was just the latest evolution of a longstanding policing technique: using arrest photos to identify suspects.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.12.content.0":{"__typename":"TextInline","text":"“I don’t think this is any secret decision that’s made behind closed doors,” the city’s chief of detectives, Dermot F. Shea, said in an interview. “This is just process, and making sure we’re doing everything to fight crime.” ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.0":{"__typename":"TextInline","text":"Other cities have begun to debate whether law enforcement should use facial recognition, which relies on an algorithm to quickly pore through images and suggest matches. In May, ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.1":{"__typename":"TextInline","text":"San Francisco blocked city agencies, including the police","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.1.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.1.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F05\u002F14\u002Fus\u002Ffacial-recognition-ban-san-francisco.html?module=inline","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.2":{"__typename":"TextInline","text":", from using the tool amid unease about potential government ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.3":{"__typename":"TextInline","text":"abuse","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.4":{"__typename":"TextInline","text":". ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.5":{"__typename":"TextInline","text":"Detroit is facing public resistance to a technology ","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.5.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.5.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F07\u002F08\u002Fus\u002Fdetroit-facial-recognition-cameras.html","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.14.content.6":{"__typename":"TextInline","text":"that has been shown to have lower accuracy with people with darker skin. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16.content.0":{"__typename":"TextInline","text":"In New York, the state Education Department recently told the Lockport, N.Y., ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.16.content.1":{"__typename":"TextInline","text":"school district to delay a plan to use facial recognition on students, citing privacy concerns. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.0":{"__typename":"TextInline","text":"“At the end ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.1":{"__typename":"TextInline","text":"of the day, it should be banned — no young people,” said ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.2":{"__typename":"TextInline","text":"Councilman Donovan Richards","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.18.content.3":{"__typename":"TextInline","text":", a Queens Democrat who heads the Public Safety Committee, which oversees the Police Department. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.0":{"__typename":"TextInline","text":"The department said its legal bureau had approved using facial recognition on juveniles. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.1":{"__typename":"TextInline","text":"The algorithm may suggest a lead, but detectives would not make an arrest based solely on ","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.1.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.1.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F06\u002F09\u002Fopinion\u002Ffacial-recognition-police-new-york-city.html","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.2":{"__typename":"TextInline","text":"that","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.2.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.2.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F06\u002F09\u002Fopinion\u002Ffacial-recognition-police-new-york-city.html","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.20.content.3":{"__typename":"TextInline","text":", Chief Shea said.","formats":[]},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm":{"id":"SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm","imageType":"photo","url":"\u002Fimagepages\u002F2019\u002F08\u002F01\u002Fnyregion\u002F00nypd-juveniles2.html","uri":"nyt:\u002F\u002Fimage\u002F5a694c3e-2066-51a1-965d-4be8779badef","credit":"Chang W. Lee\u002FThe New York Times","legacyHtmlCaption":"Dermot Shea, the city&rsquo;s chief of detectives, said investigators would not arrest anyone based solely on a facial recognition match.","crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]})":[{"type":"id","generated":true,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).0","typename":"ImageCrop"},{"type":"id","generated":true,"id":"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).1","typename":"ImageCrop"}],"caption":{"type":"id","generated":true,"id":"$Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.caption","typename":"TextOnlyDocumentBlock"},"__typename":"Image"},"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F02\u002Fnyregion\u002F02nypdjuveniles1-print\u002Fmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg","name":"articleLarge","width":600,"height":400,"__typename":"ImageRendition"},"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-popup.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F02\u002Fnyregion\u002F02nypdjuveniles1-print\u002Fmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-popup.jpg","name":"popup","width":650,"height":433,"__typename":"ImageRendition"},"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-jumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F02\u002Fnyregion\u002F02nypdjuveniles1-print\u002Fmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-jumbo.jpg","name":"jumbo","width":1024,"height":683,"__typename":"ImageRendition"},"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-superJumbo.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F02\u002Fnyregion\u002F02nypdjuveniles1-print\u002Fmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-superJumbo.jpg","name":"superJumbo","width":2048,"height":1365,"__typename":"ImageRendition"},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleLarge.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-popup.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-jumbo.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-superJumbo.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleInline.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F08\u002F02\u002Fnyregion\u002F02nypdjuveniles1-print\u002Fmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleInline.jpg","name":"articleInline","width":190,"height":127,"__typename":"ImageRendition"},"Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.crops({\"renditionNames\":[\"articleLarge\",\"jumbo\",\"superJumbo\",\"articleInline\",\"popup\"]}).1":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190802nyregion02nypdjuveniles1-printmerlin_152191512_83c5140a-fad8-400e-b645-a28f31fd7d26-articleInline.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Image:SW1hZ2U6bnl0Oi8vaW1hZ2UvNWE2OTRjM2UtMjA2Ni01MWExLTk2NWQtNGJlODc3OWJhZGVm.caption":{"text":"Dermot Shea, the city&rsquo;s chief of detectives, said investigators would not arrest anyone based solely on a facial recognition match.","__typename":"TextOnlyDocumentBlock"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.24.content.0":{"__typename":"TextInline","text":"Still, facial recognition has not been widely tested on children. Most algorithms are taught to “think” based on adult faces, and there is growing evidence that they do not work as well on children. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.0":{"__typename":"TextInline","text":"The National Institute of Standards and Technology","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.1":{"__typename":"TextInline","text":", which is part of the Commerce Department and ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.2":{"__typename":"TextInline","text":"evaluates facial recognition ","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.2.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.2.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fnvlpubs.nist.gov\u002Fnistpubs\u002Fir\u002F2018\u002FNIST.IR.8238.pdf","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.3":{"__typename":"TextInline","text":"algorithms","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.3.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.3.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fnvlpubs.nist.gov\u002Fnistpubs\u002Fir\u002F2018\u002FNIST.IR.8238.pdf","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.4":{"__typename":"TextInline","text":" for accuracy, recently found the ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.5":{"__typename":"TextInline","text":"vast majority of more than 100 ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.6":{"__typename":"TextInline","text":"facial recognition ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.7":{"__typename":"TextInline","text":"algorithms had a higher rate of mistaken matches among children. The ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.8":{"__typename":"TextInline","text":"e","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.26.content.9":{"__typename":"TextInline","text":"rror rate was most pronounced in young children but was also seen in those aged 10 to 16.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.0":{"__typename":"TextInline","text":"Aging poses another problem:","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.1":{"__typename":"TextInline","text":" The appearance of children and adolescents can change ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.28.content.2":{"__typename":"TextInline","text":" drastically as bones stretch and shift, altering the underlying facial structure. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.0":{"__typename":"TextInline","text":"“I would use extreme caution in using those algorithms,” said ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.1":{"__typename":"TextInline","text":"Karl Ricanek Jr.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.2":{"__typename":"TextInline","text":", a computer science professor and ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.3":{"__typename":"TextInline","text":"co-founder of the Face Aging Group at the University of North Carolina-","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.4":{"__typename":"TextInline","text":"Wilmington","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.30.content.5":{"__typename":"TextInline","text":". ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.32.content.0":{"__typename":"TextInline","text":"Technology that can match an image of a younger teenager to a recent arrest photo may be less effective at finding the same person even one or two years later, he said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.34.content.0":{"__typename":"TextInline","text":" “The systems do not have the capacity to understand the dynamic changes that occur to a child’s face,” Dr. Ricanek said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.0":{"__typename":"TextInline","text":"Idemia","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.1":{"__typename":"TextInline","text":" and ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.2":{"__typename":"TextInline","text":"DataWorks Plus","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.36.content.3":{"__typename":"TextInline","text":", the two companies that provide facial recognition software to the Police Department, did not respond to requests for comment. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.0":{"__typename":"TextInline","text":"The New York Police Department can take arrest photos of minors as young as ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.1":{"__typename":"TextInline","text":"11","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.38.content.2":{"__typename":"TextInline","text":" who are charged with a felony, depending on the severity of the charge. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.40.content.0":{"__typename":"TextInline","text":"And in many cases, the department keeps the photos for years, making facial recognition comparisons to what may have effectively become outdated images. There are photos of 5,500 individuals in the juvenile database, 4,100 of whom are no longer 16 or under, the department said. Teenagers 17 and older are considered adults in the criminal justice system. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.42.content.0":{"__typename":"TextInline","text":"Police officials declined to provide statistics on how often their facial recognition systems provide false matches, or to explain how they evaluate the system’s effectiveness.","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.44.content.0":{"__typename":"TextInline","text":"“We are comfortable with this technology because it has proved to be a valuable investigative method,” Chief Shea said. Facial recognition has helped lead to thousands of arrests of both adults and juveniles, the department has said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.46.content.0":{"__typename":"TextInline","text":"Mayor Bill de Blasio had been aware the department was using the technology on minors, said Freddi Goldstein, a spokeswoman for the mayor. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.48.content.0":{"__typename":"TextInline","text":"She said the Police Department followed “strict guidelines” in applying the technology and City Hall monitored the agency’s compliance with the policies. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.0":{"__typename":"TextInline","text":"The Times learned details of the department’s use of facial recognition by reviewing documents posted online earlier this year by ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.1":{"__typename":"TextInline","text":"Clare Garvie, a senior associate","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.1.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.1.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.flawedfacedata.com\u002F#footnote5","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.2":{"__typename":"TextInline","text":" at the Center on Privacy and Technology at Georgetown Law","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.2.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.2.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.flawedfacedata.com\u002F#footnote5","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.50.content.3":{"__typename":"TextInline","text":". Ms. Garvie received the documents as part of an open records lawsuit. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.52.content.0":{"__typename":"TextInline","text":"It could not be determined whether other large police departments used facial recognition with juveniles because very few have written policies governing the use of the technology, Ms. Garvie said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54.content.0":{"__typename":"TextInline","text":"New York detectives rely on a vast network","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.54.content.1":{"__typename":"TextInline","text":" of surveillance cameras throughout the city to provide images of people believed to have committed a crime. Since 2011, the department has had a dedicated unit of officers who use facial recognition to compare those images against millions of photos, usually mug shots. The software proposes matches, which have led to thousands of arrests, the department said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.56.content.0":{"__typename":"TextInline","text":"By 2013, top police officials were meeting to discuss including juveniles in the program, the documents reviewed by The Times showed. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.58.content.0":{"__typename":"TextInline","text":"The documents showed that the juvenile database had been integrated into the system by 2015. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.59.content.0":{"__typename":"TextInline","text":"“We have these photos. It makes sense,” Chief Shea said in the interview. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61.content.0":{"__typename":"TextInline","text":"State law requires that arrest photos be destroyed if the case is resolved in the juvenile’s favor, or if the child is found to have committed only a misdemeanor, rather than a felony. The photos also must be destroyed if a person reaches age 21 without a criminal record. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.61.content.1":{"__typename":"TextInline","text":" ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.63.content.0":{"__typename":"TextInline","text":"When children are charged with crimes, the court system usually takes some steps to prevent their acts from defining them in later years. Children who are 16 and under, for instance, are generally sent to Family Court, where records are not public. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.65.content.0":{"__typename":"TextInline","text":"Yet including their photos in a facial recognition database runs the risk that an imperfect algorithm identifies them as possible suspects in later crimes, civil rights advocates said. A mistaken match could lead investigators to focus on the wrong person from the outset, they said. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.67.content.0":{"__typename":"TextInline","text":"“It’s very disturbing to know that no matter what I’m doing at that moment, someone might be scanning my picture to try to find someone who committed a crime,” said Bailey, a 17-year-old Brooklyn girl who had admitted guilt in Family Court to a group attack that happened when she was 14. She said she was present at the attack but did not participate. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.0":{"__typename":"TextInline","text":"Bailey, who asked that she be identified only by her ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.1":{"__typename":"TextInline","text":"last name","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.69.content.2":{"__typename":"TextInline","text":" because she did not want her juvenile arrest to be public, has not been arrested again and is now a student at John Jay College of Criminal Justice. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.0":{"__typename":"TextInline","text":"R","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.1":{"__typename":"TextInline","text":"ecent studies indicate that people of color, as well as children and women, have a greater risk of misidentification than their counterparts, sai","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.2":{"__typename":"TextInline","text":"d ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.3":{"__typename":"TextInline","text":"Joy Buolamwini","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.4":{"__typename":"TextInline","text":", ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.5":{"__typename":"TextInline","text":"the founder of the Algorithmic Justice League and graduate researcher at the M.I.T. Media Lab","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.71.content.6":{"__typename":"TextInline","text":", who has examined how human biases are built into artificial intelligence. ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.0":{"__typename":"TextInline","text":"The racial disparities in the juvenile justice system are stark: In New York, black and Latino juveniles were charged with crimes at far higher rates than whites in 2017, the most recent year for which numbers were available. Black juveniles outnumbered white juveniles ","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.1":{"__typename":"TextInline","text":"more than 15 to 1","formats":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.1.formats.0","typename":"LinkFormat"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.1.formats.0":{"__typename":"LinkFormat","url":"https:\u002F\u002Fwww.criminaljustice.ny.gov\u002Fcrimnet\u002Fojsa\u002Fjj-reports\u002Fnewyorkcity.pdf","title":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.73.content.2":{"__typename":"TextInline","text":".","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.75.content.0":{"__typename":"TextInline","text":"“If the facial recognition algorithm has a negative bias toward a black population, that will get magnified more toward children,” Dr. Ricanek said, adding that in terms of diminished accuracy, “you’re now putting yourself in unknown territory.”","formats":[]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0":{"__typename":"Article","promotionalHeadline":"Facial Recognition Makes You Safer","promotionalSummary":"Used properly, the software effectively identifies crime suspects without violating rights.","headline":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.headline","typename":"CreativeWorkHeadline"},"summary":"Used properly, the software effectively identifies crime suspects without violating rights.","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F06\u002F09\u002Fopinion\u002Ffacial-recognition-police-new-york-city.html","firstPublished":"2019-06-09T23:00:05.000Z","promotionalMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.promotionalMedia","typename":"Image"},"section":{"type":"id","generated":false,"id":"Section:U2VjdGlvbjpueXQ6Ly9zZWN0aW9uL2Q3YTcxMTg1LWFhNjAtNTYzNS1iY2UwLTVmYWI3NmM3YzI5Nw==","typename":"Section"},"bylines":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.bylines.0","typename":"Byline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.headline":{"default":"How Facial Recognition Makes You Safer","__typename":"CreativeWorkHeadline"},"ImageRendition:images20190607opinionsunday07Oneill07Oneill-videoLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002Fsunday\u002F07Oneill\u002F07Oneill-videoLarge.jpg","name":"videoLarge","__typename":"ImageRendition"},"ImageRendition:images20190607opinionsunday07Oneill07Oneill-mediumThreeByTwo440.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002Fsunday\u002F07Oneill\u002F07Oneill-mediumThreeByTwo440.jpg","name":"mediumThreeByTwo440","__typename":"ImageRendition"},"ImageRendition:images20190607opinionsunday07Oneill07Oneill-threeByTwoSmallAt2X.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002Fsunday\u002F07Oneill\u002F07Oneill-threeByTwoSmallAt2X.jpg","name":"threeByTwoSmallAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.promotionalMedia.crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]}).0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190607opinionsunday07Oneill07Oneill-videoLarge.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190607opinionsunday07Oneill07Oneill-mediumThreeByTwo440.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190607opinionsunday07Oneill07Oneill-threeByTwoSmallAt2X.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.promotionalMedia":{"crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]})":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.promotionalMedia.crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]}).0","typename":"ImageCrop"}],"__typename":"Image"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1":{"__typename":"Article","promotionalHeadline":"Spying on Children Won’t Keep Them Safe","promotionalSummary":"This week my daughter’s school became the first in the nation to pilot facial-recognition software. The technology’s potential is chilling.","headline":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.headline","typename":"CreativeWorkHeadline"},"summary":"This week my daughter’s school became the first in the nation to pilot facial-recognition software. The technology’s potential is chilling.","url":"https:\u002F\u002Fwww.nytimes.com\u002F2019\u002F06\u002F07\u002Fopinion\u002Flockport-facial-recognition-schools.html","firstPublished":"2019-06-07T15:00:05.000Z","promotionalMedia":{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.promotionalMedia","typename":"Image"},"section":{"type":"id","generated":false,"id":"Section:U2VjdGlvbjpueXQ6Ly9zZWN0aW9uL2Q3YTcxMTg1LWFhNjAtNTYzNS1iY2UwLTVmYWI3NmM3YzI5Nw==","typename":"Section"},"bylines":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.bylines.0","typename":"Byline"}]},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.headline":{"default":"Spying on Children Won’t Keep Them Safe","__typename":"CreativeWorkHeadline"},"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-videoLarge.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002F07shultz-privacy\u002F07shultz-privacy-videoLarge.jpg","name":"videoLarge","__typename":"ImageRendition"},"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-mediumThreeByTwo440.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002F07shultz-privacy\u002F07shultz-privacy-mediumThreeByTwo440.jpg","name":"mediumThreeByTwo440","__typename":"ImageRendition"},"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-threeByTwoSmallAt2X.jpg":{"url":"https:\u002F\u002Fstatic01.nyt.com\u002Fimages\u002F2019\u002F06\u002F07\u002Fopinion\u002F07shultz-privacy\u002F07shultz-privacy-threeByTwoSmallAt2X.jpg","name":"threeByTwoSmallAt2X","__typename":"ImageRendition"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.promotionalMedia.crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]}).0":{"renditions":[{"type":"id","generated":false,"id":"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-videoLarge.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-mediumThreeByTwo440.jpg","typename":"ImageRendition"},{"type":"id","generated":false,"id":"ImageRendition:images20190607opinion07shultz-privacy07shultz-privacy-threeByTwoSmallAt2X.jpg","typename":"ImageRendition"}],"__typename":"ImageCrop"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.promotionalMedia":{"crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]})":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.promotionalMedia.crops({\"renditionNames\":[\"threeByTwoSmallAt2X\",\"videoLarge\",\"mediumThreeByTwo440\"]}).0","typename":"ImageCrop"}],"__typename":"Image"},"Section:U2VjdGlvbjpueXQ6Ly9zZWN0aW9uL2Q3YTcxMTg1LWFhNjAtNTYzNS1iY2UwLTVmYWI3NmM3YzI5Nw==":{"id":"U2VjdGlvbjpueXQ6Ly9zZWN0aW9uL2Q3YTcxMTg1LWFhNjAtNTYzNS1iY2UwLTVmYWI3NmM3YzI5Nw==","displayName":"Opinion","__typename":"Section"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.bylines.0.creators.0":{"displayName":"James O’Neill","__typename":"Person"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.bylines.0":{"creators":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.0.bylines.0.creators.0","typename":"Person"}],"__typename":"Byline"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.bylines.0.creators.0":{"displayName":"Jim Shultz","__typename":"Person"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.bylines.0":{"creators":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.sprinkledBody.content@filterEmpty.77.related@filterEmpty.1.bylines.0.creators.0","typename":"Person"}],"__typename":"Byline"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.legacy":{"reviewInformation":"","__typename":"ArticleLegacyData","htmlExtendedAuthorOrArticleInformation":"","htmlInfoBox":""},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails.socialMedia.0":{"type":"twitter","account":"JoeKGoldstein","__typename":"ContactDetailsSocialMedia"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails.socialMedia.1":{"type":"url","account":"https:\u002F\u002Fwww.nytimes.com\u002Fby\u002Fjoseph-goldstein","__typename":"ContactDetailsSocialMedia"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails":{"socialMedia":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails.socialMedia.0","typename":"ContactDetailsSocialMedia"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.contactDetails.socialMedia.1","typename":"ContactDetailsSocialMedia"}],"__typename":"ContactDetails"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.0.legacyData":{"htmlShortBiography":"\u003Cp\u003EJoseph Goldstein writes about policing and the criminal justice system. He has been a reporter at The Times since 2011, and is based in New York. He also worked for a year in the Kabul bureau, reporting on Afghanistan.\u003C\u002Fp\u003E","__typename":"PersonLegacyData"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails.socialMedia.0":{"type":"url","account":"https:\u002F\u002Fwww.nytimes.com\u002Fby\u002Fali-watkins","__typename":"ContactDetailsSocialMedia"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails.socialMedia.1":{"type":"twitter","account":"AliWatkins","__typename":"ContactDetailsSocialMedia"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails":{"socialMedia":[{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails.socialMedia.0","typename":"ContactDetailsSocialMedia"},{"type":"id","generated":true,"id":"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.contactDetails.socialMedia.1","typename":"ContactDetailsSocialMedia"}],"__typename":"ContactDetails"},"$Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==.bylines.0.creators.1.legacyData":{"htmlShortBiography":"\u003Cp\u003EAli Watkins is a reporter on the Metro Desk, covering courts and social services. Previously, she covered national security in Washington for The Times, BuzzFeed and McClatchy Newspapers.\u003C\u002Fp\u003E","__typename":"PersonLegacyData"},"ROOT_QUERY":{"workOrLocation({\"id\":\"\u002F2019\u002F08\u002F01\u002Fnyregion\u002Fnypd-facial-recognition-children-teenagers.html\"})":{"type":"id","generated":false,"id":"Article:QXJ0aWNsZTpueXQ6Ly9hcnRpY2xlLzlkYTU4MjQ2LTI0OTUtNTA1Zi05YWJkLWI1ZmRhOGU2N2I1Ng==","typename":"Article"}}},"config":{"gqlUrl":"https:\u002F\u002Fsamizdat-graphql.nytimes.com\u002Fgraphql\u002Fv2","gqlRequestHeaders":{"nyt-app-type":"project-vi","nyt-app-version":"0.0.5","nyt-token":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs+\u002FoUCTBmD\u002FcLdmcecrnBMHiU\u002FpxQCn2DDyaPKUOXxi4p0uUSZQzsuq1pJ1m5z1i0YGPd1U1OeGHAChWtqoxC7bFMCXcwnE1oyui9G1uobgpm1GdhtwkR7ta7akVTcsF8zxiXx7DNXIPd2nIJFH83rmkZueKrC4JVaNzjvD+Z03piLn5bHWU6+w+rA+kyJtGgZNTXKyPh6EC6o5N+rknNMG5+CdTq35p8f99WjFawSvYgP9V64kgckbTbtdJ6YhVP58TnuYgr12urtwnIqWP9KSJ1e5vmgf3tunMqWNm6+AnsqNj8mCLdCuc5cEB74CwUeQcP2HQQmbCddBy2y0mEwIDAQAB"},"gqlFetchTimeout":4000,"disablePersistedQueries":false,"initialDeviceType":"desktop","fastlyAbraConfig":{},"serviceWorkerFile":"service-worker-test-1565019880489.js"},"ssrQuery":{},"initialLocation":{"pathname":"\u002F2019\u002F08\u002F01\u002Fnyregion\u002Fnypd-facial-recognition-children-teenagers.html"},"externalAssets":[]};</script>
+ <script>!function(e){function r(r){for(var n,i,a=r[0],f=r[1],l=r[2],p=0,s=[];p<a.length;p++)i=a[p],o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var f=t[a];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={37:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="/vi-assets/static-assets/";var a=window.webpackJsonp=window.webpackJsonp||[],f=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var c=f;t()}([]);
+//# sourceMappingURL=runtime~adslot-a45e9d5711d983de8fda.js.map</script>
+ <script async src="/vi-assets/static-assets/adslot-88dc25fbfb7328ff1466.js"></script>
+ <script>!function(e){function r(r){for(var o,n,c=r[0],i=r[1],s=r[2],f=0,l=[];f<c.length;f++)n=c[f],d[n]&&l.push(d[n][0]),d[n]=0;for(o in i)Object.prototype.hasOwnProperty.call(i,o)&&(e[o]=i[o]);for(b&&b(r);l.length;)l.shift()();return a.push.apply(a,s||[]),t()}function t(){for(var e,r=0;r<a.length;r++){for(var t=a[r],o=!0,c=1;c<t.length;c++){var i=t[c];0!==d[i]&&(o=!1)}o&&(a.splice(r--,1),e=n(n.s=t[0]))}return e}var o={},d={39:0},a=[];function n(r){if(o[r])return o[r].exports;var t=o[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,n),t.l=!0,t.exports}n.e=function(e){var r=[],t=d[e];if(0!==t)if(t)r.push(t[2]);else{var o=new Promise(function(r,o){t=d[e]=[r,o]});r.push(t[2]=o);var a,c=document.createElement("script");c.charset="utf-8",c.timeout=120,n.nc&&c.setAttribute("nonce",n.nc),c.src=function(e){return n.p+""+({1:"answerpage~bestsellers~collections~hubpage~reviews~search~slideshow~timeswire~weddings",2:"vendors~audio~home~paidpost~story~trending~video",3:"bestsellers~byline~collections~reviews~trending",4:"vendors~answerpage~audio~slideshow~story",5:"vendors~audio~home~paidpost~story",6:"byline~timeswire~your-list",7:"answerpage~getstarted",8:"bestsellers~hubpage",9:"newsletter~regilite",10:"vendors~paidpost~video",11:"vendors~video~videoblock",13:"answerpage",14:"audio",15:"audioblock",16:"bestsellers",17:"blank",18:"byline",19:"coderedeem",20:"collections",21:"comments",22:"episodefooter",23:"getstarted",24:"home",25:"hubpage",28:"newsletter",29:"newsletters",30:"paidpost",31:"privacy",32:"programmables",33:"recirculation",34:"refer",35:"regilite",36:"reviews",40:"search",41:"slideshow",42:"stickyfilljs",43:"story",44:"timeswire",45:"trending",46:"vendors~audioblock",47:"vendors~collections",48:"vendors~episodefooter",49:"vendors~home",50:"vendors~slideshow",51:"video",52:"videoblock",53:"weddings",54:"your-list"}[e]||e)+"-"+{1:"3f4fa7221ef1476092a3",2:"99859b76d5b5d9a29339",3:"6f48de596aff21cee9e2",4:"4a8420b672b0eb786710",5:"ebc6aacf5f0b0f00d939",6:"cb31ca27d295accb8d47",7:"80921fe67fb06673afe2",8:"2cb427a40932c00d5467",9:"c17835f4020a81b3ebdf",10:"e6333c5f0c9d44a562b9",11:"340b908d6bbf26111cf8",13:"8c464e3538096d914776",14:"926f804d67e8a45a9f10",15:"287cb8154113b7f25784",16:"f4baccd76f2f8e8d9db8",17:"2102d3a3d664a932bad1",18:"7e235f2b3d6d19b68ded",19:"dd19d8e9f879d86abb75",20:"74e3e7b1d52b7fc14653",21:"bfae7d48bcf7e6c8ab89",22:"d32caaca6c5936978d4a",23:"300b3f609b3056db6c18",24:"e7c1959c1d8ba140707f",25:"c0e7bb29b120c3c2d802",28:"3ae59c9859d057a0249a",29:"e951dbf493cdf3558858",30:"c108afd87ed307bd7c43",31:"493cddaf9cad7abf670c",32:"3c3cfd695943ed02249d",33:"dc560ec354d5e74e6e39",34:"3202500fd0bc711d8680",35:"67182278afc38ad823b1",36:"f189bd767bbe13f59254",40:"33f98b7462fec3740a1d",41:"1d22c2cff98639b0c7b7",42:"8640087ba86873ebebae",43:"5230dd3423d03f5eb0b8",44:"2db55c4529c54890b4bd",45:"4614204aab86dd2d820f",46:"8574ab7f8faa5e4151d8",47:"07007634cf48d865ae1a",48:"1e063f58b4e3da82fc25",49:"6e63337189383a709584",50:"ab09592f64b71ce13dba",51:"35bd41b25aecc8dbc38b",52:"e71ac27943b9c0dc2f1c",53:"65894e06a558684c455b",54:"cf5b2e08b6f7a84842e2"}[e]+".js"}(e),a=function(r){c.onerror=c.onload=null,clearTimeout(i);var t=d[e];if(0!==t){if(t){var o=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src,n=new Error("Loading chunk "+e+" failed.\n("+o+": "+a+")");n.type=o,n.request=a,t[1](n)}d[e]=void 0}};var i=setTimeout(function(){a({type:"timeout",target:c})},12e4);c.onerror=c.onload=a,document.head.appendChild(c)}return Promise.all(r)},n.m=e,n.c=o,n.d=function(e,r,t){n.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,r){if(1&r&&(e=n(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(n.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var o in e)n.d(t,o,function(r){return e[r]}.bind(null,o));return t},n.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(r,"a",r),r},n.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},n.p="/vi-assets/static-assets/",n.oe=function(e){throw console.error(e),e};var c=window.webpackJsonp=window.webpackJsonp||[],i=c.push.bind(c);c.push=r,c=c.slice();for(var s=0;s<c.length;s++)r(c[s]);var b=i;t()}([]);
+//# sourceMappingURL=runtime~main-262212ad851d651999bf.js.map</script>
+ <script defer src="/vi-assets/static-assets/vendor-3389f9c978bdc7cb443c.js"></script>
+ <script defer src="/vi-assets/static-assets/story-5230dd3423d03f5eb0b8.js"></script>
+ <script defer src="/vi-assets/static-assets/main-72d661c291004bc90d1b.js"></script>
+ <script>
+(function(w, l) {
+ w[l] = w[l] || [];
+ w[l].push({
+ 'gtm.start': new Date().getTime(),
+ event: 'gtm.js'
+ });
+})(window, 'dataLayer');
+(function(){
+ var url = 'https://et.nytimes.com/pixel' +
+ '?url=' + window.location.href +
+ '&referrer=' + document.referrer +
+ '&subject=module-interactions' +
+ '&moduleData=%7B%22module%22%3A%22nyt-vi-page-pixel%22%2C%22pgType%22%3A%22%22%2C%22eventName%22%3A%22Impression%22%2C%22action%22%3A%22Impression%22%7D' +
+ '&sourceApp=nyt-vi&instant=1' +
+ '&_=' + Date.now();
+ var img = document.createElement('img');
+ img.src = url;
+ img.alt = "";
+ img.style.cssText = 'position: absolute; z-index: -999999; left: -1000px; top: -1000px;';
+ document.body.appendChild(img);
+})();
+</script>
+ <script defer src="https://www.googletagmanager.com/gtm.js?id=GTM-P528B3&gtm_auth=tfAzqo1rYDLgYhmTnSjPqw&gtm_preview=env-130&gtm_cookies_win=x"></script>
+<noscript>
+<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-P528B3&gtm_auth=tfAzqo1rYDLgYhmTnSjPqw&gtm_preview=env-130&gtm_cookies_win=x" height="0" width="0" style="display:none;visibility:hidden"></iframe>
+</noscript>
+ <div id="RavenInstaller">
+<script>
+if (window.INSTALL_RAVEN) {
+ window.addEventListener('load', function(event) {
+ var includeRaven = document.getElementById("RavenInstaller");
+ var script = document.createElement("script");
+ script.src = "/vi-assets/static-assets/raven.min-830a6d04a55c283934dd1893d6ddc66d.js";
+ script.onload = function() {
+ /* eslint-disable */
+// Install Raven
+window.Raven.config('https://7bc8bccf5c254286a99b11c68f6bf4ce@sentry.io/178860', {
+ release: vi.env.RELEASE,
+ environment: vi.env.ENVIRONMENT,
+ ignoreErrors: [/SecurityError: Blocked a frame with origin.*/]
+}).install(); // Stop using our error handler
+
+window.nyt_errors.ravenInstalled = true;
+var regex = /nyt-a=(.*?)(;|$)/;
+var id = regex.exec(document.cookie);
+
+if (id !== null) {
+ id = id[1];
+} else {
+ id = '';
+} // Setting nyt-a as user context
+
+
+window.Raven.setUserContext({
+ id: id
+}); // Pass collected errors to Raven
+
+window.nyt_errors.list.forEach(function (err) {
+ // weird?
+ if (!err) {
+ return;
+ } // also weird ... ?
+
+
+ if (!err.err) {
+ // maybe err itself is an Error?
+ if (err instanceof Error) {
+ window.Raven.captureException(err, err.data || {});
+ } // else { silently ignore? }
+
+ } // just making sure ...
+
+
+ if (err.err instanceof Error) {
+ window.Raven.captureException(err.err, err.data || {});
+ } // else { silently ignore? }
+
+}); // Pass collected Tags to Raven
+
+window.nyt_errors.tags.forEach(function (tag) {
+ window.Raven.setTagsContext(tag);
+});
+ };
+ includeRaven.appendChild(script);
+ });
+}
+</script>
+</div>
+
+
+ </body>
+</html>
diff --git a/test/fixtures/relay/accept-follow.json b/test/fixtures/relay/accept-follow.json
new file mode 100644
index 000000000..1b166f2da
--- /dev/null
+++ b/test/fixtures/relay/accept-follow.json
@@ -0,0 +1,15 @@
+{
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "actor": "https://relay.mastodon.host/actor",
+ "id": "https://relay.mastodon.host/activities/ec477b69-db26-4019-923e-cf809de516ab",
+ "object": {
+ "actor": "{{ap_id}}",
+ "id": "{{activity_id}}",
+ "object": "https://relay.mastodon.host/actor",
+ "type": "Follow"
+ },
+ "to": [
+ "{{ap_id}}"
+ ],
+ "type": "Accept"
+} \ No newline at end of file
diff --git a/test/fixtures/relay/relay.json b/test/fixtures/relay/relay.json
new file mode 100644
index 000000000..77ae7f06c
--- /dev/null
+++ b/test/fixtures/relay/relay.json
@@ -0,0 +1,20 @@
+{
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "endpoints": {
+ "sharedInbox": "https://relay.mastodon.host/inbox"
+ },
+ "followers": "https://relay.mastodon.host/followers",
+ "following": "https://relay.mastodon.host/following",
+ "inbox": "https://relay.mastodon.host/inbox",
+ "name": "ActivityRelay",
+ "type": "Application",
+ "id": "https://relay.mastodon.host/actor",
+ "publicKey": {
+ "id": "https://relay.mastodon.host/actor#main-key",
+ "owner": "https://relay.mastodon.host/actor",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuNYHNYETdsZFsdcTTEQo\nlsTP9yz4ZjOGrQ1EjoBA7NkjBUxxUAPxZbBjWPT9F+L3IbCX1IwI2OrBM/KwDlug\nV41xnjNmxSCUNpxX5IMZtFaAz9/hWu6xkRTs9Bh6XWZxi+db905aOqszb9Mo3H2g\nQJiAYemXwTh2kBO7XlBDbsMhO11Tu8FxcWTMdR54vlGv4RoiVh8dJRa06yyiTs+m\njbj/OJwR06mHHwlKYTVT/587NUb+e9QtCK6t/dqpyZ1o7vKSK5PSldZVjwHt292E\nXVxFOQVXi7JazTwpdPww79ECSe8ThCykOYCNkm3RjsKuLuokp7Vzq1hXIoeBJ7z2\ndU8vbgg/JyazsOsTxkVs2nd2i9/QW2SH+sX9X3357+XLSCh/A8p8fv/GeoN7UCXe\n4DWHFJZDlItNFfymiPbQH+omuju8qrfW9ngk1gFeI2mahXFQVu7x0qsaZYioCIrZ\nwq0zPnUGl9u0tLUXQz+ZkInRrEz+JepDVauy5/3QdzMLG420zCj/ygDrFzpBQIrc\n62Z6URueUBJox0UK71K+usxqOrepgw8haFGMvg3STFo34pNYjoK4oKO+h5qZEDFD\nb1n57t6JWUaBocZbJns9RGASq5gih+iMk2+zPLWp1x64yvuLsYVLPLBHxjCxS6lA\ndWcopZHi7R/OsRz+vTT7420CAwEAAQ==\n-----END PUBLIC KEY-----"
+ },
+ "summary": "ActivityRelay bot",
+ "preferredUsername": "relay",
+ "url": "https://relay.mastodon.host/actor"
+} \ No newline at end of file
diff --git a/test/fixtures/tesla_mock/admin@mastdon.example.org.json b/test/fixtures/tesla_mock/admin@mastdon.example.org.json
index 8159dc20a..9fdd6557c 100644
--- a/test/fixtures/tesla_mock/admin@mastdon.example.org.json
+++ b/test/fixtures/tesla_mock/admin@mastdon.example.org.json
@@ -9,7 +9,11 @@
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
- "Emoji": "toot:Emoji"
+ "Emoji": "toot:Emoji",
+ "alsoKnownAs": {
+ "@id": "as:alsoKnownAs",
+ "@type": "@id"
+ }
}],
"id": "http://mastodon.example.org/users/admin",
"type": "Person",
@@ -50,5 +54,6 @@
"type": "Image",
"mediaType": "image/png",
"url": "https://cdn.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png"
- }
+ },
+ "alsoKnownAs": ["http://example.org/users/foo"]
}
diff --git a/test/fixtures/tesla_mock/craigmaloney.json b/test/fixtures/tesla_mock/craigmaloney.json
new file mode 100644
index 000000000..56ea9c7c3
--- /dev/null
+++ b/test/fixtures/tesla_mock/craigmaloney.json
@@ -0,0 +1,112 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "CacheFile": "pt:CacheFile",
+ "Hashtag": "as:Hashtag",
+ "Infohash": "pt:Infohash",
+ "RsaSignature2017": "https://w3id.org/security#RsaSignature2017",
+ "category": "sc:category",
+ "commentsEnabled": {
+ "@id": "pt:commentsEnabled",
+ "@type": "sc:Boolean"
+ },
+ "downloadEnabled": {
+ "@id": "pt:downloadEnabled",
+ "@type": "sc:Boolean"
+ },
+ "expires": "sc:expires",
+ "fps": {
+ "@id": "pt:fps",
+ "@type": "sc:Number"
+ },
+ "language": "sc:inLanguage",
+ "licence": "sc:license",
+ "originallyPublishedAt": "sc:datePublished",
+ "position": {
+ "@id": "pt:position",
+ "@type": "sc:Number"
+ },
+ "pt": "https://joinpeertube.org/ns#",
+ "sc": "http://schema.org#",
+ "sensitive": "as:sensitive",
+ "size": {
+ "@id": "pt:size",
+ "@type": "sc:Number"
+ },
+ "startTimestamp": {
+ "@id": "pt:startTimestamp",
+ "@type": "sc:Number"
+ },
+ "state": {
+ "@id": "pt:state",
+ "@type": "sc:Number"
+ },
+ "stopTimestamp": {
+ "@id": "pt:stopTimestamp",
+ "@type": "sc:Number"
+ },
+ "subtitleLanguage": "sc:subtitleLanguage",
+ "support": {
+ "@id": "pt:support",
+ "@type": "sc:Text"
+ },
+ "uuid": "sc:identifier",
+ "views": {
+ "@id": "pt:views",
+ "@type": "sc:Number"
+ },
+ "waitTranscoding": {
+ "@id": "pt:waitTranscoding",
+ "@type": "sc:Boolean"
+ }
+ },
+ {
+ "comments": {
+ "@id": "as:comments",
+ "@type": "@id"
+ },
+ "dislikes": {
+ "@id": "as:dislikes",
+ "@type": "@id"
+ },
+ "likes": {
+ "@id": "as:likes",
+ "@type": "@id"
+ },
+ "playlists": {
+ "@id": "pt:playlists",
+ "@type": "@id"
+ },
+ "shares": {
+ "@id": "as:shares",
+ "@type": "@id"
+ }
+ }
+ ],
+ "endpoints": {
+ "sharedInbox": "https://peertube.social/inbox"
+ },
+ "followers": "https://peertube.social/accounts/craigmaloney/followers",
+ "following": "https://peertube.social/accounts/craigmaloney/following",
+ "icon": {
+ "mediaType": "image/png",
+ "type": "Image",
+ "url": "https://peertube.social/lazy-static/avatars/87bd694b-95bc-4066-83f4-bddfcd2b9caa.png"
+ },
+ "id": "https://peertube.social/accounts/craigmaloney",
+ "inbox": "https://peertube.social/accounts/craigmaloney/inbox",
+ "name": "Craig Maloney",
+ "outbox": "https://peertube.social/accounts/craigmaloney/outbox",
+ "playlists": "https://peertube.social/accounts/craigmaloney/playlists",
+ "preferredUsername": "craigmaloney",
+ "publicKey": {
+ "id": "https://peertube.social/accounts/craigmaloney#main-key",
+ "owner": "https://peertube.social/accounts/craigmaloney",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9qvGIYUW01yc8CCsrwxK\n5OXlV5s7EbNWY8tJr/p1oGuELZwAnG2XKxtdbvgcCT+YxL5uRXIdCFIIIKrzRFr/\nHfS0mOgNT9u3gu+SstCNgtatciT0RVP77yiC3b2NHq1NRRvvVhzQb4cpIWObIxqh\nb2ypDClTc7XaKtgmQCbwZlGyZMT+EKz/vustD6BlpGsglRkm7iES6s1PPGb1BU+n\nS94KhbS2DOFiLcXCVWt0QarokIIuKznp4+xP1axKyP+SkT5AHx08Nd5TYFb2C1Jl\nz0WD/1q0mAN62m7QrA3SQPUgB+wWD+S3Nzf7FwNPiP4srbBgxVEUnji/r9mQ6BXC\nrQIDAQAB\n-----END PUBLIC KEY-----"
+ },
+ "summary": null,
+ "type": "Person",
+ "url": "https://peertube.social/accounts/craigmaloney"
+}
diff --git a/test/fixtures/tesla_mock/funkwhale_audio.json b/test/fixtures/tesla_mock/funkwhale_audio.json
new file mode 100644
index 000000000..15736b1f8
--- /dev/null
+++ b/test/fixtures/tesla_mock/funkwhale_audio.json
@@ -0,0 +1,44 @@
+{
+ "id": "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871",
+ "type": "Audio",
+ "name": "Compositions - Test Audio for Pleroma",
+ "attributedTo": "https://channels.tests.funkwhale.audio/federation/actors/compositions",
+ "published": "2020-03-11T10:01:52.714918+00:00",
+ "to": "https://www.w3.org/ns/activitystreams#Public",
+ "url": [
+ {
+ "type": "Link",
+ "mimeType": "audio/ogg",
+ "href": "https://channels.tests.funkwhale.audio/api/v1/listen/3901e5d8-0445-49d5-9711-e096cf32e515/?upload=42342395-0208-4fee-a38d-259a6dae0871&download=false"
+ },
+ {
+ "type": "Link",
+ "mimeType": "text/html",
+ "href": "https://channels.tests.funkwhale.audio/library/tracks/74"
+ }
+ ],
+ "content": "<p>This is a test Audio for Pleroma.</p>",
+ "mediaType": "text/html",
+ "tag": [
+ {
+ "type": "Hashtag",
+ "name": "#funkwhale"
+ },
+ {
+ "type": "Hashtag",
+ "name": "#test"
+ },
+ {
+ "type": "Hashtag",
+ "name": "#tests"
+ }
+ ],
+ "summary": "#funkwhale #test #tests",
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers"
+ }
+ ]
+}
diff --git a/test/fixtures/tesla_mock/funkwhale_channel.json b/test/fixtures/tesla_mock/funkwhale_channel.json
new file mode 100644
index 000000000..cf9ee8151
--- /dev/null
+++ b/test/fixtures/tesla_mock/funkwhale_channel.json
@@ -0,0 +1,44 @@
+{
+ "id": "https://channels.tests.funkwhale.audio/federation/actors/compositions",
+ "outbox": "https://channels.tests.funkwhale.audio/federation/actors/compositions/outbox",
+ "inbox": "https://channels.tests.funkwhale.audio/federation/actors/compositions/inbox",
+ "preferredUsername": "compositions",
+ "type": "Person",
+ "name": "Compositions",
+ "followers": "https://channels.tests.funkwhale.audio/federation/actors/compositions/followers",
+ "following": "https://channels.tests.funkwhale.audio/federation/actors/compositions/following",
+ "manuallyApprovesFollowers": false,
+ "url": [
+ {
+ "type": "Link",
+ "href": "https://channels.tests.funkwhale.audio/channels/compositions",
+ "mediaType": "text/html"
+ },
+ {
+ "type": "Link",
+ "href": "https://channels.tests.funkwhale.audio/api/v1/channels/compositions/rss",
+ "mediaType": "application/rss+xml"
+ }
+ ],
+ "icon": {
+ "type": "Image",
+ "url": "https://channels.tests.funkwhale.audio/media/attachments/75/b4/f1/nosmile.jpeg",
+ "mediaType": "image/jpeg"
+ },
+ "summary": "<p>I'm testing federation with the fediverse :)</p>",
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers"
+ }
+ ],
+ "publicKey": {
+ "owner": "https://channels.tests.funkwhale.audio/federation/actors/compositions",
+ "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAv25u57oZfVLV3KltS+HcsdSx9Op4MmzIes1J8Wu8s0KbdXf2zEwS\nsVqyHgs/XCbnzsR3FqyJTo46D2BVnvZcuU5srNcR2I2HMaqQ0oVdnATE4K6KdcgV\nN+98pMWo56B8LTgE1VpvqbsrXLi9jCTzjrkebVMOP+ZVu+64v1qdgddseblYMnBZ\nct0s7ONbHnqrWlTGf5wES1uIZTVdn5r4MduZG+Uenfi1opBS0lUUxfWdW9r0oF2b\nyneZUyaUCbEroeKbqsweXCWVgnMarUOsgqC42KM4cf95lySSwTSaUtZYIbTw7s9W\n2jveU/rVg8BYZu5JK5obgBoxtlUeUoSswwIDAQAB\n-----END RSA PUBLIC KEY-----\n",
+ "id": "https://channels.tests.funkwhale.audio/federation/actors/compositions#main-key"
+ },
+ "endpoints": {
+ "sharedInbox": "https://channels.tests.funkwhale.audio/federation/shared/inbox"
+ }
+}
diff --git a/test/fixtures/tesla_mock/mobilizon.org-event.json b/test/fixtures/tesla_mock/mobilizon.org-event.json
new file mode 100644
index 000000000..7411cf817
--- /dev/null
+++ b/test/fixtures/tesla_mock/mobilizon.org-event.json
@@ -0,0 +1 @@
+{"@context":["https://www.w3.org/ns/activitystreams","https://litepub.social/litepub/context.jsonld",{"GeoCoordinates":"sc:GeoCoordinates","Hashtag":"as:Hashtag","Place":"sc:Place","PostalAddress":"sc:PostalAddress","address":{"@id":"sc:address","@type":"sc:PostalAddress"},"addressCountry":"sc:addressCountry","addressLocality":"sc:addressLocality","addressRegion":"sc:addressRegion","category":"sc:category","commentsEnabled":{"@id":"pt:commentsEnabled","@type":"sc:Boolean"},"geo":{"@id":"sc:geo","@type":"sc:GeoCoordinates"},"ical":"http://www.w3.org/2002/12/cal/ical#","joinMode":{"@id":"mz:joinMode","@type":"mz:joinModeType"},"joinModeType":{"@id":"mz:joinModeType","@type":"rdfs:Class"},"location":{"@id":"sc:location","@type":"sc:Place"},"maximumAttendeeCapacity":"sc:maximumAttendeeCapacity","mz":"https://joinmobilizon.org/ns#","postalCode":"sc:postalCode","pt":"https://joinpeertube.org/ns#","repliesModerationOption":{"@id":"mz:repliesModerationOption","@type":"mz:repliesModerationOptionType"},"repliesModerationOptionType":{"@id":"mz:repliesModerationOptionType","@type":"rdfs:Class"},"sc":"http://schema.org#","streetAddress":"sc:streetAddress","uuid":"sc:identifier"}],"actor":"https://mobilizon.org/@tcit","attributedTo":"https://mobilizon.org/@tcit","category":"meeting","cc":[],"commentsEnabled":true,"content":"<p>Mobilizon is now federated! 🎉</p><p></p><p>You can view this event from other instances if they are subscribed to mobilizon.org, and soon directly from Mastodon and Pleroma. It is possible that you may see some comments from other instances, including Mastodon ones, just below.</p><p></p><p>With a Mobilizon account on an instance, you may <strong>participate</strong> at events from other instances and <strong>add comments</strong> on events.</p><p></p><p>Of course, it's still <u>a work in progress</u>: if reports made from an instance on events and comments can be federated, you can't block people right now, and moderators actions are rather limited, but this <strong>will definitely get fixed over time</strong> until first stable version next year.</p><p></p><p>Anyway, if you want to come up with some feedback, head over to our forum or - if you feel you have technical skills and are familiar with it - on our Gitlab repository.</p><p></p><p>Also, to people that want to set Mobilizon themselves even though we really don't advise to do that for now, we have a little documentation but it's quite the early days and you'll probably need some help. No worries, you can chat with us on our Forum or though our Matrix channel.</p><p></p><p>Check our website for more informations and follow us on Twitter or Mastodon.</p>","endTime":"2019-12-18T14:00:00Z","ical:status":"CONFIRMED","id":"https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39","joinMode":"free","location":{"address":{"addressCountry":"France","addressLocality":"Nantes","addressRegion":"Pays de la Loire","postalCode":null,"streetAddress":" ","type":"PostalAddress"},"geo":{"latitude":-1.54939699141711,"longitude":47.21617415,"type":"GeoCoordinates"},"id":"https://mobilizon.org/address/1368fdab-1e2c-4de6-bcff-a90c84abdee1","name":"Cour du Château des Ducs de Bretagne","type":"Place"},"maximumAttendeeCapacity":0,"mediaType":"text/html","name":"Mobilizon Launching Party","published":"2019-12-17T11:33:56Z","repliesModerationOption":"allow_all","startTime":"2019-12-18T13:00:00Z","tag":[{"href":"https://mobilizon.org/tags/mobilizon","name":"#Mobilizon","type":"Hashtag"},{"href":"https://mobilizon.org/tags/federation","name":"#Federation","type":"Hashtag"},{"href":"https://mobilizon.org/tags/activitypub","name":"#ActivityPub","type":"Hashtag"},{"href":"https://mobilizon.org/tags/party","name":"#Party","type":"Hashtag"}],"to":["https://www.w3.org/ns/activitystreams#Public"],"type":"Event","updated":"2019-12-17T12:25:01Z","url":"https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39","uuid":"252d5816-00a3-4a89-a66f-15bf65c33e39"} \ No newline at end of file
diff --git a/test/fixtures/tesla_mock/mobilizon.org-user.json b/test/fixtures/tesla_mock/mobilizon.org-user.json
new file mode 100644
index 000000000..f948ae5f0
--- /dev/null
+++ b/test/fixtures/tesla_mock/mobilizon.org-user.json
@@ -0,0 +1 @@
+{"@context":["https://www.w3.org/ns/activitystreams","https://litepub.social/litepub/context.jsonld",{"GeoCoordinates":"sc:GeoCoordinates","Hashtag":"as:Hashtag","Place":"sc:Place","PostalAddress":"sc:PostalAddress","address":{"@id":"sc:address","@type":"sc:PostalAddress"},"addressCountry":"sc:addressCountry","addressLocality":"sc:addressLocality","addressRegion":"sc:addressRegion","category":"sc:category","commentsEnabled":{"@id":"pt:commentsEnabled","@type":"sc:Boolean"},"geo":{"@id":"sc:geo","@type":"sc:GeoCoordinates"},"ical":"http://www.w3.org/2002/12/cal/ical#","joinMode":{"@id":"mz:joinMode","@type":"mz:joinModeType"},"joinModeType":{"@id":"mz:joinModeType","@type":"rdfs:Class"},"location":{"@id":"sc:location","@type":"sc:Place"},"maximumAttendeeCapacity":"sc:maximumAttendeeCapacity","mz":"https://joinmobilizon.org/ns#","postalCode":"sc:postalCode","pt":"https://joinpeertube.org/ns#","repliesModerationOption":{"@id":"mz:repliesModerationOption","@type":"mz:repliesModerationOptionType"},"repliesModerationOptionType":{"@id":"mz:repliesModerationOptionType","@type":"rdfs:Class"},"sc":"http://schema.org#","streetAddress":"sc:streetAddress","uuid":"sc:identifier"}],"endpoints":{"sharedInbox":"https://mobilizon.org/inbox"},"followers":"https://mobilizon.org/@tcit/followers","following":"https://mobilizon.org/@tcit/following","icon":{"mediaType":null,"type":"Image","url":"https://mobilizon.org/media/3a5f18c058a8193b1febfaf561f94ae8b91f85ac64c01ddf5ad7b251fb43baf5.jpg?name=profil.jpg"},"id":"https://mobilizon.org/@tcit","inbox":"https://mobilizon.org/@tcit/inbox","manuallyApprovesFollowers":false,"name":"Thomas Citharel","outbox":"https://mobilizon.org/@tcit/outbox","preferredUsername":"tcit","publicKey":{"id":"https://mobilizon.org/@tcit#main-key","owner":"https://mobilizon.org/@tcit","publicKeyPem":"-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAtzuZFviv5f12SuA0wZFMuwKS8RIlT3IjPCMLRDhiorZeV3UJ1lik\nDYO6mEh22KDXYgJtNVSYGF0Q5LJivgcvuvU+VQ048iTB1B2x0rHMr47KPByPjfVb\nKDeHt6fkHcLY0JK8UkIxW542wXAg4jX5w3gJi3pgTQrCT8VNyPbH1CaA0uW//9jc\nqzZQVFzpfdJoVOM9E3Urc/u58HC4xOptlM7+B/594ZI9drYwy5m+ZxHwlQUYCva4\n34dvwsfOGxkQyIrzXoep80EnWnFpYCLMcCiz+sEhPYxqLgNE+Cmn/6pv7SIscz6p\neVlQXIchdw+J4yl07paJDkFc6CNTCmaIHQIDAQAB\n-----END RSA PUBLIC KEY-----\n\n"},"summary":"Main profile","type":"Person","url":"https://mobilizon.org/@tcit"} \ No newline at end of file
diff --git a/test/fixtures/tesla_mock/peertube-social.json b/test/fixtures/tesla_mock/peertube-social.json
new file mode 100644
index 000000000..0e996ba35
--- /dev/null
+++ b/test/fixtures/tesla_mock/peertube-social.json
@@ -0,0 +1,234 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "CacheFile": "pt:CacheFile",
+ "Hashtag": "as:Hashtag",
+ "Infohash": "pt:Infohash",
+ "RsaSignature2017": "https://w3id.org/security#RsaSignature2017",
+ "category": "sc:category",
+ "commentsEnabled": {
+ "@id": "pt:commentsEnabled",
+ "@type": "sc:Boolean"
+ },
+ "downloadEnabled": {
+ "@id": "pt:downloadEnabled",
+ "@type": "sc:Boolean"
+ },
+ "expires": "sc:expires",
+ "fps": {
+ "@id": "pt:fps",
+ "@type": "sc:Number"
+ },
+ "language": "sc:inLanguage",
+ "licence": "sc:license",
+ "originallyPublishedAt": "sc:datePublished",
+ "position": {
+ "@id": "pt:position",
+ "@type": "sc:Number"
+ },
+ "pt": "https://joinpeertube.org/ns#",
+ "sc": "http://schema.org#",
+ "sensitive": "as:sensitive",
+ "size": {
+ "@id": "pt:size",
+ "@type": "sc:Number"
+ },
+ "startTimestamp": {
+ "@id": "pt:startTimestamp",
+ "@type": "sc:Number"
+ },
+ "state": {
+ "@id": "pt:state",
+ "@type": "sc:Number"
+ },
+ "stopTimestamp": {
+ "@id": "pt:stopTimestamp",
+ "@type": "sc:Number"
+ },
+ "subtitleLanguage": "sc:subtitleLanguage",
+ "support": {
+ "@id": "pt:support",
+ "@type": "sc:Text"
+ },
+ "uuid": "sc:identifier",
+ "views": {
+ "@id": "pt:views",
+ "@type": "sc:Number"
+ },
+ "waitTranscoding": {
+ "@id": "pt:waitTranscoding",
+ "@type": "sc:Boolean"
+ }
+ },
+ {
+ "comments": {
+ "@id": "as:comments",
+ "@type": "@id"
+ },
+ "dislikes": {
+ "@id": "as:dislikes",
+ "@type": "@id"
+ },
+ "likes": {
+ "@id": "as:likes",
+ "@type": "@id"
+ },
+ "playlists": {
+ "@id": "pt:playlists",
+ "@type": "@id"
+ },
+ "shares": {
+ "@id": "as:shares",
+ "@type": "@id"
+ }
+ }
+ ],
+ "attributedTo": [
+ {
+ "id": "https://peertube.social/accounts/craigmaloney",
+ "type": "Person"
+ },
+ {
+ "id": "https://peertube.social/video-channels/9909c7d9-6b5b-4aae-9164-c1af7229c91c",
+ "type": "Group"
+ }
+ ],
+ "category": {
+ "identifier": "15",
+ "name": "Science & Technology"
+ },
+ "cc": [
+ "https://peertube.social/accounts/craigmaloney/followers"
+ ],
+ "comments": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/comments",
+ "commentsEnabled": true,
+ "content": "Support this and our other Michigan!/usr/group videos and meetings. Learn more at http://mug.org/membership\n\nTwenty Years in Jail: FreeBSD's Jails, Then and Now\n\nJails started as a limited virtualization system, but over the last two years they've...",
+ "dislikes": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/dislikes",
+ "downloadEnabled": true,
+ "duration": "PT5151S",
+ "icon": {
+ "height": 122,
+ "mediaType": "image/jpeg",
+ "type": "Image",
+ "url": "https://peertube.social/static/thumbnails/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe.jpg",
+ "width": 223
+ },
+ "id": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe",
+ "language": {
+ "identifier": "en",
+ "name": "English"
+ },
+ "licence": {
+ "identifier": "1",
+ "name": "Attribution"
+ },
+ "likes": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/likes",
+ "mediaType": "text/markdown",
+ "name": "Twenty Years in Jail: FreeBSD's Jails, Then and Now",
+ "originallyPublishedAt": "2019-08-13T00:00:00.000Z",
+ "published": "2020-02-12T01:06:08.054Z",
+ "sensitive": false,
+ "shares": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/announces",
+ "state": 1,
+ "subtitleLanguage": [],
+ "support": "Learn more at http://mug.org",
+ "tag": [
+ {
+ "name": "linux",
+ "type": "Hashtag"
+ },
+ {
+ "name": "mug.org",
+ "type": "Hashtag"
+ },
+ {
+ "name": "open",
+ "type": "Hashtag"
+ },
+ {
+ "name": "oss",
+ "type": "Hashtag"
+ },
+ {
+ "name": "source",
+ "type": "Hashtag"
+ }
+ ],
+ "to": [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "type": "Video",
+ "updated": "2020-02-15T15:01:09.474Z",
+ "url": [
+ {
+ "href": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe",
+ "mediaType": "text/html",
+ "type": "Link"
+ },
+ {
+ "fps": 30,
+ "height": 240,
+ "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.mp4",
+ "mediaType": "video/mp4",
+ "size": 119465800,
+ "type": "Link"
+ },
+ {
+ "height": 240,
+ "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.torrent",
+ "mediaType": "application/x-bittorrent",
+ "type": "Link"
+ },
+ {
+ "height": 240,
+ "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.torrent&xt=urn:btih:b3365331a8543bf48d09add56d7fe4b1cbbb5659&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.mp4",
+ "mediaType": "application/x-bittorrent;x-scheme-handler/magnet",
+ "type": "Link"
+ },
+ {
+ "fps": 30,
+ "height": 360,
+ "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.mp4",
+ "mediaType": "video/mp4",
+ "size": 143930318,
+ "type": "Link"
+ },
+ {
+ "height": 360,
+ "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.torrent",
+ "mediaType": "application/x-bittorrent",
+ "type": "Link"
+ },
+ {
+ "height": 360,
+ "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.torrent&xt=urn:btih:0d37b23c98cb0d89e28b5dc8f49b3c97a041e569&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.mp4",
+ "mediaType": "application/x-bittorrent;x-scheme-handler/magnet",
+ "type": "Link"
+ },
+ {
+ "fps": 30,
+ "height": 480,
+ "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.mp4",
+ "mediaType": "video/mp4",
+ "size": 130530754,
+ "type": "Link"
+ },
+ {
+ "height": 480,
+ "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.torrent",
+ "mediaType": "application/x-bittorrent",
+ "type": "Link"
+ },
+ {
+ "height": 480,
+ "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.torrent&xt=urn:btih:3a13ff822ad9494165eff6167183ddaaabc1372a&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.mp4",
+ "mediaType": "application/x-bittorrent;x-scheme-handler/magnet",
+ "type": "Link"
+ }
+ ],
+ "uuid": "278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe",
+ "views": 2,
+ "waitTranscoding": false
+}
diff --git a/test/fixtures/users_mock/friendica_followers.json b/test/fixtures/users_mock/friendica_followers.json
new file mode 100644
index 000000000..7b86b5fe2
--- /dev/null
+++ b/test/fixtures/users_mock/friendica_followers.json
@@ -0,0 +1,19 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "vcard": "http://www.w3.org/2006/vcard/ns#",
+ "dfrn": "http://purl.org/macgirvin/dfrn/1.0/",
+ "diaspora": "https://diasporafoundation.org/ns/",
+ "litepub": "http://litepub.social/ns#",
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "sensitive": "as:sensitive",
+ "Hashtag": "as:Hashtag",
+ "directMessage": "litepub:directMessage"
+ }
+ ],
+ "id": "http://localhost:8080/followers/fuser3",
+ "type": "OrderedCollection",
+ "totalItems": 296
+}
diff --git a/test/fixtures/users_mock/friendica_following.json b/test/fixtures/users_mock/friendica_following.json
new file mode 100644
index 000000000..7c526befc
--- /dev/null
+++ b/test/fixtures/users_mock/friendica_following.json
@@ -0,0 +1,19 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "vcard": "http://www.w3.org/2006/vcard/ns#",
+ "dfrn": "http://purl.org/macgirvin/dfrn/1.0/",
+ "diaspora": "https://diasporafoundation.org/ns/",
+ "litepub": "http://litepub.social/ns#",
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "sensitive": "as:sensitive",
+ "Hashtag": "as:Hashtag",
+ "directMessage": "litepub:directMessage"
+ }
+ ],
+ "id": "http://localhost:8080/following/fuser3",
+ "type": "OrderedCollection",
+ "totalItems": 32
+}
diff --git a/test/fixtures/users_mock/localhost.json b/test/fixtures/users_mock/localhost.json
new file mode 100644
index 000000000..a49935db1
--- /dev/null
+++ b/test/fixtures/users_mock/localhost.json
@@ -0,0 +1,41 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "http://localhost:4001/schemas/litepub-0.1.jsonld",
+ {
+ "@language": "und"
+ }
+ ],
+ "attachment": [],
+ "endpoints": {
+ "oauthAuthorizationEndpoint": "http://localhost:4001/oauth/authorize",
+ "oauthRegistrationEndpoint": "http://localhost:4001/api/v1/apps",
+ "oauthTokenEndpoint": "http://localhost:4001/oauth/token",
+ "sharedInbox": "http://localhost:4001/inbox"
+ },
+ "followers": "http://localhost:4001/users/{{nickname}}/followers",
+ "following": "http://localhost:4001/users/{{nickname}}/following",
+ "icon": {
+ "type": "Image",
+ "url": "http://localhost:4001/media/4e914f5b84e4a259a3f6c2d2edc9ab642f2ab05f3e3d9c52c81fc2d984b3d51e.jpg"
+ },
+ "id": "http://localhost:4001/users/{{nickname}}",
+ "image": {
+ "type": "Image",
+ "url": "http://localhost:4001/media/f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg?name=f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg"
+ },
+ "inbox": "http://localhost:4001/users/{{nickname}}/inbox",
+ "manuallyApprovesFollowers": false,
+ "name": "{{nickname}}",
+ "outbox": "http://localhost:4001/users/{{nickname}}/outbox",
+ "preferredUsername": "{{nickname}}",
+ "publicKey": {
+ "id": "http://localhost:4001/users/{{nickname}}#main-key",
+ "owner": "http://localhost:4001/users/{{nickname}}",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5DLtwGXNZElJyxFGfcVc\nXANhaMadj/iYYQwZjOJTV9QsbtiNBeIK54PJrYuU0/0YIdrvS1iqheX5IwXRhcwa\nhm3ZyLz7XeN9st7FBni4BmZMBtMpxAuYuu5p/jbWy13qAiYOhPreCx0wrWgm/lBD\n9mkgaxIxPooBE0S4ZWEJIDIV1Vft3AWcRUyWW1vIBK0uZzs6GYshbQZB952S0yo4\nFzI1hABGHncH8UvuFauh4EZ8tY7/X5I0pGRnDOcRN1dAht5w5yTA+6r5kebiFQjP\nIzN/eCO/a9Flrj9YGW7HDNtjSOH0A31PLRGlJtJO3yK57dnf5ppyCZGfL4emShQo\ncQIDAQAB\n-----END PUBLIC KEY-----\n\n"
+ },
+ "summary": "your friendly neighborhood pleroma developer<br>I like cute things and distributed systems, and really hate delete and redrafts",
+ "tag": [],
+ "type": "Person",
+ "url": "http://localhost:4001/users/{{nickname}}"
+} \ No newline at end of file
diff --git a/test/fixtures/warnings/otp_version/21.1 b/test/fixtures/warnings/otp_version/21.1
new file mode 100644
index 000000000..90cd64c4f
--- /dev/null
+++ b/test/fixtures/warnings/otp_version/21.1
@@ -0,0 +1 @@
+21.1 \ No newline at end of file
diff --git a/test/fixtures/warnings/otp_version/22.1 b/test/fixtures/warnings/otp_version/22.1
new file mode 100644
index 000000000..d9b314368
--- /dev/null
+++ b/test/fixtures/warnings/otp_version/22.1
@@ -0,0 +1 @@
+22.1 \ No newline at end of file
diff --git a/test/fixtures/warnings/otp_version/22.4 b/test/fixtures/warnings/otp_version/22.4
new file mode 100644
index 000000000..1da8ccd28
--- /dev/null
+++ b/test/fixtures/warnings/otp_version/22.4
@@ -0,0 +1 @@
+22.4 \ No newline at end of file
diff --git a/test/fixtures/warnings/otp_version/23.0 b/test/fixtures/warnings/otp_version/23.0
new file mode 100644
index 000000000..4266d8634
--- /dev/null
+++ b/test/fixtures/warnings/otp_version/23.0
@@ -0,0 +1 @@
+23.0 \ No newline at end of file
diff --git a/test/following_relationship_test.exs b/test/following_relationship_test.exs
new file mode 100644
index 000000000..17a468abb
--- /dev/null
+++ b/test/following_relationship_test.exs
@@ -0,0 +1,47 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.FollowingRelationshipTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.FollowingRelationship
+ alias Pleroma.Web.ActivityPub.InternalFetchActor
+ alias Pleroma.Web.ActivityPub.Relay
+
+ import Pleroma.Factory
+
+ describe "following/1" do
+ test "returns following addresses without internal.fetch" do
+ user = insert(:user)
+ fetch_actor = InternalFetchActor.get_actor()
+ FollowingRelationship.follow(fetch_actor, user, :follow_accept)
+ assert FollowingRelationship.following(fetch_actor) == [user.follower_address]
+ end
+
+ test "returns following addresses without relay" do
+ user = insert(:user)
+ relay_actor = Relay.get_actor()
+ FollowingRelationship.follow(relay_actor, user, :follow_accept)
+ assert FollowingRelationship.following(relay_actor) == [user.follower_address]
+ end
+
+ test "returns following addresses without remote user" do
+ user = insert(:user)
+ actor = insert(:user, local: false)
+ FollowingRelationship.follow(actor, user, :follow_accept)
+ assert FollowingRelationship.following(actor) == [user.follower_address]
+ end
+
+ test "returns following addresses with local user" do
+ user = insert(:user)
+ actor = insert(:user, local: true)
+ FollowingRelationship.follow(actor, user, :follow_accept)
+
+ assert FollowingRelationship.following(actor) == [
+ actor.follower_address,
+ user.follower_address
+ ]
+ end
+ end
+end
diff --git a/test/formatter_test.exs b/test/formatter_test.exs
index 3bff51527..bef5a2c28 100644
--- a/test/formatter_test.exs
+++ b/test/formatter_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.FormatterTest do
@@ -119,16 +119,29 @@ defmodule Pleroma.FormatterTest do
end
end
- describe "add_user_links" do
+ describe "Formatter.linkify" do
+ test "correctly finds mentions that contain the domain name" do
+ _user = insert(:user, %{nickname: "lain"})
+ _remote_user = insert(:user, %{nickname: "lain@lain.com", local: false})
+
+ text = "hey @lain@lain.com what's up"
+
+ {_text, mentions, []} = Formatter.linkify(text)
+ [{username, user}] = mentions
+
+ assert username == "@lain@lain.com"
+ assert user.nickname == "lain@lain.com"
+ end
+
test "gives a replacement for user links, using local nicknames in user links text" do
text = "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme@archae.me"
gsimg = insert(:user, %{nickname: "gsimg"})
archaeme =
- insert(:user, %{
+ insert(:user,
nickname: "archa_eme_",
- info: %User.Info{source_data: %{"url" => "https://archeme/@archa_eme_"}}
- })
+ uri: "https://archeme/@archa_eme_"
+ )
archaeme_remote = insert(:user, %{nickname: "archaeme@archae.me"})
@@ -137,13 +150,13 @@ defmodule Pleroma.FormatterTest do
assert length(mentions) == 3
expected_text =
- ~s(<span class="h-card"><a data-user="#{gsimg.id}" class="u-url mention" href="#{
+ ~s(<span class="h-card"><a class="u-url mention" data-user="#{gsimg.id}" href="#{
gsimg.ap_id
- }" rel="ugc">@<span>gsimg</span></a></span> According to <span class="h-card"><a data-user="#{
+ }" rel="ugc">@<span>gsimg</span></a></span> According to <span class="h-card"><a class="u-url mention" data-user="#{
archaeme.id
- }" class="u-url mention" href="#{"https://archeme/@archa_eme_"}" rel="ugc">@<span>archa_eme_</span></a></span>, that is @daggsy. Also hello <span class="h-card"><a data-user="#{
+ }" href="#{"https://archeme/@archa_eme_"}" rel="ugc">@<span>archa_eme_</span></a></span>, that is @daggsy. Also hello <span class="h-card"><a class="u-url mention" data-user="#{
archaeme_remote.id
- }" class="u-url mention" href="#{archaeme_remote.ap_id}" rel="ugc">@<span>archaeme</span></a></span>)
+ }" href="#{archaeme_remote.ap_id}" rel="ugc">@<span>archaeme</span></a></span>)
assert expected_text == text
end
@@ -158,7 +171,7 @@ defmodule Pleroma.FormatterTest do
assert length(mentions) == 1
expected_text =
- ~s(<span class="h-card"><a data-user="#{mike.id}" class="u-url mention" href="#{
+ ~s(<span class="h-card"><a class="u-url mention" data-user="#{mike.id}" href="#{
mike.ap_id
}" rel="ugc">@<span>mike</span></a></span> test)
@@ -174,7 +187,7 @@ defmodule Pleroma.FormatterTest do
assert length(mentions) == 1
expected_text =
- ~s(<span class="h-card"><a data-user="#{o.id}" class="u-url mention" href="#{o.ap_id}" rel="ugc">@<span>o</span></a></span> hi)
+ ~s(<span class="h-card"><a class="u-url mention" data-user="#{o.id}" href="#{o.ap_id}" rel="ugc">@<span>o</span></a></span> hi)
assert expected_text == text
end
@@ -196,17 +209,13 @@ defmodule Pleroma.FormatterTest do
assert mentions == [{"@#{user.nickname}", user}, {"@#{other_user.nickname}", other_user}]
assert expected_text ==
- ~s(<span class="h-card"><a data-user="#{user.id}" class="u-url mention" href="#{
+ ~s(<span class="h-card"><a class="u-url mention" data-user="#{user.id}" href="#{
user.ap_id
- }" rel="ugc">@<span>#{user.nickname}</span></a></span> <span class="h-card"><a data-user="#{
+ }" rel="ugc">@<span>#{user.nickname}</span></a></span> <span class="h-card"><a class="u-url mention" data-user="#{
other_user.id
- }" class="u-url mention" href="#{other_user.ap_id}" rel="ugc">@<span>#{
- other_user.nickname
- }</span></a></span> hey dudes i hate <span class="h-card"><a data-user="#{
+ }" href="#{other_user.ap_id}" rel="ugc">@<span>#{other_user.nickname}</span></a></span> hey dudes i hate <span class="h-card"><a class="u-url mention" data-user="#{
third_user.id
- }" class="u-url mention" href="#{third_user.ap_id}" rel="ugc">@<span>#{
- third_user.nickname
- }</span></a></span>)
+ }" href="#{third_user.ap_id}" rel="ugc">@<span>#{third_user.nickname}</span></a></span>)
end
test "given the 'safe_mention' option, it will still work without any mention" do
diff --git a/test/healthcheck_test.exs b/test/healthcheck_test.exs
index 66d5026ff..e341e6983 100644
--- a/test/healthcheck_test.exs
+++ b/test/healthcheck_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HealthcheckTest do
diff --git a/test/html_test.exs b/test/html_test.exs
index 306ad3b3b..0a4b4ebbc 100644
--- a/test/html_test.exs
+++ b/test/html_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTMLTest do
@@ -21,31 +21,31 @@ defmodule Pleroma.HTMLTest do
"""
@html_onerror_sample """
- <img src="http://example.com/image.jpg" onerror="alert('hacked')">
+ <img src="http://example.com/image.jpg" onerror="alert('hacked')">
"""
@html_span_class_sample """
- <span class="animate-spin">hi</span>
+ <span class="animate-spin">hi</span>
"""
@html_span_microformats_sample """
- <span class="h-card"><a class="u-url mention">@<span>foo</span></a></span>
+ <span class="h-card"><a class="u-url mention">@<span>foo</span></a></span>
"""
@html_span_invalid_microformats_sample """
- <span class="h-card"><a class="u-url mention animate-spin">@<span>foo</span></a></span>
+ <span class="h-card"><a class="u-url mention animate-spin">@<span>foo</span></a></span>
"""
describe "StripTags scrubber" do
test "works as expected" do
expected = """
- this is in bold
+ this is in bold
this is a paragraph
this is a linebreak
- this is a link with allowed "rel" attribute: example.com
- this is a link with not allowed "rel" attribute: example.com
+ this is a link with allowed &quot;rel&quot; attribute: example.com
+ this is a link with not allowed &quot;rel&quot; attribute: example.com
this is an image:
- alert('hacked')
+ alert(&#39;hacked&#39;)
"""
assert expected == HTML.strip_tags(@html_sample)
@@ -61,13 +61,13 @@ defmodule Pleroma.HTMLTest do
describe "TwitterText scrubber" do
test "normalizes HTML as expected" do
expected = """
- this is in bold
+ this is in bold
<p>this is a paragraph</p>
- this is a linebreak<br />
- this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
- this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
- this is an image: <img src="http://example.com/image.jpg" /><br />
- alert('hacked')
+ this is a linebreak<br/>
+ this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
+ this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
+ this is an image: <img src="http://example.com/image.jpg"/><br/>
+ alert(&#39;hacked&#39;)
"""
assert expected == HTML.filter_tags(@html_sample, Pleroma.HTML.Scrubber.TwitterText)
@@ -75,7 +75,7 @@ defmodule Pleroma.HTMLTest do
test "does not allow attribute-based XSS" do
expected = """
- <img src="http://example.com/image.jpg" />
+ <img src="http://example.com/image.jpg"/>
"""
assert expected == HTML.filter_tags(@html_onerror_sample, Pleroma.HTML.Scrubber.TwitterText)
@@ -115,13 +115,13 @@ defmodule Pleroma.HTMLTest do
describe "default scrubber" do
test "normalizes HTML as expected" do
expected = """
- <b>this is in bold</b>
+ <b>this is in bold</b>
<p>this is a paragraph</p>
- this is a linebreak<br />
- this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
- this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
- this is an image: <img src="http://example.com/image.jpg" /><br />
- alert('hacked')
+ this is a linebreak<br/>
+ this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
+ this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
+ this is an image: <img src="http://example.com/image.jpg"/><br/>
+ alert(&#39;hacked&#39;)
"""
assert expected == HTML.filter_tags(@html_sample, Pleroma.HTML.Scrubber.Default)
@@ -129,7 +129,7 @@ defmodule Pleroma.HTMLTest do
test "does not allow attribute-based XSS" do
expected = """
- <img src="http://example.com/image.jpg" />
+ <img src="http://example.com/image.jpg"/>
"""
assert expected == HTML.filter_tags(@html_onerror_sample, Pleroma.HTML.Scrubber.Default)
@@ -171,7 +171,7 @@ defmodule Pleroma.HTMLTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"I think I just found the best github repo https://github.com/komeiji-satori/Dress"
})
@@ -186,7 +186,7 @@ defmodule Pleroma.HTMLTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"@#{other_user.nickname} install misskey! https://github.com/syuilo/misskey/blob/develop/docs/setup.en.md"
})
@@ -203,8 +203,7 @@ defmodule Pleroma.HTMLTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
- "#cofe https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140"
+ status: "#cofe https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140"
})
object = Object.normalize(activity)
@@ -218,9 +217,9 @@ defmodule Pleroma.HTMLTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"<a href=\"https://pleroma.gov/tags/cofe\" rel=\"tag\">#cofe</a> https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140",
- "content_type" => "text/html"
+ content_type: "text/html"
})
object = Object.normalize(activity)
@@ -228,5 +227,15 @@ defmodule Pleroma.HTMLTest do
assert url == "https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140"
end
+
+ test "does not crash when there is an HTML entity in a link" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "\"http://cofe.com/?boomer=ok&foo=bar\""})
+
+ object = Object.normalize(activity)
+
+ assert {:ok, nil} = HTML.extract_first_external_url(object, object.data["content"])
+ end
end
end
diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs
new file mode 100644
index 000000000..2e961826e
--- /dev/null
+++ b/test/http/adapter_helper/gun_test.exs
@@ -0,0 +1,258 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.AdapterHelper.GunTest do
+ use ExUnit.Case, async: true
+ use Pleroma.Tests.Helpers
+
+ import Mox
+
+ alias Pleroma.Config
+ alias Pleroma.Gun.Conn
+ alias Pleroma.HTTP.AdapterHelper.Gun
+ alias Pleroma.Pool.Connections
+
+ setup :verify_on_exit!
+
+ defp gun_mock(_) do
+ gun_mock()
+ :ok
+ end
+
+ defp gun_mock do
+ Pleroma.GunMock
+ |> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end)
+ |> stub(:await_up, fn _, _ -> {:ok, :http} end)
+ |> stub(:set_owner, fn _, _ -> :ok end)
+ end
+
+ describe "options/1" do
+ setup do: clear_config([:http, :adapter], a: 1, b: 2)
+
+ test "https url with default port" do
+ uri = URI.parse("https://example.com")
+
+ opts = Gun.options([receive_conn: false], uri)
+ assert opts[:certificates_verification]
+ assert opts[:tls_opts][:log_level] == :warning
+ end
+
+ test "https ipv4 with default port" do
+ uri = URI.parse("https://127.0.0.1")
+
+ opts = Gun.options([receive_conn: false], uri)
+ assert opts[:certificates_verification]
+ assert opts[:tls_opts][:log_level] == :warning
+ end
+
+ test "https ipv6 with default port" do
+ uri = URI.parse("https://[2a03:2880:f10c:83:face:b00c:0:25de]")
+
+ opts = Gun.options([receive_conn: false], uri)
+ assert opts[:certificates_verification]
+ assert opts[:tls_opts][:log_level] == :warning
+ end
+
+ test "https url with non standart port" do
+ uri = URI.parse("https://example.com:115")
+
+ opts = Gun.options([receive_conn: false], uri)
+
+ assert opts[:certificates_verification]
+ end
+
+ test "get conn on next request" do
+ gun_mock()
+ level = Application.get_env(:logger, :level)
+ Logger.configure(level: :debug)
+ on_exit(fn -> Logger.configure(level: level) end)
+ uri = URI.parse("http://some-domain2.com")
+
+ opts = Gun.options(uri)
+
+ assert opts[:conn] == nil
+ assert opts[:close_conn] == nil
+
+ Process.sleep(50)
+ opts = Gun.options(uri)
+
+ assert is_pid(opts[:conn])
+ assert opts[:close_conn] == false
+ end
+
+ test "merges with defaul http adapter config" do
+ defaults = Gun.options([receive_conn: false], URI.parse("https://example.com"))
+ assert Keyword.has_key?(defaults, :a)
+ assert Keyword.has_key?(defaults, :b)
+ end
+
+ test "default ssl adapter opts with connection" do
+ gun_mock()
+ uri = URI.parse("https://some-domain.com")
+
+ :ok = Conn.open(uri, :gun_connections)
+
+ opts = Gun.options(uri)
+
+ assert opts[:certificates_verification]
+ refute opts[:tls_opts] == []
+
+ assert opts[:close_conn] == false
+ assert is_pid(opts[:conn])
+ end
+
+ test "parses string proxy host & port" do
+ proxy = Config.get([:http, :proxy_url])
+ Config.put([:http, :proxy_url], "localhost:8123")
+ on_exit(fn -> Config.put([:http, :proxy_url], proxy) end)
+
+ uri = URI.parse("https://some-domain.com")
+ opts = Gun.options([receive_conn: false], uri)
+ assert opts[:proxy] == {'localhost', 8123}
+ end
+
+ test "parses tuple proxy scheme host and port" do
+ proxy = Config.get([:http, :proxy_url])
+ Config.put([:http, :proxy_url], {:socks, 'localhost', 1234})
+ on_exit(fn -> Config.put([:http, :proxy_url], proxy) end)
+
+ uri = URI.parse("https://some-domain.com")
+ opts = Gun.options([receive_conn: false], uri)
+ assert opts[:proxy] == {:socks, 'localhost', 1234}
+ end
+
+ test "passed opts have more weight than defaults" do
+ proxy = Config.get([:http, :proxy_url])
+ Config.put([:http, :proxy_url], {:socks5, 'localhost', 1234})
+ on_exit(fn -> Config.put([:http, :proxy_url], proxy) end)
+ uri = URI.parse("https://some-domain.com")
+ opts = Gun.options([receive_conn: false, proxy: {'example.com', 4321}], uri)
+
+ assert opts[:proxy] == {'example.com', 4321}
+ end
+ end
+
+ describe "options/1 with receive_conn parameter" do
+ setup :gun_mock
+
+ test "receive conn by default" do
+ uri = URI.parse("http://another-domain.com")
+ :ok = Conn.open(uri, :gun_connections)
+
+ received_opts = Gun.options(uri)
+ assert received_opts[:close_conn] == false
+ assert is_pid(received_opts[:conn])
+ end
+
+ test "don't receive conn if receive_conn is false" do
+ uri = URI.parse("http://another-domain.com")
+ :ok = Conn.open(uri, :gun_connections)
+
+ opts = [receive_conn: false]
+ received_opts = Gun.options(opts, uri)
+ assert received_opts[:close_conn] == nil
+ assert received_opts[:conn] == nil
+ end
+ end
+
+ describe "after_request/1" do
+ setup :gun_mock
+
+ test "body_as not chunks" do
+ uri = URI.parse("http://some-domain.com")
+ :ok = Conn.open(uri, :gun_connections)
+ opts = Gun.options(uri)
+ :ok = Gun.after_request(opts)
+ conn = opts[:conn]
+
+ assert %Connections{
+ conns: %{
+ "http:some-domain.com:80" => %Pleroma.Gun.Conn{
+ conn: ^conn,
+ conn_state: :idle,
+ used_by: []
+ }
+ }
+ } = Connections.get_state(:gun_connections)
+ end
+
+ test "body_as chunks" do
+ uri = URI.parse("http://some-domain.com")
+ :ok = Conn.open(uri, :gun_connections)
+ opts = Gun.options([body_as: :chunks], uri)
+ :ok = Gun.after_request(opts)
+ conn = opts[:conn]
+ self = self()
+
+ assert %Connections{
+ conns: %{
+ "http:some-domain.com:80" => %Pleroma.Gun.Conn{
+ conn: ^conn,
+ conn_state: :active,
+ used_by: [{^self, _}]
+ }
+ }
+ } = Connections.get_state(:gun_connections)
+ end
+
+ test "with no connection" do
+ uri = URI.parse("http://uniq-domain.com")
+
+ :ok = Conn.open(uri, :gun_connections)
+
+ opts = Gun.options([body_as: :chunks], uri)
+ conn = opts[:conn]
+ opts = Keyword.delete(opts, :conn)
+ self = self()
+
+ :ok = Gun.after_request(opts)
+
+ assert %Connections{
+ conns: %{
+ "http:uniq-domain.com:80" => %Pleroma.Gun.Conn{
+ conn: ^conn,
+ conn_state: :active,
+ used_by: [{^self, _}]
+ }
+ }
+ } = Connections.get_state(:gun_connections)
+ end
+
+ test "with ipv4" do
+ uri = URI.parse("http://127.0.0.1")
+ :ok = Conn.open(uri, :gun_connections)
+ opts = Gun.options(uri)
+ :ok = Gun.after_request(opts)
+ conn = opts[:conn]
+
+ assert %Connections{
+ conns: %{
+ "http:127.0.0.1:80" => %Pleroma.Gun.Conn{
+ conn: ^conn,
+ conn_state: :idle,
+ used_by: []
+ }
+ }
+ } = Connections.get_state(:gun_connections)
+ end
+
+ test "with ipv6" do
+ uri = URI.parse("http://[2a03:2880:f10c:83:face:b00c:0:25de]")
+ :ok = Conn.open(uri, :gun_connections)
+ opts = Gun.options(uri)
+ :ok = Gun.after_request(opts)
+ conn = opts[:conn]
+
+ assert %Connections{
+ conns: %{
+ "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Pleroma.Gun.Conn{
+ conn: ^conn,
+ conn_state: :idle,
+ used_by: []
+ }
+ }
+ } = Connections.get_state(:gun_connections)
+ end
+ end
+end
diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs
new file mode 100644
index 000000000..3f7e708e0
--- /dev/null
+++ b/test/http/adapter_helper/hackney_test.exs
@@ -0,0 +1,47 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
+ use ExUnit.Case, async: true
+ use Pleroma.Tests.Helpers
+
+ alias Pleroma.HTTP.AdapterHelper.Hackney
+
+ setup_all do
+ uri = URI.parse("http://domain.com")
+ {:ok, uri: uri}
+ end
+
+ describe "options/2" do
+ setup do: clear_config([:http, :adapter], a: 1, b: 2)
+
+ test "add proxy and opts from config", %{uri: uri} do
+ opts = Hackney.options([proxy: "localhost:8123"], uri)
+
+ assert opts[:a] == 1
+ assert opts[:b] == 2
+ assert opts[:proxy] == "localhost:8123"
+ end
+
+ test "respect connection opts and no proxy", %{uri: uri} do
+ opts = Hackney.options([a: 2, b: 1], uri)
+
+ assert opts[:a] == 2
+ assert opts[:b] == 1
+ refute Keyword.has_key?(opts, :proxy)
+ end
+
+ test "add opts for https" do
+ uri = URI.parse("https://domain.com")
+
+ opts = Hackney.options(uri)
+
+ assert opts[:ssl_options] == [
+ partial_chain: &:hackney_connect.partial_chain/1,
+ versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"],
+ server_name_indication: 'domain.com'
+ ]
+ end
+ end
+end
diff --git a/test/http/adapter_helper_test.exs b/test/http/adapter_helper_test.exs
new file mode 100644
index 000000000..24d501ad5
--- /dev/null
+++ b/test/http/adapter_helper_test.exs
@@ -0,0 +1,28 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.AdapterHelperTest do
+ use ExUnit.Case, async: true
+
+ alias Pleroma.HTTP.AdapterHelper
+
+ describe "format_proxy/1" do
+ test "with nil" do
+ assert AdapterHelper.format_proxy(nil) == nil
+ end
+
+ test "with string" do
+ assert AdapterHelper.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123}
+ end
+
+ test "localhost with port" do
+ assert AdapterHelper.format_proxy("localhost:8123") == {'localhost', 8123}
+ end
+
+ test "tuple" do
+ assert AdapterHelper.format_proxy({:socks4, :localhost, 9050}) ==
+ {:socks4, 'localhost', 9050}
+ end
+ end
+end
diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs
new file mode 100644
index 000000000..7c94a50b2
--- /dev/null
+++ b/test/http/connection_test.exs
@@ -0,0 +1,135 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.ConnectionTest do
+ use ExUnit.Case
+ use Pleroma.Tests.Helpers
+
+ import ExUnit.CaptureLog
+
+ alias Pleroma.Config
+ alias Pleroma.HTTP.Connection
+
+ describe "parse_host/1" do
+ test "as atom to charlist" do
+ assert Connection.parse_host(:localhost) == 'localhost'
+ end
+
+ test "as string to charlist" do
+ assert Connection.parse_host("localhost.com") == 'localhost.com'
+ end
+
+ test "as string ip to tuple" do
+ assert Connection.parse_host("127.0.0.1") == {127, 0, 0, 1}
+ end
+ end
+
+ describe "parse_proxy/1" do
+ test "ip with port" do
+ assert Connection.parse_proxy("127.0.0.1:8123") == {:ok, {127, 0, 0, 1}, 8123}
+ end
+
+ test "host with port" do
+ assert Connection.parse_proxy("localhost:8123") == {:ok, 'localhost', 8123}
+ end
+
+ test "as tuple" do
+ assert Connection.parse_proxy({:socks4, :localhost, 9050}) ==
+ {:ok, :socks4, 'localhost', 9050}
+ end
+
+ test "as tuple with string host" do
+ assert Connection.parse_proxy({:socks5, "localhost", 9050}) ==
+ {:ok, :socks5, 'localhost', 9050}
+ end
+ end
+
+ describe "parse_proxy/1 errors" do
+ test "ip without port" do
+ capture_log(fn ->
+ assert Connection.parse_proxy("127.0.0.1") == {:error, :invalid_proxy}
+ end) =~ "parsing proxy fail \"127.0.0.1\""
+ end
+
+ test "host without port" do
+ capture_log(fn ->
+ assert Connection.parse_proxy("localhost") == {:error, :invalid_proxy}
+ end) =~ "parsing proxy fail \"localhost\""
+ end
+
+ test "host with bad port" do
+ capture_log(fn ->
+ assert Connection.parse_proxy("localhost:port") == {:error, :invalid_proxy_port}
+ end) =~ "parsing port in proxy fail \"localhost:port\""
+ end
+
+ test "ip with bad port" do
+ capture_log(fn ->
+ assert Connection.parse_proxy("127.0.0.1:15.9") == {:error, :invalid_proxy_port}
+ end) =~ "parsing port in proxy fail \"127.0.0.1:15.9\""
+ end
+
+ test "as tuple without port" do
+ capture_log(fn ->
+ assert Connection.parse_proxy({:socks5, :localhost}) == {:error, :invalid_proxy}
+ end) =~ "parsing proxy fail {:socks5, :localhost}"
+ end
+
+ test "with nil" do
+ assert Connection.parse_proxy(nil) == nil
+ end
+ end
+
+ describe "options/3" do
+ setup do: clear_config([:http, :proxy_url])
+
+ test "without proxy_url in config" do
+ Config.delete([:http, :proxy_url])
+
+ opts = Connection.options(%URI{})
+ refute Keyword.has_key?(opts, :proxy)
+ end
+
+ test "parses string proxy host & port" do
+ Config.put([:http, :proxy_url], "localhost:8123")
+
+ opts = Connection.options(%URI{})
+ assert opts[:proxy] == {'localhost', 8123}
+ end
+
+ test "parses tuple proxy scheme host and port" do
+ Config.put([:http, :proxy_url], {:socks, 'localhost', 1234})
+
+ opts = Connection.options(%URI{})
+ assert opts[:proxy] == {:socks, 'localhost', 1234}
+ end
+
+ test "passed opts have more weight than defaults" do
+ Config.put([:http, :proxy_url], {:socks5, 'localhost', 1234})
+
+ opts = Connection.options(%URI{}, proxy: {'example.com', 4321})
+
+ assert opts[:proxy] == {'example.com', 4321}
+ end
+ end
+
+ describe "format_host/1" do
+ test "with domain" do
+ assert Connection.format_host("example.com") == 'example.com'
+ end
+
+ test "with idna domain" do
+ assert Connection.format_host("ですexample.com") == 'xn--example-183fne.com'
+ end
+
+ test "with ipv4" do
+ assert Connection.format_host("127.0.0.1") == '127.0.0.1'
+ end
+
+ test "with ipv6" do
+ assert Connection.format_host("2a03:2880:f10c:83:face:b00c:0:25de") ==
+ '2a03:2880:f10c:83:face:b00c:0:25de'
+ end
+ end
+end
diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs
index 170ca916f..fab909905 100644
--- a/test/http/request_builder_test.exs
+++ b/test/http/request_builder_test.exs
@@ -1,48 +1,40 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.RequestBuilderTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case
use Pleroma.Tests.Helpers
+ alias Pleroma.HTTP.Request
alias Pleroma.HTTP.RequestBuilder
describe "headers/2" do
- clear_config([:http, :send_user_agent])
-
test "don't send pleroma user agent" do
- assert RequestBuilder.headers(%{}, []) == %{headers: []}
+ assert RequestBuilder.headers(%Request{}, []) == %Request{headers: []}
end
test "send pleroma user agent" do
- Pleroma.Config.put([:http, :send_user_agent], true)
+ clear_config([:http, :send_user_agent], true)
+ clear_config([:http, :user_agent], :default)
- assert RequestBuilder.headers(%{}, []) == %{
- headers: [{"User-Agent", Pleroma.Application.user_agent()}]
+ assert RequestBuilder.headers(%Request{}, []) == %Request{
+ headers: [{"user-agent", Pleroma.Application.user_agent()}]
}
end
- end
- describe "add_optional_params/3" do
- test "don't add if keyword is empty" do
- assert RequestBuilder.add_optional_params(%{}, %{}, []) == %{}
- end
+ test "send custom user agent" do
+ clear_config([:http, :send_user_agent], true)
+ clear_config([:http, :user_agent], "totally-not-pleroma")
- test "add query parameter" do
- assert RequestBuilder.add_optional_params(
- %{},
- %{query: :query, body: :body, another: :val},
- [
- {:query, "param1=val1&param2=val2"},
- {:body, "some body"}
- ]
- ) == %{query: "param1=val1&param2=val2", body: "some body"}
+ assert RequestBuilder.headers(%Request{}, []) == %Request{
+ headers: [{"user-agent", "totally-not-pleroma"}]
+ }
end
end
describe "add_param/4" do
test "add file parameter" do
- %{
+ %Request{
body: %Tesla.Multipart{
boundary: _,
content_type_params: [],
@@ -59,7 +51,7 @@ defmodule Pleroma.HTTP.RequestBuilderTest do
}
]
}
- } = RequestBuilder.add_param(%{}, :file, "filename.png", "some-path/filename.png")
+ } = RequestBuilder.add_param(%Request{}, :file, "filename.png", "some-path/filename.png")
end
test "add key to body" do
@@ -71,7 +63,7 @@ defmodule Pleroma.HTTP.RequestBuilderTest do
%Tesla.Multipart.Part{
body: "\"someval\"",
dispositions: [name: "somekey"],
- headers: ["Content-Type": "application/json"]
+ headers: [{"content-type", "application/json"}]
}
]
}
diff --git a/test/http_test.exs b/test/http_test.exs
index 5f9522cf0..618485b55 100644
--- a/test/http_test.exs
+++ b/test/http_test.exs
@@ -1,10 +1,12 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTPTest do
- use Pleroma.DataCase
+ use ExUnit.Case, async: true
+ use Pleroma.Tests.Helpers
import Tesla.Mock
+ alias Pleroma.HTTP
setup do
mock(fn
@@ -27,7 +29,7 @@ defmodule Pleroma.HTTPTest do
describe "get/1" do
test "returns successfully result" do
- assert Pleroma.HTTP.get("http://example.com/hello") == {
+ assert HTTP.get("http://example.com/hello") == {
:ok,
%Tesla.Env{status: 200, body: "hello"}
}
@@ -36,7 +38,7 @@ defmodule Pleroma.HTTPTest do
describe "get/2 (with headers)" do
test "returns successfully result for json content-type" do
- assert Pleroma.HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) ==
+ assert HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) ==
{
:ok,
%Tesla.Env{
@@ -50,7 +52,7 @@ defmodule Pleroma.HTTPTest do
describe "post/2" do
test "returns successfully result" do
- assert Pleroma.HTTP.post("http://example.com/world", "") == {
+ assert HTTP.post("http://example.com/world", "") == {
:ok,
%Tesla.Env{status: 200, body: "world"}
}
diff --git a/test/instance_static/add/shortcode.png b/test/instance_static/add/shortcode.png
new file mode 100644
index 000000000..8f50fa023
--- /dev/null
+++ b/test/instance_static/add/shortcode.png
Binary files differ
diff --git a/test/instance_static/emoji/pack_bad_sha/blank.png b/test/instance_static/emoji/pack_bad_sha/blank.png
new file mode 100644
index 000000000..8f50fa023
--- /dev/null
+++ b/test/instance_static/emoji/pack_bad_sha/blank.png
Binary files differ
diff --git a/test/instance_static/emoji/pack_bad_sha/pack.json b/test/instance_static/emoji/pack_bad_sha/pack.json
new file mode 100644
index 000000000..35caf4298
--- /dev/null
+++ b/test/instance_static/emoji/pack_bad_sha/pack.json
@@ -0,0 +1,13 @@
+{
+ "pack": {
+ "license": "Test license",
+ "homepage": "https://pleroma.social",
+ "description": "Test description",
+ "can-download": true,
+ "share-files": true,
+ "download-sha256": "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238"
+ },
+ "files": {
+ "blank": "blank.png"
+ }
+} \ No newline at end of file
diff --git a/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip b/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip
new file mode 100644
index 000000000..148446c64
--- /dev/null
+++ b/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip
Binary files differ
diff --git a/test/instance_static/emoji/test_pack/pack.json b/test/instance_static/emoji/test_pack/pack.json
index 5a8ee75f9..481891b08 100644
--- a/test/instance_static/emoji/test_pack/pack.json
+++ b/test/instance_static/emoji/test_pack/pack.json
@@ -1,13 +1,11 @@
{
+ "files": {
+ "blank": "blank.png"
+ },
"pack": {
- "license": "Test license",
- "homepage": "https://pleroma.social",
"description": "Test description",
-
+ "homepage": "https://pleroma.social",
+ "license": "Test license",
"share-files": true
- },
-
- "files": {
- "blank": "blank.png"
}
-}
+} \ No newline at end of file
diff --git a/test/instance_static/emoji/test_pack_nonshared/pack.json b/test/instance_static/emoji/test_pack_nonshared/pack.json
index b96781f81..93d643a5f 100644
--- a/test/instance_static/emoji/test_pack_nonshared/pack.json
+++ b/test/instance_static/emoji/test_pack_nonshared/pack.json
@@ -3,14 +3,11 @@
"license": "Test license",
"homepage": "https://pleroma.social",
"description": "Test description",
-
"fallback-src": "https://nonshared-pack",
"fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF",
-
"share-files": false
},
-
"files": {
"blank": "blank.png"
}
-}
+} \ No newline at end of file
diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs
index 63fce07bb..ea17e9feb 100644
--- a/test/integration/mastodon_websocket_test.exs
+++ b/test/integration/mastodon_websocket_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Integration.MastodonWebsocketTest do
@@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth
+ @moduletag needs_streamer: true, capture_log: true
+
@path Pleroma.Web.Endpoint.url()
|> URI.parse()
|> Map.put(:scheme, "ws")
|> Map.put(:path, "/api/v1/streaming")
|> URI.to_string()
- setup_all do
- start_supervised(Pleroma.Web.Streamer.supervisor())
- :ok
- end
-
def start_socket(qs \\ nil, headers \\ []) do
path =
case qs do
@@ -35,7 +32,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
test "refuses invalid requests" do
capture_log(fn ->
- assert {:error, {400, _}} = start_socket()
+ assert {:error, {404, _}} = start_socket()
assert {:error, {404, _}} = start_socket("?stream=ncjdk")
Process.sleep(30)
end)
@@ -43,8 +40,8 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
test "requires authentication and a valid token for protected streams" do
capture_log(fn ->
- assert {:error, {403, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa")
- assert {:error, {403, _}} = start_socket("?stream=user")
+ assert {:error, {401, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa")
+ assert {:error, {401, _}} = start_socket("?stream=user")
Process.sleep(30)
end)
end
@@ -58,7 +55,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
test "receives well formatted events" do
user = insert(:user)
{:ok, _} = start_socket("?stream=public")
- {:ok, activity} = CommonAPI.post(user, %{"status" => "nice echo chamber"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "nice echo chamber"})
assert_receive {:text, raw_json}, 1_000
assert {:ok, json} = Jason.decode(raw_json)
@@ -103,7 +100,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
assert capture_log(fn ->
- assert {:error, {403, "Forbidden"}} = start_socket("?stream=user")
+ assert {:error, {401, _}} = start_socket("?stream=user")
Process.sleep(30)
end) =~ ":badarg"
end
@@ -112,7 +109,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
assert capture_log(fn ->
- assert {:error, {403, "Forbidden"}} = start_socket("?stream=user:notification")
+ assert {:error, {401, _}} = start_socket("?stream=user:notification")
Process.sleep(30)
end) =~ ":badarg"
end
@@ -121,7 +118,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}])
assert capture_log(fn ->
- assert {:error, {403, "Forbidden"}} =
+ assert {:error, {401, _}} =
start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}])
Process.sleep(30)
diff --git a/test/job_queue_monitor_test.exs b/test/job_queue_monitor_test.exs
index 17c6f3246..65c1e9f29 100644
--- a/test/job_queue_monitor_test.exs
+++ b/test/job_queue_monitor_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.JobQueueMonitorTest do
diff --git a/test/keys_test.exs b/test/keys_test.exs
index 059f70b74..9e8528cba 100644
--- a/test/keys_test.exs
+++ b/test/keys_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.KeysTest do
diff --git a/test/list_test.exs b/test/list_test.exs
index e7b23915b..b5572cbae 100644
--- a/test/list_test.exs
+++ b/test/list_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ListTest do
diff --git a/test/marker_test.exs b/test/marker_test.exs
index 04bd67fe6..5b6d0b4a4 100644
--- a/test/marker_test.exs
+++ b/test/marker_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MarkerTest do
@@ -8,12 +8,39 @@ defmodule Pleroma.MarkerTest do
import Pleroma.Factory
+ describe "multi_set_unread_count/3" do
+ test "returns multi" do
+ user = insert(:user)
+
+ assert %Ecto.Multi{
+ operations: [marker: {:run, _}, counters: {:run, _}]
+ } =
+ Marker.multi_set_last_read_id(
+ Ecto.Multi.new(),
+ user,
+ "notifications"
+ )
+ end
+
+ test "return empty multi" do
+ user = insert(:user)
+ multi = Ecto.Multi.new()
+ assert Marker.multi_set_last_read_id(multi, user, "home") == multi
+ end
+ end
+
describe "get_markers/2" do
test "returns user markers" do
user = insert(:user)
marker = insert(:marker, user: user)
+ insert(:notification, user: user)
+ insert(:notification, user: user)
insert(:marker, timeline: "home", user: user)
- assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)]
+
+ assert Marker.get_markers(
+ user,
+ ["notifications"]
+ ) == [%Marker{refresh_record(marker) | unread_count: 2}]
end
end
diff --git a/test/mfa/backup_codes_test.exs b/test/mfa/backup_codes_test.exs
new file mode 100644
index 000000000..7bc01b36b
--- /dev/null
+++ b/test/mfa/backup_codes_test.exs
@@ -0,0 +1,11 @@
+defmodule Pleroma.MFA.BackupCodesTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.MFA.BackupCodes
+
+ test "generate backup codes" do
+ codes = BackupCodes.generate(number_of_codes: 2, length: 4)
+
+ assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes
+ end
+end
diff --git a/test/mfa/totp_test.exs b/test/mfa/totp_test.exs
new file mode 100644
index 000000000..50153d208
--- /dev/null
+++ b/test/mfa/totp_test.exs
@@ -0,0 +1,17 @@
+defmodule Pleroma.MFA.TOTPTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.MFA.TOTP
+
+ test "create provisioning_uri to generate qrcode" do
+ uri =
+ TOTP.provisioning_uri("test-secrcet", "test@example.com",
+ issuer: "Plerome-42",
+ digits: 8,
+ period: 60
+ )
+
+ assert uri ==
+ "otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet"
+ end
+end
diff --git a/test/mfa_test.exs b/test/mfa_test.exs
new file mode 100644
index 000000000..8875cefd9
--- /dev/null
+++ b/test/mfa_test.exs
@@ -0,0 +1,52 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.MFATest do
+ use Pleroma.DataCase
+
+ import Pleroma.Factory
+ alias Pleroma.MFA
+
+ describe "mfa_settings" do
+ test "returns settings user's" do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true}
+ }
+ )
+
+ settings = MFA.mfa_settings(user)
+ assert match?(^settings, %{enabled: true, totp: true})
+ end
+ end
+
+ describe "generate backup codes" do
+ test "returns backup codes" do
+ user = insert(:user)
+
+ {:ok, [code1, code2]} = MFA.generate_backup_codes(user)
+ updated_user = refresh_record(user)
+ [hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes
+ assert Pbkdf2.verify_pass(code1, hash1)
+ assert Pbkdf2.verify_pass(code2, hash2)
+ end
+ end
+
+ describe "invalidate_backup_code" do
+ test "invalid used code" do
+ user = insert(:user)
+
+ {:ok, _} = MFA.generate_backup_codes(user)
+ user = refresh_record(user)
+ assert length(user.multi_factor_authentication_settings.backup_codes) == 2
+ [hash_code | _] = user.multi_factor_authentication_settings.backup_codes
+
+ {:ok, user} = MFA.invalidate_backup_code(user, hash_code)
+
+ assert length(user.multi_factor_authentication_settings.backup_codes) == 1
+ end
+ end
+end
diff --git a/test/moderation_log_test.exs b/test/moderation_log_test.exs
index 81c0fef12..59f4d67f8 100644
--- a/test/moderation_log_test.exs
+++ b/test/moderation_log_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ModerationLogTest do
@@ -12,8 +12,8 @@ defmodule Pleroma.ModerationLogTest do
describe "user moderation" do
setup do
- admin = insert(:user, info: %{is_admin: true})
- moderator = insert(:user, info: %{is_moderator: true})
+ admin = insert(:user, is_admin: true)
+ moderator = insert(:user, is_moderator: true)
subject1 = insert(:user)
subject2 = insert(:user)
@@ -214,7 +214,7 @@ defmodule Pleroma.ModerationLogTest do
{:ok, _} =
ModerationLog.insert_log(%{
actor: moderator,
- action: "report_response",
+ action: "report_note",
subject: report,
text: "look at this"
})
@@ -222,7 +222,7 @@ defmodule Pleroma.ModerationLogTest do
log = Repo.one(ModerationLog)
assert log.data["message"] ==
- "@#{moderator.nickname} responded with 'look at this' to report ##{report.id}"
+ "@#{moderator.nickname} added note 'look at this' to report ##{report.id}"
end
test "logging status sensitivity update", %{moderator: moderator} do
diff --git a/test/notification_test.exs b/test/notification_test.exs
index 96316f8dd..111ff09f4 100644
--- a/test/notification_test.exs
+++ b/test/notification_test.exs
@@ -1,20 +1,38 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.NotificationTest do
use Pleroma.DataCase
import Pleroma.Factory
+ import Mock
+ alias Pleroma.FollowingRelationship
alias Pleroma.Notification
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.NotificationView
+ alias Pleroma.Web.Push
alias Pleroma.Web.Streamer
describe "create_notifications" do
+ test "creates a notification for an emoji reaction" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "yeah"})
+ {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+ {:ok, [notification]} = Notification.create_notifications(activity)
+
+ assert notification.user_id == user.id
+ end
+
test "notifies someone when they are directly addressed" do
user = insert(:user)
other_user = insert(:user)
@@ -22,7 +40,7 @@ defmodule Pleroma.NotificationTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname} and @#{third_user.nickname}"
+ status: "hey @#{other_user.nickname} and @#{third_user.nickname}"
})
{:ok, [notification, other_notification]} = Notification.create_notifications(activity)
@@ -31,6 +49,9 @@ defmodule Pleroma.NotificationTest do
assert notified_ids == [other_user.id, third_user.id]
assert notification.activity_id == activity.id
assert other_notification.activity_id == activity.id
+
+ assert [%Pleroma.Marker{unread_count: 2}] =
+ Pleroma.Marker.get_markers(other_user, ["notifications"])
end
test "it creates a notification for subscribed users" do
@@ -39,7 +60,7 @@ defmodule Pleroma.NotificationTest do
User.subscribe(subscriber, user)
- {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"})
+ {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"})
{:ok, [notification]} = Notification.create_notifications(status)
assert notification.user_id == subscriber.id
@@ -52,12 +73,12 @@ defmodule Pleroma.NotificationTest do
User.subscribe(subscriber, other_user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
{:ok, _reply_activity} =
CommonAPI.post(other_user, %{
- "status" => "test reply",
- "in_reply_to_status_id" => activity.id
+ status: "test reply",
+ in_reply_to_status_id: activity.id
})
user_notifications = Notification.for_user(user)
@@ -68,18 +89,96 @@ defmodule Pleroma.NotificationTest do
end
end
+ describe "CommonApi.post/2 notification-related functionality" do
+ test_with_mock "creates but does NOT send notification to blocker user",
+ Push,
+ [:passthrough],
+ [] do
+ user = insert(:user)
+ blocker = insert(:user)
+ {:ok, _user_relationship} = User.block(blocker, user)
+
+ {:ok, _activity} = CommonAPI.post(user, %{status: "hey @#{blocker.nickname}!"})
+
+ blocker_id = blocker.id
+ assert [%Notification{user_id: ^blocker_id}] = Repo.all(Notification)
+ refute called(Push.send(:_))
+ end
+
+ test_with_mock "creates but does NOT send notification to notification-muter user",
+ Push,
+ [:passthrough],
+ [] do
+ user = insert(:user)
+ muter = insert(:user)
+ {:ok, _user_relationships} = User.mute(muter, user)
+
+ {:ok, _activity} = CommonAPI.post(user, %{status: "hey @#{muter.nickname}!"})
+
+ muter_id = muter.id
+ assert [%Notification{user_id: ^muter_id}] = Repo.all(Notification)
+ refute called(Push.send(:_))
+ end
+
+ test_with_mock "creates but does NOT send notification to thread-muter user",
+ Push,
+ [:passthrough],
+ [] do
+ user = insert(:user)
+ thread_muter = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{thread_muter.nickname}!"})
+
+ {:ok, _} = CommonAPI.add_mute(thread_muter, activity)
+
+ {:ok, _same_context_activity} =
+ CommonAPI.post(user, %{
+ status: "hey-hey-hey @#{thread_muter.nickname}!",
+ in_reply_to_status_id: activity.id
+ })
+
+ [pre_mute_notification, post_mute_notification] =
+ Repo.all(from(n in Notification, where: n.user_id == ^thread_muter.id, order_by: n.id))
+
+ pre_mute_notification_id = pre_mute_notification.id
+ post_mute_notification_id = post_mute_notification.id
+
+ assert called(
+ Push.send(
+ :meck.is(fn
+ %Notification{id: ^pre_mute_notification_id} -> true
+ _ -> false
+ end)
+ )
+ )
+
+ refute called(
+ Push.send(
+ :meck.is(fn
+ %Notification{id: ^post_mute_notification_id} -> true
+ _ -> false
+ end)
+ )
+ )
+ end
+ end
+
describe "create_notification" do
@tag needs_streamer: true
test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do
user = insert(:user)
- task = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
- task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
- Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}})
- Streamer.add_socket(
- "user:notification",
- %{transport_pid: task_user_notification.pid, assigns: %{user: user}}
- )
+ task =
+ Task.async(fn ->
+ Streamer.get_topic_and_add_socket("user", user)
+ assert_receive {:render_with_user, _, _, _}, 4_000
+ end)
+
+ task_user_notification =
+ Task.async(fn ->
+ Streamer.get_topic_and_add_socket("user:notification", user)
+ assert_receive {:render_with_user, _, _, _}, 4_000
+ end)
activity = insert(:note_activity)
@@ -93,17 +192,17 @@ defmodule Pleroma.NotificationTest do
activity = insert(:note_activity)
author = User.get_cached_by_ap_id(activity.data["actor"])
user = insert(:user)
- {:ok, user} = User.block(user, author)
+ {:ok, _user_relationship} = User.block(user, author)
assert Notification.create_notification(activity, user)
end
- test "it creates a notificatin for the user if the user mutes the activity author" do
+ test "it creates a notification for the user if the user mutes the activity author" do
muter = insert(:user)
muted = insert(:user)
{:ok, _} = User.mute(muter, muted)
muter = Repo.get(User, muter.id)
- {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"})
+ {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"})
assert Notification.create_notification(activity, muter)
end
@@ -112,9 +211,9 @@ defmodule Pleroma.NotificationTest do
muter = insert(:user)
muted = insert(:user)
- {:ok, muter} = User.mute(muter, muted, false)
+ {:ok, _user_relationships} = User.mute(muter, muted, false)
- {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"})
+ {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"})
assert Notification.create_notification(activity, muter)
end
@@ -122,13 +221,13 @@ defmodule Pleroma.NotificationTest do
test "it creates a notification for an activity from a muted thread" do
muter = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(muter, %{"status" => "hey"})
+ {:ok, activity} = CommonAPI.post(muter, %{status: "hey"})
CommonAPI.add_mute(muter, activity)
{:ok, activity} =
CommonAPI.post(other_user, %{
- "status" => "Hi @#{muter.nickname}",
- "in_reply_to_status_id" => activity.id
+ status: "Hi @#{muter.nickname}",
+ in_reply_to_status_id: activity.id
})
assert Notification.create_notification(activity, muter)
@@ -136,32 +235,44 @@ defmodule Pleroma.NotificationTest do
test "it disables notifications from followers" do
follower = insert(:user)
- followed = insert(:user, info: %{notification_settings: %{"followers" => false}})
+
+ followed =
+ insert(:user, notification_settings: %Pleroma.User.NotificationSetting{followers: false})
+
User.follow(follower, followed)
- {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"})
+ {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"})
refute Notification.create_notification(activity, followed)
end
test "it disables notifications from non-followers" do
follower = insert(:user)
- followed = insert(:user, info: %{notification_settings: %{"non_followers" => false}})
- {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"})
+
+ followed =
+ insert(:user,
+ notification_settings: %Pleroma.User.NotificationSetting{non_followers: false}
+ )
+
+ {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"})
refute Notification.create_notification(activity, followed)
end
test "it disables notifications from people the user follows" do
- follower = insert(:user, info: %{notification_settings: %{"follows" => false}})
+ follower =
+ insert(:user, notification_settings: %Pleroma.User.NotificationSetting{follows: false})
+
followed = insert(:user)
User.follow(follower, followed)
follower = Repo.get(User, follower.id)
- {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"})
+ {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"})
refute Notification.create_notification(activity, follower)
end
test "it disables notifications from people the user does not follow" do
- follower = insert(:user, info: %{notification_settings: %{"non_follows" => false}})
+ follower =
+ insert(:user, notification_settings: %Pleroma.User.NotificationSetting{non_follows: false})
+
followed = insert(:user)
- {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"})
+ {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"})
refute Notification.create_notification(activity, follower)
end
@@ -172,23 +283,13 @@ defmodule Pleroma.NotificationTest do
refute Notification.create_notification(activity, author)
end
- test "it doesn't create a notification for follow-unfollow-follow chains" do
- user = insert(:user)
- followed_user = insert(:user)
- {:ok, _, _, activity} = CommonAPI.follow(user, followed_user)
- Notification.create_notification(activity, followed_user)
- CommonAPI.unfollow(user, followed_user)
- {:ok, _, _, activity_dupe} = CommonAPI.follow(user, followed_user)
- refute Notification.create_notification(activity_dupe, followed_user)
- end
-
test "it doesn't create duplicate notifications for follow+subscribed users" do
user = insert(:user)
subscriber = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(subscriber, user)
User.subscribe(subscriber, user)
- {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"})
+ {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"})
{:ok, [_notif]} = Notification.create_notifications(status)
end
@@ -198,18 +299,78 @@ defmodule Pleroma.NotificationTest do
User.subscribe(subscriber, user)
- {:ok, status} = CommonAPI.post(user, %{"status" => "inwisible", "visibility" => "direct"})
+ {:ok, status} = CommonAPI.post(user, %{status: "inwisible", visibility: "direct"})
assert {:ok, []} == Notification.create_notifications(status)
end
end
+ describe "follow / follow_request notifications" do
+ test "it creates `follow` notification for approved Follow activity" do
+ user = insert(:user)
+ followed_user = insert(:user, locked: false)
+
+ {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user)
+ assert FollowingRelationship.following?(user, followed_user)
+ assert [notification] = Notification.for_user(followed_user)
+
+ assert %{type: "follow"} =
+ NotificationView.render("show.json", %{
+ notification: notification,
+ for: followed_user
+ })
+ end
+
+ test "it creates `follow_request` notification for pending Follow activity" do
+ user = insert(:user)
+ followed_user = insert(:user, locked: true)
+
+ {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user)
+ refute FollowingRelationship.following?(user, followed_user)
+ assert [notification] = Notification.for_user(followed_user)
+
+ render_opts = %{notification: notification, for: followed_user}
+ assert %{type: "follow_request"} = NotificationView.render("show.json", render_opts)
+
+ # After request is accepted, the same notification is rendered with type "follow":
+ assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user)
+
+ notification_id = notification.id
+ assert [%{id: ^notification_id}] = Notification.for_user(followed_user)
+ assert %{type: "follow"} = NotificationView.render("show.json", render_opts)
+ end
+
+ test "it doesn't create a notification for follow-unfollow-follow chains" do
+ user = insert(:user)
+ followed_user = insert(:user, locked: false)
+
+ {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user)
+ assert FollowingRelationship.following?(user, followed_user)
+ assert [notification] = Notification.for_user(followed_user)
+
+ CommonAPI.unfollow(user, followed_user)
+ {:ok, _, _, _activity_dupe} = CommonAPI.follow(user, followed_user)
+
+ notification_id = notification.id
+ assert [%{id: ^notification_id}] = Notification.for_user(followed_user)
+ end
+
+ test "dismisses the notification on follow request rejection" do
+ user = insert(:user, locked: true)
+ follower = insert(:user)
+ {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, user)
+ assert [notification] = Notification.for_user(user)
+ {:ok, _follower} = CommonAPI.reject_follow_request(follower, user)
+ assert [] = Notification.for_user(user)
+ end
+ end
+
describe "get notification" do
test "it gets a notification that belongs to the user" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
{:ok, notification} = Notification.get(other_user, notification.id)
@@ -221,7 +382,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
{:error, _notification} = Notification.get(user, notification.id)
@@ -233,7 +394,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
{:ok, notification} = Notification.dismiss(other_user, notification.id)
@@ -245,7 +406,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
{:error, _notification} = Notification.dismiss(user, notification.id)
@@ -260,14 +421,14 @@ defmodule Pleroma.NotificationTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname} and @#{third_user.nickname} !"
+ status: "hey @#{other_user.nickname} and @#{third_user.nickname} !"
})
{:ok, _notifs} = Notification.create_notifications(activity)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "hey again @#{other_user.nickname} and @#{third_user.nickname} !"
+ status: "hey again @#{other_user.nickname} and @#{third_user.nickname} !"
})
{:ok, _notifs} = Notification.create_notifications(activity)
@@ -285,12 +446,12 @@ defmodule Pleroma.NotificationTest do
{:ok, _activity} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname}!"
+ status: "hey @#{other_user.nickname}!"
})
{:ok, _activity} =
CommonAPI.post(user, %{
- "status" => "hey again @#{other_user.nickname}!"
+ status: "hey again @#{other_user.nickname}!"
})
[n2, n1] = notifs = Notification.for_user(other_user)
@@ -300,7 +461,7 @@ defmodule Pleroma.NotificationTest do
{:ok, _activity} =
CommonAPI.post(user, %{
- "status" => "hey yet again @#{other_user.nickname}!"
+ status: "hey yet again @#{other_user.nickname}!"
})
Notification.set_read_up_to(other_user, n2.id)
@@ -310,6 +471,16 @@ defmodule Pleroma.NotificationTest do
assert n1.seen == true
assert n2.seen == true
assert n3.seen == false
+
+ assert %Pleroma.Marker{} =
+ m =
+ Pleroma.Repo.get_by(
+ Pleroma.Marker,
+ user_id: other_user.id,
+ timeline: "notifications"
+ )
+
+ assert m.last_read_id == to_string(n2.id)
end
end
@@ -329,7 +500,7 @@ defmodule Pleroma.NotificationTest do
Enum.each(0..10, fn i ->
{:ok, _activity} =
CommonAPI.post(user1, %{
- "status" => "hey ##{i} @#{user2.nickname}!"
+ status: "hey ##{i} @#{user2.nickname}!"
})
end)
@@ -358,17 +529,19 @@ defmodule Pleroma.NotificationTest do
end
end
- describe "notification target determination" do
+ describe "notification target determination / get_notified_from_activity/2" do
test "it sends notifications to addressed users in new messages" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname}!"
+ status: "hey @#{other_user.nickname}!"
})
- assert other_user in Notification.get_notified_from_activity(activity)
+ {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert other_user in enabled_receivers
end
test "it sends notifications to mentioned users in new messages" do
@@ -396,7 +569,9 @@ defmodule Pleroma.NotificationTest do
{:ok, activity} = Transmogrifier.handle_incoming(create_activity)
- assert other_user in Notification.get_notified_from_activity(activity)
+ {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert other_user in enabled_receivers
end
test "it does not send notifications to users who are only cc in new messages" do
@@ -418,7 +593,9 @@ defmodule Pleroma.NotificationTest do
{:ok, activity} = Transmogrifier.handle_incoming(create_activity)
- assert other_user not in Notification.get_notified_from_activity(activity)
+ {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert other_user not in enabled_receivers
end
test "it does not send notification to mentioned users in likes" do
@@ -428,12 +605,37 @@ defmodule Pleroma.NotificationTest do
{:ok, activity_one} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname}!"
+ status: "hey @#{other_user.nickname}!"
+ })
+
+ {:ok, activity_two} = CommonAPI.favorite(third_user, activity_one.id)
+
+ {enabled_receivers, _disabled_receivers} =
+ Notification.get_notified_from_activity(activity_two)
+
+ assert other_user not in enabled_receivers
+ end
+
+ test "it only notifies the post's author in likes" do
+ user = insert(:user)
+ other_user = insert(:user)
+ third_user = insert(:user)
+
+ {:ok, activity_one} =
+ CommonAPI.post(user, %{
+ status: "hey @#{other_user.nickname}!"
})
- {:ok, activity_two, _} = CommonAPI.favorite(activity_one.id, third_user)
+ {:ok, like_data, _} = Builder.like(third_user, activity_one.object)
- assert other_user not in Notification.get_notified_from_activity(activity_two)
+ {:ok, like, _} =
+ like_data
+ |> Map.put("to", [other_user.ap_id | like_data["to"]])
+ |> ActivityPub.persist(local: true)
+
+ {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(like)
+
+ assert other_user not in enabled_receivers
end
test "it does not send notification to mentioned users in announces" do
@@ -443,12 +645,93 @@ defmodule Pleroma.NotificationTest do
{:ok, activity_one} =
CommonAPI.post(user, %{
- "status" => "hey @#{other_user.nickname}!"
+ status: "hey @#{other_user.nickname}!"
})
{:ok, activity_two, _} = CommonAPI.repeat(activity_one.id, third_user)
- assert other_user not in Notification.get_notified_from_activity(activity_two)
+ {enabled_receivers, _disabled_receivers} =
+ Notification.get_notified_from_activity(activity_two)
+
+ assert other_user not in enabled_receivers
+ end
+
+ test "it returns blocking recipient in disabled recipients list" do
+ user = insert(:user)
+ other_user = insert(:user)
+ {:ok, _user_relationship} = User.block(other_user, user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"})
+
+ {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert [] == enabled_receivers
+ assert [other_user] == disabled_receivers
+ end
+
+ test "it returns notification-muting recipient in disabled recipients list" do
+ user = insert(:user)
+ other_user = insert(:user)
+ {:ok, _user_relationships} = User.mute(other_user, user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"})
+
+ {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert [] == enabled_receivers
+ assert [other_user] == disabled_receivers
+ end
+
+ test "it returns thread-muting recipient in disabled recipients list" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"})
+
+ {:ok, _} = CommonAPI.add_mute(other_user, activity)
+
+ {:ok, same_context_activity} =
+ CommonAPI.post(user, %{
+ status: "hey-hey-hey @#{other_user.nickname}!",
+ in_reply_to_status_id: activity.id
+ })
+
+ {enabled_receivers, disabled_receivers} =
+ Notification.get_notified_from_activity(same_context_activity)
+
+ assert [other_user] == disabled_receivers
+ refute other_user in enabled_receivers
+ end
+
+ test "it returns non-following domain-blocking recipient in disabled recipients list" do
+ blocked_domain = "blocked.domain"
+ user = insert(:user, %{ap_id: "https://#{blocked_domain}/@actor"})
+ other_user = insert(:user)
+
+ {:ok, other_user} = User.block_domain(other_user, blocked_domain)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"})
+
+ {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert [] == enabled_receivers
+ assert [other_user] == disabled_receivers
+ end
+
+ test "it returns following domain-blocking recipient in enabled recipients list" do
+ blocked_domain = "blocked.domain"
+ user = insert(:user, %{ap_id: "https://#{blocked_domain}/@actor"})
+ other_user = insert(:user)
+
+ {:ok, other_user} = User.block_domain(other_user, blocked_domain)
+ {:ok, other_user} = User.follow(other_user, user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"})
+
+ {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity)
+
+ assert [other_user] == enabled_receivers
+ assert [] == disabled_receivers
end
end
@@ -457,11 +740,11 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
assert length(Notification.for_user(user)) == 1
@@ -474,15 +757,15 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
assert length(Notification.for_user(user)) == 1
- {:ok, _, _, _} = CommonAPI.unfavorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.unfavorite(activity.id, other_user)
assert Enum.empty?(Notification.for_user(user))
end
@@ -491,7 +774,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
@@ -508,7 +791,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
@@ -516,7 +799,7 @@ defmodule Pleroma.NotificationTest do
assert length(Notification.for_user(user)) == 1
- {:ok, _, _} = CommonAPI.unrepeat(activity.id, other_user)
+ {:ok, _} = CommonAPI.unrepeat(activity.id, other_user)
assert Enum.empty?(Notification.for_user(user))
end
@@ -525,7 +808,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
@@ -533,7 +816,7 @@ defmodule Pleroma.NotificationTest do
assert Enum.empty?(Notification.for_user(user))
- {:error, _} = CommonAPI.favorite(activity.id, other_user)
+ {:error, :not_found} = CommonAPI.favorite(other_user, activity.id)
assert Enum.empty?(Notification.for_user(user))
end
@@ -542,7 +825,7 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
assert Enum.empty?(Notification.for_user(user))
@@ -559,13 +842,13 @@ defmodule Pleroma.NotificationTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
{:ok, _deletion_activity} = CommonAPI.delete(activity.id, user)
{:ok, _reply_activity} =
CommonAPI.post(other_user, %{
- "status" => "test reply",
- "in_reply_to_status_id" => activity.id
+ status: "test reply",
+ in_reply_to_status_id: activity.id
})
assert Enum.empty?(Notification.for_user(user))
@@ -576,7 +859,7 @@ defmodule Pleroma.NotificationTest do
other_user = insert(:user)
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "hi @#{other_user.nickname}", visibility: "direct"})
refute Enum.empty?(Notification.for_user(other_user))
@@ -625,20 +908,69 @@ defmodule Pleroma.NotificationTest do
"object" => remote_user.ap_id
}
+ remote_user_url = remote_user.ap_id
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^remote_user_url} ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
{:ok, _delete_activity} = Transmogrifier.handle_incoming(delete_user_message)
ObanHelpers.perform_all()
assert Enum.empty?(Notification.for_user(local_user))
end
+
+ @tag capture_log: true
+ test "move activity generates a notification" do
+ %{ap_id: old_ap_id} = old_user = insert(:user)
+ %{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
+ follower = insert(:user)
+ other_follower = insert(:user, %{allow_following_move: false})
+
+ User.follow(follower, old_user)
+ User.follow(other_follower, old_user)
+
+ old_user_url = old_user.ap_id
+
+ body =
+ File.read!("test/fixtures/users_mock/localhost.json")
+ |> String.replace("{{nickname}}", old_user.nickname)
+ |> Jason.encode!()
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^old_user_url} ->
+ %Tesla.Env{status: 200, body: body}
+ end)
+
+ Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
+ ObanHelpers.perform_all()
+
+ assert [
+ %{
+ activity: %{
+ data: %{"type" => "Move", "actor" => ^old_ap_id, "target" => ^new_ap_id}
+ }
+ }
+ ] = Notification.for_user(follower)
+
+ assert [
+ %{
+ activity: %{
+ data: %{"type" => "Move", "actor" => ^old_ap_id, "target" => ^new_ap_id}
+ }
+ }
+ ] = Notification.for_user(other_follower)
+ end
end
describe "for_user" do
test "it returns notifications for muted user without notifications" do
user = insert(:user)
muted = insert(:user)
- {:ok, user} = User.mute(user, muted, false)
+ {:ok, _user_relationships} = User.mute(user, muted, false)
- {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"})
assert length(Notification.for_user(user)) == 1
end
@@ -646,9 +978,9 @@ defmodule Pleroma.NotificationTest do
test "it doesn't return notifications for muted user with notifications" do
user = insert(:user)
muted = insert(:user)
- {:ok, user} = User.mute(user, muted)
+ {:ok, _user_relationships} = User.mute(user, muted)
- {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"})
assert Notification.for_user(user) == []
end
@@ -656,28 +988,40 @@ defmodule Pleroma.NotificationTest do
test "it doesn't return notifications for blocked user" do
user = insert(:user)
blocked = insert(:user)
- {:ok, user} = User.block(user, blocked)
+ {:ok, _user_relationship} = User.block(user, blocked)
- {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"})
assert Notification.for_user(user) == []
end
- test "it doesn't return notificatitons for blocked domain" do
+ test "it doesn't return notifications for domain-blocked non-followed user" do
user = insert(:user)
blocked = insert(:user, ap_id: "http://some-domain.com")
{:ok, user} = User.block_domain(user, "some-domain.com")
- {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"})
assert Notification.for_user(user) == []
end
+ test "it returns notifications for domain-blocked but followed user" do
+ user = insert(:user)
+ blocked = insert(:user, ap_id: "http://some-domain.com")
+
+ {:ok, user} = User.block_domain(user, "some-domain.com")
+ {:ok, _} = User.follow(user, blocked)
+
+ {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"})
+
+ assert length(Notification.for_user(user)) == 1
+ end
+
test "it doesn't return notifications for muted thread" do
user = insert(:user)
another_user = insert(:user)
- {:ok, activity} = CommonAPI.post(another_user, %{"status" => "hey @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"})
{:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"])
assert Notification.for_user(user) == []
@@ -686,9 +1030,9 @@ defmodule Pleroma.NotificationTest do
test "it returns notifications from a muted user when with_muted is set" do
user = insert(:user)
muted = insert(:user)
- {:ok, user} = User.mute(user, muted)
+ {:ok, _user_relationships} = User.mute(user, muted)
- {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"})
assert length(Notification.for_user(user, %{with_muted: true})) == 1
end
@@ -696,28 +1040,29 @@ defmodule Pleroma.NotificationTest do
test "it doesn't return notifications from a blocked user when with_muted is set" do
user = insert(:user)
blocked = insert(:user)
- {:ok, user} = User.block(user, blocked)
+ {:ok, _user_relationship} = User.block(user, blocked)
- {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"})
- assert length(Notification.for_user(user, %{with_muted: true})) == 0
+ assert Enum.empty?(Notification.for_user(user, %{with_muted: true}))
end
- test "it doesn't return notifications from a domain-blocked user when with_muted is set" do
+ test "when with_muted is set, " <>
+ "it doesn't return notifications from a domain-blocked non-followed user" do
user = insert(:user)
blocked = insert(:user, ap_id: "http://some-domain.com")
{:ok, user} = User.block_domain(user, "some-domain.com")
- {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"})
- assert length(Notification.for_user(user, %{with_muted: true})) == 0
+ assert Enum.empty?(Notification.for_user(user, %{with_muted: true}))
end
test "it returns notifications from muted threads when with_muted is set" do
user = insert(:user)
another_user = insert(:user)
- {:ok, activity} = CommonAPI.post(another_user, %{"status" => "hey @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"})
{:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"])
assert length(Notification.for_user(user, %{with_muted: true})) == 1
diff --git a/test/object/containment_test.exs b/test/object/containment_test.exs
index 0dc2728b9..90b6dccf2 100644
--- a/test/object/containment_test.exs
+++ b/test/object/containment_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Object.ContainmentTest do
@@ -17,6 +17,16 @@ defmodule Pleroma.Object.ContainmentTest do
end
describe "general origin containment" do
+ test "works for completely actorless posts" do
+ assert :error ==
+ Containment.contain_origin("https://glaceon.social/users/monorail", %{
+ "deleted" => "2019-10-30T05:48:50.249606Z",
+ "formerType" => "Note",
+ "id" => "https://glaceon.social/users/monorail/statuses/103049757364029187",
+ "type" => "Tombstone"
+ })
+ end
+
test "contain_origin_from_id() catches obvious spoofing attempts" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"
@@ -67,6 +77,20 @@ defmodule Pleroma.Object.ContainmentTest do
end) =~
"[error] Could not decode user at fetch https://n1u.moe/users/rye"
end
+
+ test "contain_origin_from_id() gracefully handles cases where no ID is present" do
+ data = %{
+ "type" => "Create",
+ "object" => %{
+ "id" => "http://example.net/~alyssa/activities/1234",
+ "attributedTo" => "http://example.org/~alyssa"
+ },
+ "actor" => "http://example.com/~bob"
+ }
+
+ :error =
+ Containment.contain_origin_from_id("http://example.net/~alyssa/activities/1234", data)
+ end
end
describe "containment of children" do
diff --git a/test/object/fetcher_test.exs b/test/object/fetcher_test.exs
index 9ae6b015d..c06e91f12 100644
--- a/test/object/fetcher_test.exs
+++ b/test/object/fetcher_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Object.FetcherTest do
@@ -26,6 +26,30 @@ defmodule Pleroma.Object.FetcherTest do
:ok
end
+ describe "max thread distance restriction" do
+ @ap_id "http://mastodon.example.org/@admin/99541947525187367"
+ setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
+
+ test "it returns thread depth exceeded error if thread depth is exceeded" do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+ assert {:error, "Max thread distance exceeded."} =
+ Fetcher.fetch_object_from_id(@ap_id, depth: 1)
+ end
+
+ test "it fetches object if max thread depth is restricted to 0 and depth is not specified" do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+ assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id)
+ end
+
+ test "it fetches object if requested depth does not exceed max thread depth" do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10)
+
+ assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id, depth: 10)
+ end
+ end
+
describe "actor origin containment" do
test "it rejects objects with a bogus origin" do
{:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json")
@@ -77,6 +101,15 @@ defmodule Pleroma.Object.FetcherTest do
assert object
end
+ test "it can fetch Mobilizon events" do
+ {:ok, object} =
+ Fetcher.fetch_object_from_id(
+ "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"
+ )
+
+ assert object
+ end
+
test "it can fetch wedistribute articles" do
{:ok, object} =
Fetcher.fetch_object_from_id("https://wedistribute.org/wp-json/pterotype/v1/object/85810")
@@ -126,7 +159,7 @@ defmodule Pleroma.Object.FetcherTest do
end
describe "signed fetches" do
- clear_config([:activitypub, :sign_object_fetches])
+ setup do: clear_config([:activitypub, :sign_object_fetches])
test_with_mock "it signs fetches when configured to do so",
Pleroma.Signature,
diff --git a/test/object_test.exs b/test/object_test.exs
index dd228c32f..198d3b1cf 100644
--- a/test/object_test.exs
+++ b/test/object_test.exs
@@ -1,15 +1,17 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ObjectTest do
use Pleroma.DataCase
+ use Oban.Testing, repo: Pleroma.Repo
import ExUnit.CaptureLog
import Pleroma.Factory
import Tesla.Mock
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Repo
+ alias Pleroma.Tests.ObanHelpers
alias Pleroma.Web.CommonAPI
setup do
@@ -71,6 +73,188 @@ defmodule Pleroma.ObjectTest do
end
end
+ describe "delete attachments" do
+ setup do: clear_config([Pleroma.Upload])
+ setup do: clear_config([:instance, :cleanup_attachments])
+
+ test "Disabled via config" do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([:instance, :cleanup_attachments], false)
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ user = insert(:user)
+
+ {:ok, %Object{} = attachment} =
+ Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+ %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+ note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+ uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+ path = href |> Path.dirname() |> Path.basename()
+
+ assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+
+ Object.delete(note)
+
+ ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
+
+ assert Object.get_by_id(note.id).data["deleted"]
+ refute Object.get_by_id(attachment.id) == nil
+
+ assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+ end
+
+ test "in subdirectories" do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([:instance, :cleanup_attachments], true)
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ user = insert(:user)
+
+ {:ok, %Object{} = attachment} =
+ Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+ %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+ note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+ uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+ path = href |> Path.dirname() |> Path.basename()
+
+ assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+
+ Object.delete(note)
+
+ ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
+
+ assert Object.get_by_id(note.id).data["deleted"]
+ assert Object.get_by_id(attachment.id) == nil
+
+ assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
+ end
+
+ test "with dedupe enabled" do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([Pleroma.Upload, :filters], [Pleroma.Upload.Filter.Dedupe])
+ Pleroma.Config.put([:instance, :cleanup_attachments], true)
+
+ uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+ File.mkdir_p!(uploads_dir)
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ user = insert(:user)
+
+ {:ok, %Object{} = attachment} =
+ Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+ %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+ note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+ filename = Path.basename(href)
+
+ assert {:ok, files} = File.ls(uploads_dir)
+ assert filename in files
+
+ Object.delete(note)
+
+ ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
+
+ assert Object.get_by_id(note.id).data["deleted"]
+ assert Object.get_by_id(attachment.id) == nil
+ assert {:ok, files} = File.ls(uploads_dir)
+ refute filename in files
+ end
+
+ test "with objects that have legacy data.url attribute" do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([:instance, :cleanup_attachments], true)
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ user = insert(:user)
+
+ {:ok, %Object{} = attachment} =
+ Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+ {:ok, %Object{}} = Object.create(%{url: "https://google.com", actor: user.ap_id})
+
+ %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+ note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+ uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+ path = href |> Path.dirname() |> Path.basename()
+
+ assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+
+ Object.delete(note)
+
+ ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
+
+ assert Object.get_by_id(note.id).data["deleted"]
+ assert Object.get_by_id(attachment.id) == nil
+
+ assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
+ end
+
+ test "With custom base_url" do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([Pleroma.Upload, :base_url], "https://sub.domain.tld/dir/")
+ Pleroma.Config.put([:instance, :cleanup_attachments], true)
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ user = insert(:user)
+
+ {:ok, %Object{} = attachment} =
+ Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
+
+ %{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
+ note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
+
+ uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
+
+ path = href |> Path.dirname() |> Path.basename()
+
+ assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
+
+ Object.delete(note)
+
+ ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
+
+ assert Object.get_by_id(note.id).data["deleted"]
+ assert Object.get_by_id(attachment.id) == nil
+
+ assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
+ end
+ end
+
describe "normalizer" do
test "fetches unknown objects by default" do
%Object{} =
@@ -124,6 +308,8 @@ defmodule Pleroma.ObjectTest do
%Object{} =
object = Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d")
+ Object.set_cache(object)
+
assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
@@ -133,6 +319,8 @@ defmodule Pleroma.ObjectTest do
})
updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
+ object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
+ assert updated_object == object_in_cache
assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8
assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3
end
@@ -141,6 +329,8 @@ defmodule Pleroma.ObjectTest do
%Object{} =
object = Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d")
+ Object.set_cache(object)
+
assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
@@ -148,6 +338,8 @@ defmodule Pleroma.ObjectTest do
mock_modified.(%Tesla.Env{status: 404, body: ""})
updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
+ object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
+ assert updated_object == object_in_cache
assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0
end) =~
@@ -160,6 +352,8 @@ defmodule Pleroma.ObjectTest do
%Object{} =
object = Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d")
+ Object.set_cache(object)
+
assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
@@ -169,6 +363,8 @@ defmodule Pleroma.ObjectTest do
})
updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: 100)
+ object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
+ assert updated_object == object_in_cache
assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0
end
@@ -177,12 +373,15 @@ defmodule Pleroma.ObjectTest do
%Object{} =
object = Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d")
+ Object.set_cache(object)
+
assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
user = insert(:user)
activity = Activity.get_create_by_object_ap_id(object.data["id"])
- {:ok, _activity, object} = CommonAPI.favorite(activity.id, user)
+ {:ok, activity} = CommonAPI.favorite(user, activity.id)
+ object = Object.get_by_ap_id(activity.data["object"])
assert object.data["like_count"] == 1
@@ -192,6 +391,8 @@ defmodule Pleroma.ObjectTest do
})
updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
+ object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
+ assert updated_object == object_in_cache
assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8
assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3
diff --git a/test/otp_version_test.exs b/test/otp_version_test.exs
new file mode 100644
index 000000000..7d2538ec8
--- /dev/null
+++ b/test/otp_version_test.exs
@@ -0,0 +1,42 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.OTPVersionTest do
+ use ExUnit.Case, async: true
+
+ alias Pleroma.OTPVersion
+
+ describe "check/1" do
+ test "22.4" do
+ assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.4"]) ==
+ "22.4"
+ end
+
+ test "22.1" do
+ assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.1"]) ==
+ "22.1"
+ end
+
+ test "21.1" do
+ assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/21.1"]) ==
+ "21.1"
+ end
+
+ test "23.0" do
+ assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/23.0"]) ==
+ "23.0"
+ end
+
+ test "with non existance file" do
+ assert OTPVersion.get_version_from_files([
+ "test/fixtures/warnings/otp_version/non-exising",
+ "test/fixtures/warnings/otp_version/22.4"
+ ]) == "22.4"
+ end
+
+ test "empty paths" do
+ assert OTPVersion.get_version_from_files([]) == nil
+ end
+ end
+end
diff --git a/test/pagination_test.exs b/test/pagination_test.exs
index c0fbe7933..d5b1b782d 100644
--- a/test/pagination_test.exs
+++ b/test/pagination_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.PaginationTest do
diff --git a/test/plugs/admin_secret_authentication_plug_test.exs b/test/plugs/admin_secret_authentication_plug_test.exs
index e1d4b391f..100016c62 100644
--- a/test/plugs/admin_secret_authentication_plug_test.exs
+++ b/test/plugs/admin_secret_authentication_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.AdminSecretAuthenticationPlugTest do
@@ -22,21 +22,41 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlugTest do
assert conn == ret_conn
end
- test "with secret set and given in the 'admin_token' parameter, it assigns an admin user", %{
- conn: conn
- } do
- Pleroma.Config.put(:admin_token, "password123")
+ describe "when secret set it assigns an admin user" do
+ setup do: clear_config([:admin_token])
- conn =
- %{conn | params: %{"admin_token" => "wrong_password"}}
- |> AdminSecretAuthenticationPlug.call(%{})
+ test "with `admin_token` query parameter", %{conn: conn} do
+ Pleroma.Config.put(:admin_token, "password123")
- refute conn.assigns[:user]
+ conn =
+ %{conn | params: %{"admin_token" => "wrong_password"}}
+ |> AdminSecretAuthenticationPlug.call(%{})
- conn =
- %{conn | params: %{"admin_token" => "password123"}}
- |> AdminSecretAuthenticationPlug.call(%{})
+ refute conn.assigns[:user]
+
+ conn =
+ %{conn | params: %{"admin_token" => "password123"}}
+ |> AdminSecretAuthenticationPlug.call(%{})
+
+ assert conn.assigns[:user].is_admin
+ end
+
+ test "with `x-admin-token` HTTP header", %{conn: conn} do
+ Pleroma.Config.put(:admin_token, "☕️")
+
+ conn =
+ conn
+ |> put_req_header("x-admin-token", "🥛")
+ |> AdminSecretAuthenticationPlug.call(%{})
+
+ refute conn.assigns[:user]
+
+ conn =
+ conn
+ |> put_req_header("x-admin-token", "☕️")
+ |> AdminSecretAuthenticationPlug.call(%{})
- assert conn.assigns[:user].info.is_admin
+ assert conn.assigns[:user].is_admin
+ end
end
end
diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs
index 9ae4c506f..3c70c1747 100644
--- a/test/plugs/authentication_plug_test.exs
+++ b/test/plugs/authentication_plug_test.exs
@@ -1,20 +1,23 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.AuthenticationPlugTest do
use Pleroma.Web.ConnCase, async: true
alias Pleroma.Plugs.AuthenticationPlug
+ alias Pleroma.Plugs.OAuthScopesPlug
+ alias Pleroma.Plugs.PlugHelper
alias Pleroma.User
import ExUnit.CaptureLog
+ import Pleroma.Factory
setup %{conn: conn} do
user = %User{
id: 1,
name: "dude",
- password_hash: Comeonin.Pbkdf2.hashpwsalt("guy")
+ password_hash: Pbkdf2.hash_pwd_salt("guy")
}
conn =
@@ -36,25 +39,53 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do
assert ret_conn == conn
end
- test "with a correct password in the credentials, it assigns the auth_user", %{conn: conn} do
+ test "with a correct password in the credentials, " <>
+ "it assigns the auth_user and marks OAuthScopesPlug as skipped",
+ %{conn: conn} do
conn =
conn
|> assign(:auth_credentials, %{password: "guy"})
|> AuthenticationPlug.call(%{})
assert conn.assigns.user == conn.assigns.auth_user
+ assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end
- test "with a wrong password in the credentials, it does nothing", %{conn: conn} do
+ test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do
+ user = insert(:user, password_hash: Bcrypt.hash_pwd_salt("123"))
+ assert "$2" <> _ = user.password_hash
+
conn =
conn
- |> assign(:auth_credentials, %{password: "wrong"})
+ |> assign(:auth_user, user)
+ |> assign(:auth_credentials, %{password: "123"})
+ |> AuthenticationPlug.call(%{})
- ret_conn =
+ assert conn.assigns.user.id == conn.assigns.auth_user.id
+ assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
+
+ user = User.get_by_id(user.id)
+ assert "$pbkdf2" <> _ = user.password_hash
+ end
+
+ test "with a crypt hash, it updates to a pkbdf2 hash", %{conn: conn} do
+ user =
+ insert(:user,
+ password_hash:
+ "$6$9psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1"
+ )
+
+ conn =
conn
+ |> assign(:auth_user, user)
+ |> assign(:auth_credentials, %{password: "password"})
|> AuthenticationPlug.call(%{})
- assert conn == ret_conn
+ assert conn.assigns.user.id == conn.assigns.auth_user.id
+ assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
+
+ user = User.get_by_id(user.id)
+ assert "$pbkdf2" <> _ = user.password_hash
end
describe "checkpw/2" do
@@ -74,6 +105,13 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do
assert AuthenticationPlug.checkpw("password", hash)
end
+ test "check bcrypt hash" do
+ hash = "$2a$10$uyhC/R/zoE1ndwwCtMusK.TLVzkQ/Ugsbqp3uXI.CTTz0gBw.24jS"
+
+ assert AuthenticationPlug.checkpw("password", hash)
+ refute AuthenticationPlug.checkpw("password1", hash)
+ end
+
test "it returns false when hash invalid" do
hash =
"psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1"
diff --git a/test/plugs/basic_auth_decoder_plug_test.exs b/test/plugs/basic_auth_decoder_plug_test.exs
index 4d7728e93..a6063d4f6 100644
--- a/test/plugs/basic_auth_decoder_plug_test.exs
+++ b/test/plugs/basic_auth_decoder_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.BasicAuthDecoderPlugTest do
diff --git a/test/plugs/cache_control_test.exs b/test/plugs/cache_control_test.exs
index 69ce6cc7d..6b567e81d 100644
--- a/test/plugs/cache_control_test.exs
+++ b/test/plugs/cache_control_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CacheControlTest do
diff --git a/test/plugs/cache_test.exs b/test/plugs/cache_test.exs
index e6e7f409e..8b231c881 100644
--- a/test/plugs/cache_test.exs
+++ b/test/plugs/cache_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.CacheTest do
diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs
index 37ab5213a..a0667c5e0 100644
--- a/test/plugs/ensure_authenticated_plug_test.exs
+++ b/test/plugs/ensure_authenticated_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
@@ -8,24 +8,89 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do
alias Pleroma.Plugs.EnsureAuthenticatedPlug
alias Pleroma.User
- test "it halts if no user is assigned", %{conn: conn} do
+ describe "without :if_func / :unless_func options" do
+ test "it halts if user is NOT assigned", %{conn: conn} do
+ conn = EnsureAuthenticatedPlug.call(conn, %{})
+
+ assert conn.status == 403
+ assert conn.halted == true
+ end
+
+ test "it continues if a user is assigned", %{conn: conn} do
+ conn = assign(conn, :user, %User{})
+ ret_conn = EnsureAuthenticatedPlug.call(conn, %{})
+
+ refute ret_conn.halted
+ end
+ end
+
+ test "it halts if user is assigned and MFA enabled", %{conn: conn} do
conn =
conn
+ |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}})
+ |> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
assert conn.status == 403
assert conn.halted == true
+
+ assert conn.resp_body ==
+ "{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}"
end
- test "it continues if a user is assigned", %{conn: conn} do
+ test "it continues if user is assigned and MFA disabled", %{conn: conn} do
conn =
conn
- |> assign(:user, %User{})
-
- ret_conn =
- conn
+ |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}})
+ |> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
- assert ret_conn == conn
+ refute conn.status == 403
+ refute conn.halted
+ end
+
+ describe "with :if_func / :unless_func options" do
+ setup do
+ %{
+ true_fn: fn _conn -> true end,
+ false_fn: fn _conn -> false end
+ }
+ end
+
+ test "it continues if a user is assigned", %{conn: conn, true_fn: true_fn, false_fn: false_fn} do
+ conn = assign(conn, :user, %User{})
+ refute EnsureAuthenticatedPlug.call(conn, if_func: true_fn).halted
+ refute EnsureAuthenticatedPlug.call(conn, if_func: false_fn).halted
+ refute EnsureAuthenticatedPlug.call(conn, unless_func: true_fn).halted
+ refute EnsureAuthenticatedPlug.call(conn, unless_func: false_fn).halted
+ end
+
+ test "it continues if a user is NOT assigned but :if_func evaluates to `false`",
+ %{conn: conn, false_fn: false_fn} do
+ ret_conn = EnsureAuthenticatedPlug.call(conn, if_func: false_fn)
+ refute ret_conn.halted
+ end
+
+ test "it continues if a user is NOT assigned but :unless_func evaluates to `true`",
+ %{conn: conn, true_fn: true_fn} do
+ ret_conn = EnsureAuthenticatedPlug.call(conn, unless_func: true_fn)
+ refute ret_conn.halted
+ end
+
+ test "it halts if a user is NOT assigned and :if_func evaluates to `true`",
+ %{conn: conn, true_fn: true_fn} do
+ conn = EnsureAuthenticatedPlug.call(conn, if_func: true_fn)
+
+ assert conn.status == 403
+ assert conn.halted == true
+ end
+
+ test "it halts if a user is NOT assigned and :unless_func evaluates to `false`",
+ %{conn: conn, false_fn: false_fn} do
+ conn = EnsureAuthenticatedPlug.call(conn, unless_func: false_fn)
+
+ assert conn.status == 403
+ assert conn.halted == true
+ end
end
end
diff --git a/test/plugs/ensure_public_or_authenticated_plug_test.exs b/test/plugs/ensure_public_or_authenticated_plug_test.exs
index bae95e150..fc2934369 100644
--- a/test/plugs/ensure_public_or_authenticated_plug_test.exs
+++ b/test/plugs/ensure_public_or_authenticated_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlugTest do
@@ -9,7 +9,7 @@ defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlugTest do
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.User
- clear_config([:instance, :public])
+ setup do: clear_config([:instance, :public])
test "it halts if not public and no user is assigned", %{conn: conn} do
Config.put([:instance, :public], false)
@@ -29,7 +29,7 @@ defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlugTest do
conn
|> EnsurePublicOrAuthenticatedPlug.call(%{})
- assert ret_conn == conn
+ refute ret_conn.halted
end
test "it continues if a user is assigned, even if not public", %{conn: conn} do
@@ -43,6 +43,6 @@ defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlugTest do
conn
|> EnsurePublicOrAuthenticatedPlug.call(%{})
- assert ret_conn == conn
+ refute ret_conn.halted
end
end
diff --git a/test/plugs/ensure_user_key_plug_test.exs b/test/plugs/ensure_user_key_plug_test.exs
index 6a9627f6a..633c05447 100644
--- a/test/plugs/ensure_user_key_plug_test.exs
+++ b/test/plugs/ensure_user_key_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.EnsureUserKeyPlugTest do
diff --git a/test/plugs/http_security_plug_test.exs b/test/plugs/http_security_plug_test.exs
index 9c1c20541..84e4c274f 100644
--- a/test/plugs/http_security_plug_test.exs
+++ b/test/plugs/http_security_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do
@@ -7,8 +7,9 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do
alias Pleroma.Config
alias Plug.Conn
- clear_config([:http_securiy, :enabled])
- clear_config([:http_security, :sts])
+ setup do: clear_config([:http_securiy, :enabled])
+ setup do: clear_config([:http_security, :sts])
+ setup do: clear_config([:http_security, :referrer_policy])
describe "http security enabled" do
setup do
diff --git a/test/plugs/http_signature_plug_test.exs b/test/plugs/http_signature_plug_test.exs
index d8ace36da..e6cbde803 100644
--- a/test/plugs/http_signature_plug_test.exs
+++ b/test/plugs/http_signature_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
alias Pleroma.Web.Plugs.HTTPSignaturePlug
import Plug.Conn
+ import Phoenix.Controller, only: [put_format: 2]
import Mock
test "it call HTTPSignatures to check validity if the actor sighed it" do
@@ -20,10 +21,69 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
+ |> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
+ assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
end
+
+ describe "requires a signature when `authorized_fetch_mode` is enabled" do
+ setup do
+ Pleroma.Config.put([:activitypub, :authorized_fetch_mode], true)
+
+ on_exit(fn ->
+ Pleroma.Config.put([:activitypub, :authorized_fetch_mode], false)
+ end)
+
+ params = %{"actor" => "http://mastodon.example.org/users/admin"}
+ conn = build_conn(:get, "/doesntmattter", params) |> put_format("activity+json")
+
+ [conn: conn]
+ end
+
+ test "when signature header is present", %{conn: conn} do
+ with_mock HTTPSignatures, validate_conn: fn _ -> false end do
+ conn =
+ conn
+ |> put_req_header(
+ "signature",
+ "keyId=\"http://mastodon.example.org/users/admin#main-key"
+ )
+ |> HTTPSignaturePlug.call(%{})
+
+ assert conn.assigns.valid_signature == false
+ assert conn.halted == true
+ assert conn.status == 401
+ assert conn.state == :sent
+ assert conn.resp_body == "Request not signed"
+ assert called(HTTPSignatures.validate_conn(:_))
+ end
+
+ with_mock HTTPSignatures, validate_conn: fn _ -> true end do
+ conn =
+ conn
+ |> put_req_header(
+ "signature",
+ "keyId=\"http://mastodon.example.org/users/admin#main-key"
+ )
+ |> HTTPSignaturePlug.call(%{})
+
+ assert conn.assigns.valid_signature == true
+ assert conn.halted == false
+ assert called(HTTPSignatures.validate_conn(:_))
+ end
+ end
+
+ test "halts the connection when `signature` header is not present", %{conn: conn} do
+ conn = HTTPSignaturePlug.call(conn, %{})
+ assert conn.assigns[:valid_signature] == nil
+ assert conn.halted == true
+ assert conn.status == 401
+ assert conn.state == :sent
+ assert conn.resp_body == "Request not signed"
+ end
+ end
end
diff --git a/test/plugs/idempotency_plug_test.exs b/test/plugs/idempotency_plug_test.exs
index ac1735f13..21fa0fbcf 100644
--- a/test/plugs/idempotency_plug_test.exs
+++ b/test/plugs/idempotency_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.IdempotencyPlugTest do
diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs
index 9b27246fa..b8f070d6a 100644
--- a/test/plugs/instance_static_test.exs
+++ b/test/plugs/instance_static_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RuntimeStaticPlugTest do
@@ -12,9 +12,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do
on_exit(fn -> File.rm_rf(@dir) end)
end
- clear_config([:instance, :static_dir]) do
- Pleroma.Config.put([:instance, :static_dir], @dir)
- end
+ setup do: clear_config([:instance, :static_dir], @dir)
test "overrides index" do
bundled_index = get(build_conn(), "/")
diff --git a/test/plugs/legacy_authentication_plug_test.exs b/test/plugs/legacy_authentication_plug_test.exs
index 568ef5abd..3b8c07627 100644
--- a/test/plugs/legacy_authentication_plug_test.exs
+++ b/test/plugs/legacy_authentication_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do
@@ -8,6 +8,8 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do
import Pleroma.Factory
alias Pleroma.Plugs.LegacyAuthenticationPlug
+ alias Pleroma.Plugs.OAuthScopesPlug
+ alias Pleroma.Plugs.PlugHelper
alias Pleroma.User
setup do
@@ -36,7 +38,8 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do
end
@tag :skip_on_mac
- test "it authenticates the auth_user if present and password is correct and resets the password",
+ test "if `auth_user` is present and password is correct, " <>
+ "it authenticates the user, resets the password, marks OAuthScopesPlug as skipped",
%{
conn: conn,
user: user
@@ -49,6 +52,7 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do
conn = LegacyAuthenticationPlug.call(conn, %{})
assert conn.assigns.user.id == user.id
+ assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end
@tag :skip_on_mac
diff --git a/test/plugs/mapped_identity_to_signature_plug_test.exs b/test/plugs/mapped_identity_to_signature_plug_test.exs
index 6b9d3649d..0ad3c2929 100644
--- a/test/plugs/mapped_identity_to_signature_plug_test.exs
+++ b/test/plugs/mapped_identity_to_signature_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlugTest do
diff --git a/test/plugs/oauth_plug_test.exs b/test/plugs/oauth_plug_test.exs
index dea11cdb0..f74c068cd 100644
--- a/test/plugs/oauth_plug_test.exs
+++ b/test/plugs/oauth_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.OAuthPlugTest do
@@ -38,7 +38,7 @@ defmodule Pleroma.Plugs.OAuthPlugTest do
assert conn.assigns[:user] == opts[:user]
end
- test "with valid token(downcase) in url parameters, it assings the user", opts do
+ test "with valid token(downcase) in url parameters, it assigns the user", opts do
conn =
:get
|> build_conn("/?access_token=#{opts[:token]}")
diff --git a/test/plugs/oauth_scopes_plug_test.exs b/test/plugs/oauth_scopes_plug_test.exs
index be6d1340b..884de7b4d 100644
--- a/test/plugs/oauth_scopes_plug_test.exs
+++ b/test/plugs/oauth_scopes_plug_test.exs
@@ -1,46 +1,25 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.OAuthScopesPlugTest do
use Pleroma.Web.ConnCase, async: true
- alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Repo
import Mock
import Pleroma.Factory
- setup_with_mocks([{EnsurePublicOrAuthenticatedPlug, [], [call: fn conn, _ -> conn end]}]) do
- :ok
- end
-
- describe "when `assigns[:token]` is nil, " do
- test "with :skip_instance_privacy_check option, proceeds with no op", %{conn: conn} do
+ test "is not performed if marked as skipped", %{conn: conn} do
+ with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do
conn =
conn
- |> assign(:user, insert(:user))
- |> OAuthScopesPlug.call(%{scopes: ["read"], skip_instance_privacy_check: true})
+ |> OAuthScopesPlug.skip_plug()
+ |> OAuthScopesPlug.call(%{scopes: ["random_scope"]})
+ refute called(OAuthScopesPlug.perform(:_, :_))
refute conn.halted
- assert conn.assigns[:user]
-
- refute called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
- end
-
- test "without :skip_instance_privacy_check option, calls EnsurePublicOrAuthenticatedPlug", %{
- conn: conn
- } do
- conn =
- conn
- |> assign(:user, insert(:user))
- |> OAuthScopesPlug.call(%{scopes: ["read"]})
-
- refute conn.halted
- assert conn.assigns[:user]
-
- assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end
end
@@ -75,64 +54,27 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
end
describe "with `fallback: :proceed_unauthenticated` option, " do
- test "if `token.scopes` doesn't fulfill specified 'any of' conditions, " <>
- "clears `assigns[:user]` and calls EnsurePublicOrAuthenticatedPlug",
- %{conn: conn} do
- token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
-
- conn =
- conn
- |> assign(:user, token.user)
- |> assign(:token, token)
- |> OAuthScopesPlug.call(%{scopes: ["follow"], fallback: :proceed_unauthenticated})
-
- refute conn.halted
- refute conn.assigns[:user]
-
- assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
- end
-
- test "if `token.scopes` doesn't fulfill specified 'all of' conditions, " <>
- "clears `assigns[:user] and calls EnsurePublicOrAuthenticatedPlug",
- %{conn: conn} do
- token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
-
- conn =
- conn
- |> assign(:user, token.user)
- |> assign(:token, token)
- |> OAuthScopesPlug.call(%{
- scopes: ["read", "follow"],
- op: :&,
- fallback: :proceed_unauthenticated
- })
-
- refute conn.halted
- refute conn.assigns[:user]
-
- assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
- end
-
- test "with :skip_instance_privacy_check option, " <>
- "if `token.scopes` doesn't fulfill specified conditions, " <>
- "clears `assigns[:user]` and does not call EnsurePublicOrAuthenticatedPlug",
+ test "if `token.scopes` doesn't fulfill specified conditions, " <>
+ "clears :user and :token assigns",
%{conn: conn} do
- token = insert(:oauth_token, scopes: ["read:statuses", "write"]) |> Repo.preload(:user)
-
- conn =
- conn
- |> assign(:user, token.user)
- |> assign(:token, token)
- |> OAuthScopesPlug.call(%{
- scopes: ["read"],
- fallback: :proceed_unauthenticated,
- skip_instance_privacy_check: true
- })
-
- refute conn.halted
- refute conn.assigns[:user]
-
- refute called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
+ user = insert(:user)
+ token1 = insert(:oauth_token, scopes: ["read", "write"], user: user)
+
+ for token <- [token1, nil], op <- [:|, :&] do
+ ret_conn =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, token)
+ |> OAuthScopesPlug.call(%{
+ scopes: ["follow"],
+ op: op,
+ fallback: :proceed_unauthenticated
+ })
+
+ refute ret_conn.halted
+ refute ret_conn.assigns[:user]
+ refute ret_conn.assigns[:token]
+ end
end
end
@@ -140,39 +82,42 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
test "if `token.scopes` does not fulfill specified 'any of' conditions, " <>
"returns 403 and halts",
%{conn: conn} do
- token = insert(:oauth_token, scopes: ["read", "write"])
- any_of_scopes = ["follow"]
+ for token <- [insert(:oauth_token, scopes: ["read", "write"]), nil] do
+ any_of_scopes = ["follow", "push"]
- conn =
- conn
- |> assign(:token, token)
- |> OAuthScopesPlug.call(%{scopes: any_of_scopes})
+ ret_conn =
+ conn
+ |> assign(:token, token)
+ |> OAuthScopesPlug.call(%{scopes: any_of_scopes})
- assert conn.halted
- assert 403 == conn.status
+ assert ret_conn.halted
+ assert 403 == ret_conn.status
- expected_error = "Insufficient permissions: #{Enum.join(any_of_scopes, ", ")}."
- assert Jason.encode!(%{error: expected_error}) == conn.resp_body
+ expected_error = "Insufficient permissions: #{Enum.join(any_of_scopes, " | ")}."
+ assert Jason.encode!(%{error: expected_error}) == ret_conn.resp_body
+ end
end
test "if `token.scopes` does not fulfill specified 'all of' conditions, " <>
"returns 403 and halts",
%{conn: conn} do
- token = insert(:oauth_token, scopes: ["read", "write"])
- all_of_scopes = ["write", "follow"]
+ for token <- [insert(:oauth_token, scopes: ["read", "write"]), nil] do
+ token_scopes = (token && token.scopes) || []
+ all_of_scopes = ["write", "follow"]
- conn =
- conn
- |> assign(:token, token)
- |> OAuthScopesPlug.call(%{scopes: all_of_scopes, op: :&})
+ conn =
+ conn
+ |> assign(:token, token)
+ |> OAuthScopesPlug.call(%{scopes: all_of_scopes, op: :&})
- assert conn.halted
- assert 403 == conn.status
+ assert conn.halted
+ assert 403 == conn.status
- expected_error =
- "Insufficient permissions: #{Enum.join(all_of_scopes -- token.scopes, ", ")}."
+ expected_error =
+ "Insufficient permissions: #{Enum.join(all_of_scopes -- token_scopes, " & ")}."
- assert Jason.encode!(%{error: expected_error}) == conn.resp_body
+ assert Jason.encode!(%{error: expected_error}) == conn.resp_body
+ end
end
end
@@ -224,4 +169,42 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
assert f.(["admin:read"], ["write", "admin"]) == ["admin:read"]
end
end
+
+ describe "transform_scopes/2" do
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage])
+
+ setup do
+ {:ok, %{f: &OAuthScopesPlug.transform_scopes/2}}
+ end
+
+ test "with :admin option, prefixes all requested scopes with `admin:` " <>
+ "and [optionally] keeps only prefixed scopes, " <>
+ "depending on `[:auth, :enforce_oauth_admin_scope_usage]` setting",
+ %{f: f} do
+ Pleroma.Config.put([:auth, :enforce_oauth_admin_scope_usage], false)
+
+ assert f.(["read"], %{admin: true}) == ["admin:read", "read"]
+
+ assert f.(["read", "write"], %{admin: true}) == [
+ "admin:read",
+ "read",
+ "admin:write",
+ "write"
+ ]
+
+ Pleroma.Config.put([:auth, :enforce_oauth_admin_scope_usage], true)
+
+ assert f.(["read:accounts"], %{admin: true}) == ["admin:read:accounts"]
+
+ assert f.(["read", "write:reports"], %{admin: true}) == [
+ "admin:read",
+ "admin:write:reports"
+ ]
+ end
+
+ test "with no supported options, returns unmodified scopes", %{f: f} do
+ assert f.(["read"], %{}) == ["read"]
+ assert f.(["read", "write"], %{}) == ["read", "write"]
+ end
+ end
end
diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs
index 395095079..4d3d694f4 100644
--- a/test/plugs/rate_limiter_test.exs
+++ b/test/plugs/rate_limiter_test.exs
@@ -1,174 +1,263 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.RateLimiterTest do
- use ExUnit.Case, async: true
- use Plug.Test
+ use Pleroma.Web.ConnCase
+ alias Phoenix.ConnTest
+ alias Pleroma.Config
alias Pleroma.Plugs.RateLimiter
+ alias Plug.Conn
import Pleroma.Factory
+ import Pleroma.Tests.Helpers, only: [clear_config: 1, clear_config: 2]
# Note: each example must work with separate buckets in order to prevent concurrency issues
+ setup do: clear_config([Pleroma.Web.Endpoint, :http, :ip])
+ setup do: clear_config(:rate_limit)
+
+ describe "config" do
+ @limiter_name :test_init
+ setup do: clear_config([Pleroma.Plugs.RemoteIp, :enabled])
+
+ test "config is required for plug to work" do
+ Config.put([:rate_limit, @limiter_name], {1, 1})
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+
+ assert %{limits: {1, 1}, name: :test_init, opts: [name: :test_init]} ==
+ [name: @limiter_name]
+ |> RateLimiter.init()
+ |> RateLimiter.action_settings()
+
+ assert nil ==
+ [name: :nonexisting_limiter]
+ |> RateLimiter.init()
+ |> RateLimiter.action_settings()
+ end
+ end
- test "init/1" do
- limiter_name = :test_init
- Pleroma.Config.put([:rate_limit, limiter_name], {1, 1})
+ test "it is disabled if it remote ip plug is enabled but no remote ip is found" do
+ assert RateLimiter.disabled?(Conn.assign(build_conn(), :remote_ip_found, false))
+ end
- assert {limiter_name, {1, 1}, []} == RateLimiter.init(limiter_name)
- assert nil == RateLimiter.init(:foo)
+ test "it is enabled if remote ip found" do
+ refute RateLimiter.disabled?(Conn.assign(build_conn(), :remote_ip_found, true))
end
- test "ip/1" do
- assert "127.0.0.1" == RateLimiter.ip(%{remote_ip: {127, 0, 0, 1}})
+ test "it is enabled if remote_ip_found flag doesn't exist" do
+ refute RateLimiter.disabled?(build_conn())
end
- test "it restricts by opts" do
- limiter_name = :test_opts
- scale = 1000
+ test "it restricts based on config values" do
+ limiter_name = :test_plug_opts
+ scale = 80
limit = 5
- Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+ Config.put([:rate_limit, limiter_name], {scale, limit})
- opts = RateLimiter.init(limiter_name)
- conn = conn(:get, "/")
- bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
+ plug_opts = RateLimiter.init(name: limiter_name)
+ conn = build_conn(:get, "/")
- conn = RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ for i <- 1..5 do
+ conn = RateLimiter.call(conn, plug_opts)
+ assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ Process.sleep(10)
+ end
- conn = RateLimiter.call(conn, opts)
- assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ conn = RateLimiter.call(conn, plug_opts)
+ assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests)
+ assert conn.halted
- conn = RateLimiter.call(conn, opts)
- assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ Process.sleep(50)
- conn = RateLimiter.call(conn, opts)
- assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ conn = build_conn(:get, "/")
- conn = RateLimiter.call(conn, opts)
- assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ conn = RateLimiter.call(conn, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
- conn = RateLimiter.call(conn, opts)
+ refute conn.status == Conn.Status.code(:too_many_requests)
+ refute conn.resp_body
+ refute conn.halted
+ end
- assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
- assert conn.halted
+ describe "options" do
+ test "`bucket_name` option overrides default bucket name" do
+ limiter_name = :test_bucket_name
- Process.sleep(to_reset)
+ Config.put([:rate_limit, limiter_name], {1000, 5})
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
- conn = conn(:get, "/")
+ base_bucket_name = "#{limiter_name}:group1"
+ plug_opts = RateLimiter.init(name: limiter_name, bucket_name: base_bucket_name)
- conn = RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ conn = build_conn(:get, "/")
- refute conn.status == Plug.Conn.Status.code(:too_many_requests)
- refute conn.resp_body
- refute conn.halted
- end
+ RateLimiter.call(conn, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, plug_opts)
+ assert {:error, :not_found} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ end
- test "`bucket_name` option overrides default bucket name" do
- limiter_name = :test_bucket_name
- scale = 1000
- limit = 5
+ test "`params` option allows different queries to be tracked independently" do
+ limiter_name = :test_params
+ Config.put([:rate_limit, limiter_name], {1000, 5})
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
- Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
- base_bucket_name = "#{limiter_name}:group1"
- opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name})
+ plug_opts = RateLimiter.init(name: limiter_name, params: ["id"])
- conn = conn(:get, "/")
- default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
- customized_bucket_name = "#{base_bucket_name}:#{RateLimiter.ip(conn)}"
+ conn = build_conn(:get, "/?id=1")
+ conn = Conn.fetch_query_params(conn)
+ conn_2 = build_conn(:get, "/?id=2")
- RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(customized_bucket_name, scale, limit)
- assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
- end
+ RateLimiter.call(conn, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ assert {0, 5} = RateLimiter.inspect_bucket(conn_2, limiter_name, plug_opts)
+ end
- test "`params` option appends specified params' values to bucket name" do
- limiter_name = :test_params
- scale = 1000
- limit = 5
+ test "it supports combination of options modifying bucket name" do
+ limiter_name = :test_options_combo
+ Config.put([:rate_limit, limiter_name], {1000, 5})
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+
+ base_bucket_name = "#{limiter_name}:group1"
- Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
- opts = RateLimiter.init({limiter_name, params: ["id"]})
- id = "1"
+ plug_opts =
+ RateLimiter.init(name: limiter_name, bucket_name: base_bucket_name, params: ["id"])
- conn = conn(:get, "/?id=#{id}")
- conn = Plug.Conn.fetch_query_params(conn)
+ id = "100"
- default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
- parametrized_bucket_name = "#{limiter_name}:#{id}:#{RateLimiter.ip(conn)}"
+ conn = build_conn(:get, "/?id=#{id}")
+ conn = Conn.fetch_query_params(conn)
+ conn_2 = build_conn(:get, "/?id=#{101}")
- RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
- assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+ RateLimiter.call(conn, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, plug_opts)
+ assert {0, 5} = RateLimiter.inspect_bucket(conn_2, base_bucket_name, plug_opts)
+ end
end
- test "it supports combination of options modifying bucket name" do
- limiter_name = :test_options_combo
- scale = 1000
- limit = 5
+ describe "unauthenticated users" do
+ test "are restricted based on remote IP" do
+ limiter_name = :test_unauthenticated
+ Config.put([:rate_limit, limiter_name], [{1000, 5}, {1, 10}])
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+
+ plug_opts = RateLimiter.init(name: limiter_name)
- Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
- base_bucket_name = "#{limiter_name}:group1"
- opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name, params: ["id"]})
- id = "100"
+ conn = %{build_conn(:get, "/") | remote_ip: {127, 0, 0, 2}}
+ conn_2 = %{build_conn(:get, "/") | remote_ip: {127, 0, 0, 3}}
- conn = conn(:get, "/?id=#{id}")
- conn = Plug.Conn.fetch_query_params(conn)
+ for i <- 1..5 do
+ conn = RateLimiter.call(conn, plug_opts)
+ assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ refute conn.halted
+ end
- default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
- parametrized_bucket_name = "#{base_bucket_name}:#{id}:#{RateLimiter.ip(conn)}"
+ conn = RateLimiter.call(conn, plug_opts)
- RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
- assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+ assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests)
+ assert conn.halted
+
+ conn_2 = RateLimiter.call(conn_2, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, plug_opts)
+
+ refute conn_2.status == Conn.Status.code(:too_many_requests)
+ refute conn_2.resp_body
+ refute conn_2.halted
+ end
end
- test "optional limits for authenticated users" do
- limiter_name = :test_authenticated
- Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
+ describe "authenticated users" do
+ setup do
+ Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
- scale = 1000
- limit = 5
- Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {scale, limit}])
+ :ok
+ end
- opts = RateLimiter.init(limiter_name)
+ test "can have limits separate from unauthenticated connections" do
+ limiter_name = :test_authenticated1
- user = insert(:user)
- conn = conn(:get, "/") |> assign(:user, user)
- bucket_name = "#{limiter_name}:#{user.id}"
+ scale = 50
+ limit = 5
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+ Config.put([:rate_limit, limiter_name], [{1000, 1}, {scale, limit}])
- conn = RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ plug_opts = RateLimiter.init(name: limiter_name)
- conn = RateLimiter.call(conn, opts)
- assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ user = insert(:user)
+ conn = build_conn(:get, "/") |> assign(:user, user)
- conn = RateLimiter.call(conn, opts)
- assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ for i <- 1..5 do
+ conn = RateLimiter.call(conn, plug_opts)
+ assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ refute conn.halted
+ end
- conn = RateLimiter.call(conn, opts)
- assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ conn = RateLimiter.call(conn, plug_opts)
- conn = RateLimiter.call(conn, opts)
- assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests)
+ assert conn.halted
+ end
- conn = RateLimiter.call(conn, opts)
+ test "different users are counted independently" do
+ limiter_name = :test_authenticated2
+ Config.put([:rate_limit, limiter_name], [{1, 10}, {1000, 5}])
+ Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
- assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
- assert conn.halted
+ plug_opts = RateLimiter.init(name: limiter_name)
- Process.sleep(to_reset)
+ user = insert(:user)
+ conn = build_conn(:get, "/") |> assign(:user, user)
- conn = conn(:get, "/") |> assign(:user, user)
+ user_2 = insert(:user)
+ conn_2 = build_conn(:get, "/") |> assign(:user, user_2)
- conn = RateLimiter.call(conn, opts)
- assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+ for i <- 1..5 do
+ conn = RateLimiter.call(conn, plug_opts)
+ assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts)
+ end
- refute conn.status == Plug.Conn.Status.code(:too_many_requests)
- refute conn.resp_body
- refute conn.halted
+ conn = RateLimiter.call(conn, plug_opts)
+ assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests)
+ assert conn.halted
+
+ conn_2 = RateLimiter.call(conn_2, plug_opts)
+ assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, plug_opts)
+ refute conn_2.status == Conn.Status.code(:too_many_requests)
+ refute conn_2.resp_body
+ refute conn_2.halted
+ end
+ end
+
+ test "doesn't crash due to a race condition when multiple requests are made at the same time and the bucket is not yet initialized" do
+ limiter_name = :test_race_condition
+ Pleroma.Config.put([:rate_limit, limiter_name], {1000, 5})
+ Pleroma.Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8})
+
+ opts = RateLimiter.init(name: limiter_name)
+
+ conn = build_conn(:get, "/")
+ conn_2 = build_conn(:get, "/")
+
+ %Task{pid: pid1} =
+ task1 =
+ Task.async(fn ->
+ receive do
+ :process2_up ->
+ RateLimiter.call(conn, opts)
+ end
+ end)
+
+ task2 =
+ Task.async(fn ->
+ send(pid1, :process2_up)
+ RateLimiter.call(conn_2, opts)
+ end)
+
+ Task.await(task1)
+ Task.await(task2)
+
+ refute {:err, :not_found} == RateLimiter.inspect_bucket(conn, limiter_name, opts)
end
end
diff --git a/test/plugs/remote_ip_test.exs b/test/plugs/remote_ip_test.exs
index d120c588b..752ab32e7 100644
--- a/test/plugs/remote_ip_test.exs
+++ b/test/plugs/remote_ip_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.RemoteIpTest do
@@ -8,6 +8,9 @@ defmodule Pleroma.Plugs.RemoteIpTest do
alias Pleroma.Plugs.RemoteIp
+ import Pleroma.Tests.Helpers, only: [clear_config: 1, clear_config: 2]
+ setup do: clear_config(RemoteIp)
+
test "disabled" do
Pleroma.Config.put(RemoteIp, enabled: false)
diff --git a/test/plugs/session_authentication_plug_test.exs b/test/plugs/session_authentication_plug_test.exs
index 0000f4258..0949ecfed 100644
--- a/test/plugs/session_authentication_plug_test.exs
+++ b/test/plugs/session_authentication_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.SessionAuthenticationPlugTest do
diff --git a/test/plugs/set_format_plug_test.exs b/test/plugs/set_format_plug_test.exs
index 27c026fdd..7a1dfe9bf 100644
--- a/test/plugs/set_format_plug_test.exs
+++ b/test/plugs/set_format_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.SetFormatPlugTest do
diff --git a/test/plugs/set_locale_plug_test.exs b/test/plugs/set_locale_plug_test.exs
index 0aaeedc1e..7114b1557 100644
--- a/test/plugs/set_locale_plug_test.exs
+++ b/test/plugs/set_locale_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.SetLocalePlugTest do
diff --git a/test/plugs/set_user_session_id_plug_test.exs b/test/plugs/set_user_session_id_plug_test.exs
index f8bfde039..7f1a1e98b 100644
--- a/test/plugs/set_user_session_id_plug_test.exs
+++ b/test/plugs/set_user_session_id_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.SetUserSessionIdPlugTest do
diff --git a/test/plugs/uploaded_media_plug_test.exs b/test/plugs/uploaded_media_plug_test.exs
index 5ba963139..20b13dfac 100644
--- a/test/plugs/uploaded_media_plug_test.exs
+++ b/test/plugs/uploaded_media_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.UploadedMediaPlugTest do
diff --git a/test/plugs/user_enabled_plug_test.exs b/test/plugs/user_enabled_plug_test.exs
index c0fafcab1..b219d8abf 100644
--- a/test/plugs/user_enabled_plug_test.exs
+++ b/test/plugs/user_enabled_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.UserEnabledPlugTest do
@@ -8,6 +8,8 @@ defmodule Pleroma.Plugs.UserEnabledPlugTest do
alias Pleroma.Plugs.UserEnabledPlug
import Pleroma.Factory
+ setup do: clear_config([:instance, :account_activation_required])
+
test "doesn't do anything if the user isn't set", %{conn: conn} do
ret_conn =
conn
@@ -16,8 +18,22 @@ defmodule Pleroma.Plugs.UserEnabledPlugTest do
assert ret_conn == conn
end
+ test "with a user that's not confirmed and a config requiring confirmation, it removes that user",
+ %{conn: conn} do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+
+ user = insert(:user, confirmation_pending: true)
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> UserEnabledPlug.call(%{})
+
+ assert conn.assigns.user == nil
+ end
+
test "with a user that is deactivated, it removes that user", %{conn: conn} do
- user = insert(:user, info: %{deactivated: true})
+ user = insert(:user, deactivated: true)
conn =
conn
diff --git a/test/plugs/user_fetcher_plug_test.exs b/test/plugs/user_fetcher_plug_test.exs
index 262eb8d93..0496f14dd 100644
--- a/test/plugs/user_fetcher_plug_test.exs
+++ b/test/plugs/user_fetcher_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.UserFetcherPlugTest do
diff --git a/test/plugs/user_is_admin_plug_test.exs b/test/plugs/user_is_admin_plug_test.exs
index 9e05fff18..fd6a50e53 100644
--- a/test/plugs/user_is_admin_plug_test.exs
+++ b/test/plugs/user_is_admin_plug_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.UserIsAdminPlugTest do
@@ -8,36 +8,112 @@ defmodule Pleroma.Plugs.UserIsAdminPlugTest do
alias Pleroma.Plugs.UserIsAdminPlug
import Pleroma.Factory
- test "accepts a user that is admin" do
- user = insert(:user, info: %{is_admin: true})
+ describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false)
- conn =
- build_conn()
- |> assign(:user, user)
+ test "accepts a user that is an admin" do
+ user = insert(:user, is_admin: true)
- ret_conn =
- conn
- |> UserIsAdminPlug.call(%{})
+ conn = assign(build_conn(), :user, user)
- assert conn == ret_conn
- end
+ ret_conn = UserIsAdminPlug.call(conn, %{})
+
+ assert conn == ret_conn
+ end
+
+ test "denies a user that isn't an admin" do
+ user = insert(:user)
- test "denies a user that isn't admin" do
- user = insert(:user)
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> UserIsAdminPlug.call(%{})
- conn =
- build_conn()
- |> assign(:user, user)
- |> UserIsAdminPlug.call(%{})
+ assert conn.status == 403
+ end
- assert conn.status == 403
+ test "denies when a user isn't set" do
+ conn = UserIsAdminPlug.call(build_conn(), %{})
+
+ assert conn.status == 403
+ end
end
- test "denies when a user isn't set" do
- conn =
- build_conn()
- |> UserIsAdminPlug.call(%{})
+ describe "with [:auth, :enforce_oauth_admin_scope_usage]," do
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true)
+
+ setup do
+ admin_user = insert(:user, is_admin: true)
+ non_admin_user = insert(:user, is_admin: false)
+ blank_user = nil
+
+ {:ok, %{users: [admin_user, non_admin_user, blank_user]}}
+ end
+
+ test "if token has any of admin scopes, accepts a user that is an admin", %{conn: conn} do
+ user = insert(:user, is_admin: true)
+ token = insert(:oauth_token, user: user, scopes: ["admin:something"])
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, token)
+
+ ret_conn = UserIsAdminPlug.call(conn, %{})
+
+ assert conn == ret_conn
+ end
+
+ test "if token has any of admin scopes, denies a user that isn't an admin", %{conn: conn} do
+ user = insert(:user, is_admin: false)
+ token = insert(:oauth_token, user: user, scopes: ["admin:something"])
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, token)
+ |> UserIsAdminPlug.call(%{})
+
+ assert conn.status == 403
+ end
+
+ test "if token has any of admin scopes, denies when a user isn't set", %{conn: conn} do
+ token = insert(:oauth_token, scopes: ["admin:something"])
+
+ conn =
+ conn
+ |> assign(:user, nil)
+ |> assign(:token, token)
+ |> UserIsAdminPlug.call(%{})
+
+ assert conn.status == 403
+ end
+
+ test "if token lacks admin scopes, denies users regardless of is_admin flag",
+ %{users: users} do
+ for user <- users do
+ token = insert(:oauth_token, user: user)
+
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> assign(:token, token)
+ |> UserIsAdminPlug.call(%{})
+
+ assert conn.status == 403
+ end
+ end
+
+ test "if token is missing, denies users regardless of is_admin flag", %{users: users} do
+ for user <- users do
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> assign(:token, nil)
+ |> UserIsAdminPlug.call(%{})
- assert conn.status == 403
+ assert conn.status == 403
+ end
+ end
end
end
diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs
new file mode 100644
index 000000000..aeda54875
--- /dev/null
+++ b/test/pool/connections_test.exs
@@ -0,0 +1,760 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Pool.ConnectionsTest do
+ use ExUnit.Case, async: true
+ use Pleroma.Tests.Helpers
+
+ import ExUnit.CaptureLog
+ import Mox
+
+ alias Pleroma.Gun.Conn
+ alias Pleroma.GunMock
+ alias Pleroma.Pool.Connections
+
+ setup :verify_on_exit!
+
+ setup_all do
+ name = :test_connections
+ {:ok, pid} = Connections.start_link({name, [checkin_timeout: 150]})
+ {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock)
+
+ on_exit(fn ->
+ if Process.alive?(pid), do: GenServer.stop(name)
+ end)
+
+ {:ok, name: name}
+ end
+
+ defp open_mock(num \\ 1) do
+ GunMock
+ |> expect(:open, num, &start_and_register(&1, &2, &3))
+ |> expect(:await_up, num, fn _, _ -> {:ok, :http} end)
+ |> expect(:set_owner, num, fn _, _ -> :ok end)
+ end
+
+ defp connect_mock(mock) do
+ mock
+ |> expect(:connect, &connect(&1, &2))
+ |> expect(:await, &await(&1, &2))
+ end
+
+ defp info_mock(mock), do: expect(mock, :info, &info(&1))
+
+ defp start_and_register('gun-not-up.com', _, _), do: {:error, :timeout}
+
+ defp start_and_register(host, port, _) do
+ {:ok, pid} = Task.start_link(fn -> Process.sleep(1000) end)
+
+ scheme =
+ case port do
+ 443 -> "https"
+ _ -> "http"
+ end
+
+ Registry.register(GunMock, pid, %{
+ origin_scheme: scheme,
+ origin_host: host,
+ origin_port: port
+ })
+
+ {:ok, pid}
+ end
+
+ defp info(pid) do
+ [{_, info}] = Registry.lookup(GunMock, pid)
+ info
+ end
+
+ defp connect(pid, _) do
+ ref = make_ref()
+ Registry.register(GunMock, ref, pid)
+ ref
+ end
+
+ defp await(pid, ref) do
+ [{_, ^pid}] = Registry.lookup(GunMock, ref)
+ {:response, :fin, 200, []}
+ end
+
+ defp now, do: :os.system_time(:second)
+
+ describe "alive?/2" do
+ test "is alive", %{name: name} do
+ assert Connections.alive?(name)
+ end
+
+ test "returns false if not started" do
+ refute Connections.alive?(:some_random_name)
+ end
+ end
+
+ test "opens connection and reuse it on next request", %{name: name} do
+ open_mock()
+ url = "http://some-domain.com"
+ key = "http:some-domain.com:80"
+ refute Connections.checkin(url, name)
+ :ok = Conn.open(url, name)
+
+ conn = Connections.checkin(url, name)
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ self = self()
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert conn == reused_conn
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}, {^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ :ok = Connections.checkout(conn, self, name)
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ :ok = Connections.checkout(conn, self, name)
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [],
+ conn_state: :idle
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "reuse connection for idna domains", %{name: name} do
+ open_mock()
+ url = "http://ですsome-domain.com"
+ refute Connections.checkin(url, name)
+
+ :ok = Conn.open(url, name)
+
+ conn = Connections.checkin(url, name)
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ self = self()
+
+ %Connections{
+ conns: %{
+ "http:ですsome-domain.com:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert conn == reused_conn
+ end
+
+ test "reuse for ipv4", %{name: name} do
+ open_mock()
+ url = "http://127.0.0.1"
+
+ refute Connections.checkin(url, name)
+
+ :ok = Conn.open(url, name)
+
+ conn = Connections.checkin(url, name)
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ self = self()
+
+ %Connections{
+ conns: %{
+ "http:127.0.0.1:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert conn == reused_conn
+
+ :ok = Connections.checkout(conn, self, name)
+ :ok = Connections.checkout(reused_conn, self, name)
+
+ %Connections{
+ conns: %{
+ "http:127.0.0.1:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [],
+ conn_state: :idle
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "reuse for ipv6", %{name: name} do
+ open_mock()
+ url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]"
+
+ refute Connections.checkin(url, name)
+
+ :ok = Conn.open(url, name)
+
+ conn = Connections.checkin(url, name)
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ self = self()
+
+ %Connections{
+ conns: %{
+ "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert conn == reused_conn
+ end
+
+ test "up and down ipv4", %{name: name} do
+ open_mock()
+ |> info_mock()
+ |> allow(self(), name)
+
+ self = self()
+ url = "http://127.0.0.1"
+ :ok = Conn.open(url, name)
+ conn = Connections.checkin(url, name)
+ send(name, {:gun_down, conn, nil, nil, nil})
+ send(name, {:gun_up, conn, nil})
+
+ %Connections{
+ conns: %{
+ "http:127.0.0.1:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "up and down ipv6", %{name: name} do
+ self = self()
+
+ open_mock()
+ |> info_mock()
+ |> allow(self, name)
+
+ url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]"
+ :ok = Conn.open(url, name)
+ conn = Connections.checkin(url, name)
+ send(name, {:gun_down, conn, nil, nil, nil})
+ send(name, {:gun_up, conn, nil})
+
+ %Connections{
+ conns: %{
+ "http:2a03:2880:f10c:83:face:b00c:0:25de:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}],
+ conn_state: :active
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "reuses connection based on protocol", %{name: name} do
+ open_mock(2)
+ http_url = "http://some-domain.com"
+ http_key = "http:some-domain.com:80"
+ https_url = "https://some-domain.com"
+ https_key = "https:some-domain.com:443"
+
+ refute Connections.checkin(http_url, name)
+ :ok = Conn.open(http_url, name)
+ conn = Connections.checkin(http_url, name)
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ refute Connections.checkin(https_url, name)
+ :ok = Conn.open(https_url, name)
+ https_conn = Connections.checkin(https_url, name)
+
+ refute conn == https_conn
+
+ reused_https = Connections.checkin(https_url, name)
+
+ refute conn == reused_https
+
+ assert reused_https == https_conn
+
+ %Connections{
+ conns: %{
+ ^http_key => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ },
+ ^https_key => %Conn{
+ conn: ^https_conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "connection can't get up", %{name: name} do
+ expect(GunMock, :open, &start_and_register(&1, &2, &3))
+ url = "http://gun-not-up.com"
+
+ assert capture_log(fn ->
+ refute Conn.open(url, name)
+ refute Connections.checkin(url, name)
+ end) =~
+ "Opening connection to http://gun-not-up.com failed with error {:error, :timeout}"
+ end
+
+ test "process gun_down message and then gun_up", %{name: name} do
+ self = self()
+
+ open_mock()
+ |> info_mock()
+ |> allow(self, name)
+
+ url = "http://gun-down-and-up.com"
+ key = "http:gun-down-and-up.com:80"
+ :ok = Conn.open(url, name)
+ conn = Connections.checkin(url, name)
+
+ assert is_pid(conn)
+ assert Process.alive?(conn)
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up,
+ used_by: [{^self, _}]
+ }
+ }
+ } = Connections.get_state(name)
+
+ send(name, {:gun_down, conn, :http, nil, nil})
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :down,
+ used_by: [{^self, _}]
+ }
+ }
+ } = Connections.get_state(name)
+
+ send(name, {:gun_up, conn, :http})
+
+ conn2 = Connections.checkin(url, name)
+ assert conn == conn2
+
+ assert is_pid(conn2)
+ assert Process.alive?(conn2)
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: _,
+ gun_state: :up,
+ used_by: [{^self, _}, {^self, _}]
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ test "async processes get same conn for same domain", %{name: name} do
+ open_mock()
+ url = "http://some-domain.com"
+ :ok = Conn.open(url, name)
+
+ tasks =
+ for _ <- 1..5 do
+ Task.async(fn ->
+ Connections.checkin(url, name)
+ end)
+ end
+
+ tasks_with_results = Task.yield_many(tasks)
+
+ results =
+ Enum.map(tasks_with_results, fn {task, res} ->
+ res || Task.shutdown(task, :brutal_kill)
+ end)
+
+ conns = for {:ok, value} <- results, do: value
+
+ %Connections{
+ conns: %{
+ "http:some-domain.com:80" => %Conn{
+ conn: conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ assert Enum.all?(conns, fn res -> res == conn end)
+ end
+
+ test "remove frequently used and idle", %{name: name} do
+ open_mock(3)
+ self = self()
+ http_url = "http://some-domain.com"
+ https_url = "https://some-domain.com"
+ :ok = Conn.open(https_url, name)
+ :ok = Conn.open(http_url, name)
+
+ conn1 = Connections.checkin(https_url, name)
+
+ [conn2 | _conns] =
+ for _ <- 1..4 do
+ Connections.checkin(http_url, name)
+ end
+
+ http_key = "http:some-domain.com:80"
+
+ %Connections{
+ conns: %{
+ ^http_key => %Conn{
+ conn: ^conn2,
+ gun_state: :up,
+ conn_state: :active,
+ used_by: [{^self, _}, {^self, _}, {^self, _}, {^self, _}]
+ },
+ "https:some-domain.com:443" => %Conn{
+ conn: ^conn1,
+ gun_state: :up,
+ conn_state: :active,
+ used_by: [{^self, _}]
+ }
+ }
+ } = Connections.get_state(name)
+
+ :ok = Connections.checkout(conn1, self, name)
+
+ another_url = "http://another-domain.com"
+ :ok = Conn.open(another_url, name)
+ conn = Connections.checkin(another_url, name)
+
+ %Connections{
+ conns: %{
+ "http:another-domain.com:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ },
+ ^http_key => %Conn{
+ conn: _,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+ end
+
+ describe "with proxy" do
+ test "as ip", %{name: name} do
+ open_mock()
+ |> connect_mock()
+
+ url = "http://proxy-string.com"
+ key = "http:proxy-string.com:80"
+ :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123})
+
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ ^key => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+
+ test "as host", %{name: name} do
+ open_mock()
+ |> connect_mock()
+
+ url = "http://proxy-tuple-atom.com"
+ :ok = Conn.open(url, name, proxy: {'localhost', 9050})
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ "http:proxy-tuple-atom.com:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+
+ test "as ip and ssl", %{name: name} do
+ open_mock()
+ |> connect_mock()
+
+ url = "https://proxy-string.com"
+
+ :ok = Conn.open(url, name, proxy: {{127, 0, 0, 1}, 8123})
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ "https:proxy-string.com:443" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+
+ test "as host and ssl", %{name: name} do
+ open_mock()
+ |> connect_mock()
+
+ url = "https://proxy-tuple-atom.com"
+ :ok = Conn.open(url, name, proxy: {'localhost', 9050})
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ "https:proxy-tuple-atom.com:443" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+
+ test "with socks type", %{name: name} do
+ open_mock()
+
+ url = "http://proxy-socks.com"
+
+ :ok = Conn.open(url, name, proxy: {:socks5, 'localhost', 1234})
+
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ "http:proxy-socks.com:80" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+
+ test "with socks4 type and ssl", %{name: name} do
+ open_mock()
+ url = "https://proxy-socks.com"
+
+ :ok = Conn.open(url, name, proxy: {:socks4, 'localhost', 1234})
+
+ conn = Connections.checkin(url, name)
+
+ %Connections{
+ conns: %{
+ "https:proxy-socks.com:443" => %Conn{
+ conn: ^conn,
+ gun_state: :up
+ }
+ }
+ } = Connections.get_state(name)
+
+ reused_conn = Connections.checkin(url, name)
+
+ assert reused_conn == conn
+ end
+ end
+
+ describe "crf/3" do
+ setup do
+ crf = Connections.crf(1, 10, 1)
+ {:ok, crf: crf}
+ end
+
+ test "more used will have crf higher", %{crf: crf} do
+ # used 3 times
+ crf1 = Connections.crf(1, 10, crf)
+ crf1 = Connections.crf(1, 10, crf1)
+
+ # used 2 times
+ crf2 = Connections.crf(1, 10, crf)
+
+ assert crf1 > crf2
+ end
+
+ test "recently used will have crf higher on equal references", %{crf: crf} do
+ # used 3 sec ago
+ crf1 = Connections.crf(3, 10, crf)
+
+ # used 4 sec ago
+ crf2 = Connections.crf(4, 10, crf)
+
+ assert crf1 > crf2
+ end
+
+ test "equal crf on equal reference and time", %{crf: crf} do
+ # used 2 times
+ crf1 = Connections.crf(1, 10, crf)
+
+ # used 2 times
+ crf2 = Connections.crf(1, 10, crf)
+
+ assert crf1 == crf2
+ end
+
+ test "recently used will have higher crf", %{crf: crf} do
+ crf1 = Connections.crf(2, 10, crf)
+ crf1 = Connections.crf(1, 10, crf1)
+
+ crf2 = Connections.crf(3, 10, crf)
+ crf2 = Connections.crf(4, 10, crf2)
+ assert crf1 > crf2
+ end
+ end
+
+ describe "get_unused_conns/1" do
+ test "crf is equalent, sorting by reference", %{name: name} do
+ Connections.add_conn(name, "1", %Conn{
+ conn_state: :idle,
+ last_reference: now() - 1
+ })
+
+ Connections.add_conn(name, "2", %Conn{
+ conn_state: :idle,
+ last_reference: now()
+ })
+
+ assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name)
+ end
+
+ test "reference is equalent, sorting by crf", %{name: name} do
+ Connections.add_conn(name, "1", %Conn{
+ conn_state: :idle,
+ crf: 1.999
+ })
+
+ Connections.add_conn(name, "2", %Conn{
+ conn_state: :idle,
+ crf: 2
+ })
+
+ assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name)
+ end
+
+ test "higher crf and lower reference", %{name: name} do
+ Connections.add_conn(name, "1", %Conn{
+ conn_state: :idle,
+ crf: 3,
+ last_reference: now() - 1
+ })
+
+ Connections.add_conn(name, "2", %Conn{
+ conn_state: :idle,
+ crf: 2,
+ last_reference: now()
+ })
+
+ assert [{"2", _unused_conn} | _others] = Connections.get_unused_conns(name)
+ end
+
+ test "lower crf and lower reference", %{name: name} do
+ Connections.add_conn(name, "1", %Conn{
+ conn_state: :idle,
+ crf: 1.99,
+ last_reference: now() - 1
+ })
+
+ Connections.add_conn(name, "2", %Conn{
+ conn_state: :idle,
+ crf: 2,
+ last_reference: now()
+ })
+
+ assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(name)
+ end
+ end
+
+ test "count/1" do
+ name = :test_count
+ {:ok, _} = Connections.start_link({name, [checkin_timeout: 150]})
+ assert Connections.count(name) == 0
+ Connections.add_conn(name, "1", %Conn{conn: self()})
+ assert Connections.count(name) == 1
+ Connections.remove_conn(name, "1")
+ assert Connections.count(name) == 0
+ end
+end
diff --git a/test/registration_test.exs b/test/registration_test.exs
index 6143b82c7..7db8e3664 100644
--- a/test/registration_test.exs
+++ b/test/registration_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.RegistrationTest do
diff --git a/test/repo_test.exs b/test/repo_test.exs
index 85b64d4d1..daffc6542 100644
--- a/test/repo_test.exs
+++ b/test/repo_test.exs
@@ -1,10 +1,13 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.RepoTest do
use Pleroma.DataCase
+ import ExUnit.CaptureLog
import Pleroma.Factory
+ import Mock
+
alias Pleroma.User
describe "find_resource/1" do
@@ -46,4 +49,36 @@ defmodule Pleroma.RepoTest do
assert Repo.get_assoc(token, :user) == {:error, :not_found}
end
end
+
+ describe "check_migrations_applied!" do
+ setup_with_mocks([
+ {Ecto.Migrator, [],
+ [
+ with_repo: fn repo, fun -> passthrough([repo, fun]) end,
+ migrations: fn Pleroma.Repo ->
+ [
+ {:up, 20_191_128_153_944, "fix_missing_following_count"},
+ {:up, 20_191_203_043_610, "create_report_notes"},
+ {:down, 20_191_220_174_645, "add_scopes_to_pleroma_feo_auth_records"}
+ ]
+ end
+ ]}
+ ]) do
+ :ok
+ end
+
+ setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check])
+
+ test "raises if it detects unapplied migrations" do
+ assert_raise Pleroma.Repo.UnappliedMigrationsError, fn ->
+ capture_log(&Repo.check_migrations_applied!/0)
+ end
+ end
+
+ test "doesn't do anything if disabled" do
+ Pleroma.Config.put([:i_am_aware_this_may_cause_data_loss, :disable_migration_check], true)
+
+ assert :ok == Repo.check_migrations_applied!()
+ end
+ end
end
diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs
index 0672f57db..c677066b3 100644
--- a/test/reverse_proxy_test.exs
+++ b/test/reverse_proxy/reverse_proxy_test.exs
@@ -1,16 +1,19 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxyTest do
use Pleroma.Web.ConnCase, async: true
+
import ExUnit.CaptureLog
import Mox
+
alias Pleroma.ReverseProxy
alias Pleroma.ReverseProxy.ClientMock
+ alias Plug.Conn
setup_all do
- {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock)
+ {:ok, _} = Registry.start_link(keys: :unique, name: ClientMock)
:ok
end
@@ -21,7 +24,7 @@ defmodule Pleroma.ReverseProxyTest do
ClientMock
|> expect(:request, fn :get, url, _, _, _ ->
- Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0)
+ Registry.register(ClientMock, url, 0)
{:ok, 200,
[
@@ -29,14 +32,14 @@ defmodule Pleroma.ReverseProxyTest do
{"content-length", byte_size(json) |> to_string()}
], %{url: url}}
end)
- |> expect(:stream_body, invokes, fn %{url: url} ->
- case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do
+ |> expect(:stream_body, invokes, fn %{url: url} = client ->
+ case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
- Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1))
- {:ok, json}
+ Registry.update_value(ClientMock, url, &(&1 + 1))
+ {:ok, json, client}
[{_, 1}] ->
- Registry.unregister(Pleroma.ReverseProxy.ClientMock, url)
+ Registry.unregister(ClientMock, url)
:done
end
end)
@@ -78,7 +81,39 @@ defmodule Pleroma.ReverseProxyTest do
assert conn.halted
end
- describe "max_body " do
+ defp stream_mock(invokes, with_close? \\ false) do
+ ClientMock
+ |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
+ Registry.register(ClientMock, "/stream-bytes/" <> length, 0)
+
+ {:ok, 200, [{"content-type", "application/octet-stream"}],
+ %{url: "/stream-bytes/" <> length}}
+ end)
+ |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client ->
+ max = String.to_integer(length)
+
+ case Registry.lookup(ClientMock, "/stream-bytes/" <> length) do
+ [{_, current}] when current < max ->
+ Registry.update_value(
+ ClientMock,
+ "/stream-bytes/" <> length,
+ &(&1 + 10)
+ )
+
+ {:ok, "0123456789", client}
+
+ [{_, ^max}] ->
+ Registry.unregister(ClientMock, "/stream-bytes/" <> length)
+ :done
+ end
+ end)
+
+ if with_close? do
+ expect(ClientMock, :close, fn _ -> :ok end)
+ end
+ end
+
+ describe "max_body" do
test "length returns error if content-length more than option", %{conn: conn} do
user_agent_mock("hackney/1.15.1", 0)
@@ -94,38 +129,6 @@ defmodule Pleroma.ReverseProxyTest do
end) == ""
end
- defp stream_mock(invokes, with_close? \\ false) do
- ClientMock
- |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
- Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0)
-
- {:ok, 200, [{"content-type", "application/octet-stream"}],
- %{url: "/stream-bytes/" <> length}}
- end)
- |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} ->
- max = String.to_integer(length)
-
- case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do
- [{_, current}] when current < max ->
- Registry.update_value(
- Pleroma.ReverseProxy.ClientMock,
- "/stream-bytes/" <> length,
- &(&1 + 10)
- )
-
- {:ok, "0123456789"}
-
- [{_, ^max}] ->
- Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length)
- :done
- end
- end)
-
- if with_close? do
- expect(ClientMock, :close, fn _ -> :ok end)
- end
- end
-
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
stream_mock(3, true)
@@ -214,24 +217,24 @@ defmodule Pleroma.ReverseProxyTest do
conn = ReverseProxy.call(conn, "/stream-bytes/200")
assert conn.state == :chunked
assert byte_size(conn.resp_body) == 200
- assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
+ assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
defp headers_mock(_) do
ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ ->
- Registry.register(Pleroma.ReverseProxy.ClientMock, "/headers", 0)
+ Registry.register(ClientMock, "/headers", 0)
{:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}}
end)
- |> expect(:stream_body, 2, fn %{url: url, headers: headers} ->
- case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do
+ |> expect(:stream_body, 2, fn %{url: url, headers: headers} = client ->
+ case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
- Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1))
+ Registry.update_value(ClientMock, url, &(&1 + 1))
headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v}
- {:ok, Jason.encode!(%{headers: headers})}
+ {:ok, Jason.encode!(%{headers: headers}), client}
[{_, 1}] ->
- Registry.unregister(Pleroma.ReverseProxy.ClientMock, url)
+ Registry.unregister(ClientMock, url)
:done
end
end)
@@ -244,7 +247,7 @@ defmodule Pleroma.ReverseProxyTest do
test "header passes", %{conn: conn} do
conn =
- Plug.Conn.put_req_header(
+ Conn.put_req_header(
conn,
"accept",
"text/html"
@@ -257,7 +260,7 @@ defmodule Pleroma.ReverseProxyTest do
test "header is filtered", %{conn: conn} do
conn =
- Plug.Conn.put_req_header(
+ Conn.put_req_header(
conn,
"accept-language",
"en-US"
@@ -275,17 +278,6 @@ defmodule Pleroma.ReverseProxyTest do
end
describe "cache resp headers" do
- test "returns headers", %{conn: conn} do
- ClientMock
- |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ ->
- {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}}
- end)
- |> expect(:stream_body, fn _ -> :done end)
-
- conn = ReverseProxy.call(conn, "/cache/10")
- assert {"cache-control", "public, max-age=10"} in conn.resp_headers
- end
-
test "add cache-control", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/cache", _, _, _ ->
@@ -294,25 +286,25 @@ defmodule Pleroma.ReverseProxyTest do
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/cache")
- assert {"cache-control", "public"} in conn.resp_headers
+ assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers
end
end
defp disposition_headers_mock(headers) do
ClientMock
|> expect(:request, fn :get, "/disposition", _, _, _ ->
- Registry.register(Pleroma.ReverseProxy.ClientMock, "/disposition", 0)
+ Registry.register(ClientMock, "/disposition", 0)
{:ok, 200, headers, %{url: "/disposition"}}
end)
- |> expect(:stream_body, 2, fn %{url: "/disposition"} ->
- case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do
+ |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
+ case Registry.lookup(ClientMock, "/disposition") do
[{_, 0}] ->
- Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1))
- {:ok, ""}
+ Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
+ {:ok, "", client}
[{_, 1}] ->
- Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition")
+ Registry.unregister(ClientMock, "/disposition")
:done
end
end)
diff --git a/test/runtime_test.exs b/test/runtime_test.exs
new file mode 100644
index 000000000..a1a6c57cd
--- /dev/null
+++ b/test/runtime_test.exs
@@ -0,0 +1,11 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.RuntimeTest do
+ use ExUnit.Case, async: true
+
+ test "it loads custom runtime modules" do
+ assert {:module, RuntimeModule} == Code.ensure_compiled(RuntimeModule)
+ end
+end
diff --git a/test/scheduled_activity_test.exs b/test/scheduled_activity_test.exs
index dcf12fb49..7faa5660d 100644
--- a/test/scheduled_activity_test.exs
+++ b/test/scheduled_activity_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ScheduledActivityTest do
@@ -8,11 +8,51 @@ defmodule Pleroma.ScheduledActivityTest do
alias Pleroma.ScheduledActivity
import Pleroma.Factory
+ setup do: clear_config([ScheduledActivity, :enabled])
+
setup context do
DataCase.ensure_local_uploader(context)
end
describe "creation" do
+ test "scheduled activities with jobs when ScheduledActivity enabled" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], true)
+ user = insert(:user)
+
+ today =
+ NaiveDateTime.utc_now()
+ |> NaiveDateTime.add(:timer.minutes(6), :millisecond)
+ |> NaiveDateTime.to_iso8601()
+
+ attrs = %{params: %{}, scheduled_at: today}
+ {:ok, sa1} = ScheduledActivity.create(user, attrs)
+ {:ok, sa2} = ScheduledActivity.create(user, attrs)
+
+ jobs =
+ Repo.all(from(j in Oban.Job, where: j.queue == "scheduled_activities", select: j.args))
+
+ assert jobs == [%{"activity_id" => sa1.id}, %{"activity_id" => sa2.id}]
+ end
+
+ test "scheduled activities without jobs when ScheduledActivity disabled" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], false)
+ user = insert(:user)
+
+ today =
+ NaiveDateTime.utc_now()
+ |> NaiveDateTime.add(:timer.minutes(6), :millisecond)
+ |> NaiveDateTime.to_iso8601()
+
+ attrs = %{params: %{}, scheduled_at: today}
+ {:ok, _sa1} = ScheduledActivity.create(user, attrs)
+ {:ok, _sa2} = ScheduledActivity.create(user, attrs)
+
+ jobs =
+ Repo.all(from(j in Oban.Job, where: j.queue == "scheduled_activities", select: j.args))
+
+ assert jobs == []
+ end
+
test "when daily user limit is exceeded" do
user = insert(:user)
@@ -24,6 +64,7 @@ defmodule Pleroma.ScheduledActivityTest do
attrs = %{params: %{}, scheduled_at: today}
{:ok, _} = ScheduledActivity.create(user, attrs)
{:ok, _} = ScheduledActivity.create(user, attrs)
+
{:error, changeset} = ScheduledActivity.create(user, attrs)
assert changeset.errors == [scheduled_at: {"daily limit exceeded", []}]
end
diff --git a/test/signature_test.exs b/test/signature_test.exs
index 6b168f2d9..a7a75aa4d 100644
--- a/test/signature_test.exs
+++ b/test/signature_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.SignatureTest do
@@ -19,12 +19,7 @@ defmodule Pleroma.SignatureTest do
@private_key "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA48qb4v6kqigZutO9Ot0wkp27GIF2LiVaADgxQORZozZR63jH\nTaoOrS3Xhngbgc8SSOhfXET3omzeCLqaLNfXnZ8OXmuhJfJSU6mPUvmZ9QdT332j\nfN/g3iWGhYMf/M9ftCKh96nvFVO/tMruzS9xx7tkrfJjehdxh/3LlJMMImPtwcD7\nkFXwyt1qZTAU6Si4oQAJxRDQXHp1ttLl3Ob829VM7IKkrVmY8TD+JSlV0jtVJPj6\n1J19ytKTx/7UaucYvb9HIiBpkuiy5n/irDqKLVf5QEdZoNCdojOZlKJmTLqHhzKP\n3E9TxsUjhrf4/EqegNc/j982RvOxeu4i40zMQwIDAQABAoIBAQDH5DXjfh21i7b4\ncXJuw0cqget617CDUhemdakTDs9yH+rHPZd3mbGDWuT0hVVuFe4vuGpmJ8c+61X0\nRvugOlBlavxK8xvYlsqTzAmPgKUPljyNtEzQ+gz0I+3mH2jkin2rL3D+SksZZgKm\nfiYMPIQWB2WUF04gB46DDb2mRVuymGHyBOQjIx3WC0KW2mzfoFUFRlZEF+Nt8Ilw\nT+g/u0aZ1IWoszbsVFOEdghgZET0HEarum0B2Je/ozcPYtwmU10iBANGMKdLqaP/\nj954BPunrUf6gmlnLZKIKklJj0advx0NA+cL79+zeVB3zexRYSA5o9q0WPhiuTwR\n/aedWHnBAoGBAP0sDWBAM1Y4TRAf8ZI9PcztwLyHPzfEIqzbObJJnx1icUMt7BWi\n+/RMOnhrlPGE1kMhOqSxvXYN3u+eSmWTqai2sSH5Hdw2EqnrISSTnwNUPINX7fHH\njEkgmXQ6ixE48SuBZnb4w1EjdB/BA6/sjL+FNhggOc87tizLTkMXmMtTAoGBAOZV\n+wPuAMBDBXmbmxCuDIjoVmgSlgeRunB1SA8RCPAFAiUo3+/zEgzW2Oz8kgI+xVwM\n33XkLKrWG1Orhpp6Hm57MjIc5MG+zF4/YRDpE/KNG9qU1tiz0UD5hOpIU9pP4bR/\ngxgPxZzvbk4h5BfHWLpjlk8UUpgk6uxqfti48c1RAoGBALBOKDZ6HwYRCSGMjUcg\n3NPEUi84JD8qmFc2B7Tv7h2he2ykIz9iFAGpwCIyETQsJKX1Ewi0OlNnD3RhEEAy\nl7jFGQ+mkzPSeCbadmcpYlgIJmf1KN/x7fDTAepeBpCEzfZVE80QKbxsaybd3Dp8\nCfwpwWUFtBxr4c7J+gNhAGe/AoGAPn8ZyqkrPv9wXtyfqFjxQbx4pWhVmNwrkBPi\nZ2Qh3q4dNOPwTvTO8vjghvzIyR8rAZzkjOJKVFgftgYWUZfM5gE7T2mTkBYq8W+U\n8LetF+S9qAM2gDnaDx0kuUTCq7t87DKk6URuQ/SbI0wCzYjjRD99KxvChVGPBHKo\n1DjqMuECgYEAgJGNm7/lJCS2wk81whfy/ttKGsEIkyhPFYQmdGzSYC5aDc2gp1R3\nxtOkYEvdjfaLfDGEa4UX8CHHF+w3t9u8hBtcdhMH6GYb9iv6z0VBTt4A/11HUR49\n3Z7TQ18Iyh3jAUCzFV9IJlLIExq5Y7P4B3ojWFBN607sDCt8BMPbDYs=\n-----END RSA PRIVATE KEY-----"
- @public_key %{
- "id" => "https://mastodon.social/users/lambadalambda#main-key",
- "owner" => "https://mastodon.social/users/lambadalambda",
- "publicKeyPem" =>
- "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n"
- }
+ @public_key "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n"
@rsa_public_key {
:RSAPublicKey,
@@ -42,19 +37,20 @@ defmodule Pleroma.SignatureTest do
test "it returns key" do
expected_result = {:ok, @rsa_public_key}
- user = insert(:user, %{info: %{source_data: %{"publicKey" => @public_key}}})
+ user = insert(:user, public_key: @public_key)
assert Signature.fetch_public_key(make_fake_conn(user.ap_id)) == expected_result
end
test "it returns error when not found user" do
assert capture_log(fn ->
- assert Signature.fetch_public_key(make_fake_conn("test-ap_id")) == {:error, :error}
+ assert Signature.fetch_public_key(make_fake_conn("https://test-ap-id")) ==
+ {:error, :error}
end) =~ "[error] Could not decode user"
end
- test "it returns error if public key is empty" do
- user = insert(:user, %{info: %{source_data: %{"publicKey" => %{}}}})
+ test "it returns error if public key is nil" do
+ user = insert(:user, public_key: nil)
assert Signature.fetch_public_key(make_fake_conn(user.ap_id)) == {:error, :error}
end
@@ -69,7 +65,7 @@ defmodule Pleroma.SignatureTest do
test "it returns error when not found user" do
assert capture_log(fn ->
- {:error, _} = Signature.refetch_public_key(make_fake_conn("test-ap_id"))
+ {:error, _} = Signature.refetch_public_key(make_fake_conn("https://test-ap_id"))
end) =~ "[error] Could not decode user"
end
end
@@ -105,12 +101,21 @@ defmodule Pleroma.SignatureTest do
describe "key_id_to_actor_id/1" do
test "it properly deduces the actor id for misskey" do
assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") ==
- "https://example.com/users/1234"
+ {:ok, "https://example.com/users/1234"}
end
test "it properly deduces the actor id for mastodon and pleroma" do
assert Signature.key_id_to_actor_id("https://example.com/users/1234#main-key") ==
- "https://example.com/users/1234"
+ {:ok, "https://example.com/users/1234"}
+ end
+
+ test "it calls webfinger for 'acct:' accounts" do
+ with_mock(Pleroma.Web.WebFinger,
+ finger: fn _ -> %{"ap_id" => "https://gensokyo.2hu/users/raymoo"} end
+ ) do
+ assert Signature.key_id_to_actor_id("acct:raymoo@gensokyo.2hu") ==
+ {:ok, "https://gensokyo.2hu/users/raymoo"}
+ end
end
end
diff --git a/test/stats_test.exs b/test/stats_test.exs
new file mode 100644
index 000000000..4b76e2e78
--- /dev/null
+++ b/test/stats_test.exs
@@ -0,0 +1,80 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.StatsTest do
+ use Pleroma.DataCase
+ import Pleroma.Factory
+ alias Pleroma.Web.CommonAPI
+
+ describe "user count" do
+ test "it ignores internal users" do
+ _user = insert(:user, local: true)
+ _internal = insert(:user, local: true, nickname: nil)
+ _internal = Pleroma.Web.ActivityPub.Relay.get_actor()
+
+ assert match?(%{stats: %{user_count: 1}}, Pleroma.Stats.calculate_stat_data())
+ end
+ end
+
+ describe "status visibility count" do
+ test "on new status" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ CommonAPI.post(user, %{visibility: "public", status: "hey"})
+
+ Enum.each(0..1, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "unlisted",
+ status: "hey"
+ })
+ end)
+
+ Enum.each(0..2, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "direct",
+ status: "hey @#{other_user.nickname}"
+ })
+ end)
+
+ Enum.each(0..3, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "private",
+ status: "hey"
+ })
+ end)
+
+ assert %{direct: 3, private: 4, public: 1, unlisted: 2} =
+ Pleroma.Stats.get_status_visibility_count()
+ end
+
+ test "on status delete" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"})
+ assert %{public: 1} = Pleroma.Stats.get_status_visibility_count()
+ CommonAPI.delete(activity.id, user)
+ assert %{public: 0} = Pleroma.Stats.get_status_visibility_count()
+ end
+
+ test "on status visibility update" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"})
+ assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count()
+ {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{visibility: "private"})
+ assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count()
+ end
+
+ test "doesn't count unrelated activities" do
+ user = insert(:user)
+ other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"})
+ _ = CommonAPI.follow(user, other_user)
+ CommonAPI.favorite(other_user, activity.id)
+ CommonAPI.repeat(activity.id, other_user)
+
+ assert %{direct: 0, private: 0, public: 1, unlisted: 0} =
+ Pleroma.Stats.get_status_visibility_count()
+ end
+ end
+end
diff --git a/test/support/api_spec_helpers.ex b/test/support/api_spec_helpers.ex
new file mode 100644
index 000000000..80c69c788
--- /dev/null
+++ b/test/support/api_spec_helpers.ex
@@ -0,0 +1,57 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Tests.ApiSpecHelpers do
+ @moduledoc """
+ OpenAPI spec test helpers
+ """
+
+ import ExUnit.Assertions
+
+ alias OpenApiSpex.Cast.Error
+ alias OpenApiSpex.Reference
+ alias OpenApiSpex.Schema
+
+ def assert_schema(value, schema) do
+ api_spec = Pleroma.Web.ApiSpec.spec()
+
+ case OpenApiSpex.cast_value(value, schema, api_spec) do
+ {:ok, data} ->
+ data
+
+ {:error, errors} ->
+ errors =
+ Enum.map(errors, fn error ->
+ message = Error.message(error)
+ path = Error.path_to_string(error)
+ "#{message} at #{path}"
+ end)
+
+ flunk(
+ "Value does not conform to schema #{schema.title}: #{Enum.join(errors, "\n")}\n#{
+ inspect(value)
+ }"
+ )
+ end
+ end
+
+ def resolve_schema(%Schema{} = schema), do: schema
+
+ def resolve_schema(%Reference{} = ref) do
+ schemas = Pleroma.Web.ApiSpec.spec().components.schemas
+ Reference.resolve_schema(ref, schemas)
+ end
+
+ def api_operations do
+ paths = Pleroma.Web.ApiSpec.spec().paths
+
+ Enum.flat_map(paths, fn {_, path_item} ->
+ path_item
+ |> Map.take([:delete, :get, :head, :options, :patch, :post, :put, :trace])
+ |> Map.values()
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end)
+ end
+end
diff --git a/test/support/builders/activity_builder.ex b/test/support/builders/activity_builder.ex
index 6e5a8e059..7c4950bfa 100644
--- a/test/support/builders/activity_builder.ex
+++ b/test/support/builders/activity_builder.ex
@@ -21,7 +21,15 @@ defmodule Pleroma.Builders.ActivityBuilder do
def insert(data \\ %{}, opts \\ %{}) do
activity = build(data, opts)
- ActivityPub.insert(activity)
+
+ case ActivityPub.insert(activity) do
+ ok = {:ok, activity} ->
+ ActivityPub.notify_and_stream(activity)
+ ok
+
+ error ->
+ error
+ end
end
def insert_list(times, data \\ %{}, opts \\ %{}) do
diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex
index 6da16f71a..0c687c029 100644
--- a/test/support/builders/user_builder.ex
+++ b/test/support/builders/user_builder.ex
@@ -7,10 +7,12 @@ defmodule Pleroma.Builders.UserBuilder do
email: "test@example.org",
name: "Test Name",
nickname: "testname",
- password_hash: Comeonin.Pbkdf2.hashpwsalt("test"),
+ password_hash: Pbkdf2.hash_pwd_salt("test"),
bio: "A tester.",
ap_id: "some id",
- last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+ last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
+ multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
+ notification_settings: %Pleroma.User.NotificationSetting{}
}
Map.merge(user, data)
diff --git a/test/support/captcha_mock.ex b/test/support/captcha_mock.ex
index 65ca6b3bd..7b0c1d5af 100644
--- a/test/support/captcha_mock.ex
+++ b/test/support/captcha_mock.ex
@@ -1,14 +1,27 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Captcha.Mock do
alias Pleroma.Captcha.Service
@behaviour Service
+ @solution "63615261b77f5354fb8c4e4986477555"
+
+ def solution, do: @solution
+
@impl Service
- def new, do: %{type: :mock}
+ def new,
+ do: %{
+ type: :mock,
+ token: "afa1815e14e29355e6c8f6b143a39fa2",
+ answer_data: @solution,
+ url: "https://example.org/captcha.png"
+ }
@impl Service
- def validate(_token, _captcha, _data), do: :ok
+ def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok
+
+ def validate(_token, captcha, answer),
+ do: {:error, "Invalid CAPTCHA captcha: #{inspect(captcha)} ; answer: #{inspect(answer)}"}
end
diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex
index 466d8986f..d63a0f06b 100644
--- a/test/support/channel_case.ex
+++ b/test/support/channel_case.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ChannelCase do
@@ -23,6 +23,7 @@ defmodule Pleroma.Web.ChannelCase do
quote do
# Import conveniences for testing with channels
use Phoenix.ChannelTest
+ use Pleroma.Tests.Helpers
# The default endpoint for testing
@endpoint Pleroma.Web.Endpoint
diff --git a/test/support/cluster.ex b/test/support/cluster.ex
new file mode 100644
index 000000000..deb37f361
--- /dev/null
+++ b/test/support/cluster.ex
@@ -0,0 +1,218 @@
+defmodule Pleroma.Cluster do
+ @moduledoc """
+ Facilities for managing a cluster of slave VM's for federated testing.
+
+ ## Spawning the federated cluster
+
+ `spawn_cluster/1` spawns a map of slave nodes that are started
+ within the running VM. During startup, the slave node is sent all configuration
+ from the parent node, as well as all code. After receiving configuration and
+ code, the slave then starts all applications currently running on the parent.
+ The configuration passed to `spawn_cluster/1` overrides any parent application
+ configuration for the provided OTP app and key. This is useful for customizing
+ the Ecto database, Phoenix webserver ports, etc.
+
+ For example, to start a single federated VM named ":federated1", with the
+ Pleroma Endpoint running on port 4123, and with a database named
+ "pleroma_test1", you would run:
+
+ endpoint_conf = Application.fetch_env!(:pleroma, Pleroma.Web.Endpoint)
+ repo_conf = Application.fetch_env!(:pleroma, Pleroma.Repo)
+
+ Pleroma.Cluster.spawn_cluster(%{
+ :"federated1@127.0.0.1" => [
+ {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test1")},
+ {:pleroma, Pleroma.Web.Endpoint,
+ Keyword.merge(endpoint_conf, http: [port: 4011], url: [port: 4011], server: true)}
+ ]
+ })
+
+ *Note*: application configuration for a given key is not merged,
+ so any customization requires first fetching the existing values
+ and merging yourself by providing the merged configuration,
+ such as above with the endpoint config and repo config.
+
+ ## Executing code within a remote node
+
+ Use the `within/2` macro to execute code within the context of a remote
+ federated node. The code block captures all local variable bindings from
+ the parent's context and returns the result of the expression after executing
+ it on the remote node. For example:
+
+ import Pleroma.Cluster
+
+ parent_value = 123
+
+ result =
+ within :"federated1@127.0.0.1" do
+ {node(), parent_value}
+ end
+
+ assert result == {:"federated1@127.0.0.1, 123}
+
+ *Note*: while local bindings are captured and available within the block,
+ other parent contexts like required, aliased, or imported modules are not
+ in scope. Those will need to be reimported/aliases/required within the block
+ as `within/2` is a remote procedure call.
+ """
+
+ @extra_apps Pleroma.Mixfile.application()[:extra_applications]
+
+ @doc """
+ Spawns the default Pleroma federated cluster.
+
+ Values before may be customized as needed for the test suite.
+ """
+ def spawn_default_cluster do
+ endpoint_conf = Application.fetch_env!(:pleroma, Pleroma.Web.Endpoint)
+ repo_conf = Application.fetch_env!(:pleroma, Pleroma.Repo)
+
+ spawn_cluster(%{
+ :"federated1@127.0.0.1" => [
+ {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test_federated1")},
+ {:pleroma, Pleroma.Web.Endpoint,
+ Keyword.merge(endpoint_conf, http: [port: 4011], url: [port: 4011], server: true)}
+ ],
+ :"federated2@127.0.0.1" => [
+ {:pleroma, Pleroma.Repo, Keyword.merge(repo_conf, database: "pleroma_test_federated2")},
+ {:pleroma, Pleroma.Web.Endpoint,
+ Keyword.merge(endpoint_conf, http: [port: 4012], url: [port: 4012], server: true)}
+ ]
+ })
+ end
+
+ @doc """
+ Spawns a configured map of federated nodes.
+
+ See `Pleroma.Cluster` module documentation for details.
+ """
+ def spawn_cluster(node_configs) do
+ # Turn node into a distributed node with the given long name
+ :net_kernel.start([:"primary@127.0.0.1"])
+
+ # Allow spawned nodes to fetch all code from this node
+ {:ok, _} = :erl_boot_server.start([])
+ allow_boot("127.0.0.1")
+
+ silence_logger_warnings(fn ->
+ node_configs
+ |> Enum.map(&Task.async(fn -> start_slave(&1) end))
+ |> Enum.map(&Task.await(&1, 60_000))
+ end)
+ end
+
+ @doc """
+ Executes block of code again remote node.
+
+ See `Pleroma.Cluster` module documentation for details.
+ """
+ defmacro within(node, do: block) do
+ quote do
+ rpc(unquote(node), unquote(__MODULE__), :eval_quoted, [
+ unquote(Macro.escape(block)),
+ binding()
+ ])
+ end
+ end
+
+ @doc false
+ def eval_quoted(block, binding) do
+ {result, _binding} = Code.eval_quoted(block, binding, __ENV__)
+ result
+ end
+
+ defp start_slave({node_host, override_configs}) do
+ log(node_host, "booting federated VM")
+ {:ok, node} = :slave.start(~c"127.0.0.1", node_name(node_host), vm_args())
+ add_code_paths(node)
+ load_apps_and_transfer_configuration(node, override_configs)
+ ensure_apps_started(node)
+ {:ok, node}
+ end
+
+ def rpc(node, module, function, args) do
+ :rpc.block_call(node, module, function, args)
+ end
+
+ defp vm_args do
+ ~c"-loader inet -hosts 127.0.0.1 -setcookie #{:erlang.get_cookie()}"
+ end
+
+ defp allow_boot(host) do
+ {:ok, ipv4} = :inet.parse_ipv4_address(~c"#{host}")
+ :ok = :erl_boot_server.add_slave(ipv4)
+ end
+
+ defp add_code_paths(node) do
+ rpc(node, :code, :add_paths, [:code.get_path()])
+ end
+
+ defp load_apps_and_transfer_configuration(node, override_configs) do
+ Enum.each(Application.loaded_applications(), fn {app_name, _, _} ->
+ app_name
+ |> Application.get_all_env()
+ |> Enum.each(fn {key, primary_config} ->
+ rpc(node, Application, :put_env, [app_name, key, primary_config, [persistent: true]])
+ end)
+ end)
+
+ Enum.each(override_configs, fn {app_name, key, val} ->
+ rpc(node, Application, :put_env, [app_name, key, val, [persistent: true]])
+ end)
+ end
+
+ defp log(node, msg), do: IO.puts("[#{node}] #{msg}")
+
+ defp ensure_apps_started(node) do
+ loaded_names = Enum.map(Application.loaded_applications(), fn {name, _, _} -> name end)
+ app_names = @extra_apps ++ (loaded_names -- @extra_apps)
+
+ rpc(node, Application, :ensure_all_started, [:mix])
+ rpc(node, Mix, :env, [Mix.env()])
+ rpc(node, __MODULE__, :prepare_database, [])
+
+ log(node, "starting application")
+
+ Enum.reduce(app_names, MapSet.new(), fn app, loaded ->
+ if Enum.member?(loaded, app) do
+ loaded
+ else
+ {:ok, started} = rpc(node, Application, :ensure_all_started, [app])
+ MapSet.union(loaded, MapSet.new(started))
+ end
+ end)
+ end
+
+ @doc false
+ def prepare_database do
+ log(node(), "preparing database")
+ repo_config = Application.get_env(:pleroma, Pleroma.Repo)
+ repo_config[:adapter].storage_down(repo_config)
+ repo_config[:adapter].storage_up(repo_config)
+
+ {:ok, _, _} =
+ Ecto.Migrator.with_repo(Pleroma.Repo, fn repo ->
+ Ecto.Migrator.run(repo, :up, log: false, all: true)
+ end)
+
+ Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual)
+ {:ok, _} = Application.ensure_all_started(:ex_machina)
+ end
+
+ defp silence_logger_warnings(func) do
+ prev_level = Logger.level()
+ Logger.configure(level: :error)
+ res = func.()
+ Logger.configure(level: prev_level)
+
+ res
+ end
+
+ defp node_name(node_host) do
+ node_host
+ |> to_string()
+ |> String.split("@")
+ |> Enum.at(0)
+ |> String.to_atom()
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 9897f72ce..b23918dd1 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ConnCase do
@@ -26,8 +26,106 @@ defmodule Pleroma.Web.ConnCase do
use Pleroma.Tests.Helpers
import Pleroma.Web.Router.Helpers
+ alias Pleroma.Config
+
# The default endpoint for testing
@endpoint Pleroma.Web.Endpoint
+
+ # Sets up OAuth access with specified scopes
+ defp oauth_access(scopes, opts \\ []) do
+ user =
+ Keyword.get_lazy(opts, :user, fn ->
+ Pleroma.Factory.insert(:user)
+ end)
+
+ token =
+ Keyword.get_lazy(opts, :oauth_token, fn ->
+ Pleroma.Factory.insert(:oauth_token, user: user, scopes: scopes)
+ end)
+
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> assign(:token, token)
+
+ %{user: user, token: token, conn: conn}
+ end
+
+ defp request_content_type(%{conn: conn}) do
+ conn = put_req_header(conn, "content-type", "multipart/form-data")
+ [conn: conn]
+ end
+
+ defp json_response_and_validate_schema(
+ %{
+ private: %{
+ open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec}
+ }
+ } = conn,
+ status
+ ) do
+ content_type =
+ conn
+ |> Plug.Conn.get_resp_header("content-type")
+ |> List.first()
+ |> String.split(";")
+ |> List.first()
+
+ status = Plug.Conn.Status.code(status)
+
+ unless lookup[op_id].responses[status] do
+ err = "Response schema not found for #{status} #{conn.method} #{conn.request_path}"
+ flunk(err)
+ end
+
+ schema = lookup[op_id].responses[status].content[content_type].schema
+ json = json_response(conn, status)
+
+ case OpenApiSpex.cast_value(json, schema, spec) do
+ {:ok, _data} ->
+ json
+
+ {:error, errors} ->
+ errors =
+ Enum.map(errors, fn error ->
+ message = OpenApiSpex.Cast.Error.message(error)
+ path = OpenApiSpex.Cast.Error.path_to_string(error)
+ "#{message} at #{path}"
+ end)
+
+ flunk(
+ "Response does not conform to schema of #{op_id} operation: #{
+ Enum.join(errors, "\n")
+ }\n#{inspect(json)}"
+ )
+ end
+ end
+
+ defp json_response_and_validate_schema(conn, _status) do
+ flunk("Response schema not found for #{conn.method} #{conn.request_path} #{conn.status}")
+ end
+
+ defp ensure_federating_or_authenticated(conn, url, user) do
+ initial_setting = Config.get([:instance, :federating])
+ on_exit(fn -> Config.put([:instance, :federating], initial_setting) end)
+
+ Config.put([:instance, :federating], false)
+
+ conn
+ |> get(url)
+ |> response(403)
+
+ conn
+ |> assign(:user, user)
+ |> get(url)
+ |> response(200)
+
+ Config.put([:instance, :federating], true)
+
+ conn
+ |> get(url)
+ |> response(200)
+ end
end
end
@@ -41,7 +139,11 @@ defmodule Pleroma.Web.ConnCase do
end
if tags[:needs_streamer] do
- start_supervised(Pleroma.Web.Streamer.supervisor())
+ start_supervised(%{
+ id: Pleroma.Web.Streamer.registry(),
+ start:
+ {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+ })
end
{:ok, conn: Phoenix.ConnTest.build_conn()}
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
index 4ffcbac9e..ba8848952 100644
--- a/test/support/data_case.ex
+++ b/test/support/data_case.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.DataCase do
@@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do
end
if tags[:needs_streamer] do
- start_supervised(Pleroma.Web.Streamer.supervisor())
+ start_supervised(%{
+ id: Pleroma.Web.Streamer.registry(),
+ start:
+ {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
+ })
end
:ok
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 41e2b8004..d4284831c 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Factory do
@@ -29,18 +29,31 @@ defmodule Pleroma.Factory do
name: sequence(:name, &"Test テスト User #{&1}"),
email: sequence(:email, &"user#{&1}@example.com"),
nickname: sequence(:nickname, &"nick#{&1}"),
- password_hash: Comeonin.Pbkdf2.hashpwsalt("test"),
+ password_hash: Pbkdf2.hash_pwd_salt("test"),
bio: sequence(:bio, &"Tester Number #{&1}"),
- info: %{},
- last_digest_emailed_at: NaiveDateTime.utc_now()
+ last_digest_emailed_at: NaiveDateTime.utc_now(),
+ last_refreshed_at: NaiveDateTime.utc_now(),
+ notification_settings: %Pleroma.User.NotificationSetting{},
+ multi_factor_authentication_settings: %Pleroma.MFA.Settings{}
}
%{
user
| ap_id: User.ap_id(user),
follower_address: User.ap_followers(user),
- following_address: User.ap_following(user),
- following: [User.ap_id(user)]
+ following_address: User.ap_following(user)
+ }
+ end
+
+ def user_relationship_factory(attrs \\ %{}) do
+ source = attrs[:source] || insert(:user)
+ target = attrs[:target] || insert(:user)
+ relationship_type = attrs[:relationship_type] || :block
+
+ %Pleroma.UserRelationship{
+ source_id: source.id,
+ target_id: target.id,
+ relationship_type: relationship_type
}
end
@@ -283,9 +296,9 @@ defmodule Pleroma.Factory do
def oauth_app_factory do
%Pleroma.Web.OAuth.App{
- client_name: "Some client",
+ client_name: sequence(:client_name, &"Some client #{&1}"),
redirect_uris: "https://example.com/callback",
- scopes: ["read", "write", "follow", "push"],
+ scopes: ["read", "write", "follow", "push", "admin"],
website: "https://example.com",
client_id: Ecto.UUID.generate(),
client_secret: "aaa;/&bbb"
@@ -299,19 +312,37 @@ defmodule Pleroma.Factory do
}
end
- def oauth_token_factory do
- oauth_app = insert(:oauth_app)
+ def oauth_token_factory(attrs \\ %{}) do
+ scopes = Map.get(attrs, :scopes, ["read"])
+ oauth_app = Map.get_lazy(attrs, :app, fn -> insert(:oauth_app, scopes: scopes) end)
+ user = Map.get_lazy(attrs, :user, fn -> build(:user) end)
+
+ valid_until =
+ Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10))
%Pleroma.Web.OAuth.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
- scopes: ["read"],
refresh_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
- user: build(:user),
- app_id: oauth_app.id,
- valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
+ scopes: scopes,
+ user: user,
+ app: oauth_app,
+ valid_until: valid_until
}
end
+ def oauth_admin_token_factory(attrs \\ %{}) do
+ user = Map.get_lazy(attrs, :user, fn -> build(:user, is_admin: true) end)
+
+ scopes =
+ attrs
+ |> Map.get(:scopes, ["admin"])
+ |> Kernel.++(["admin"])
+ |> Enum.uniq()
+
+ attrs = Map.merge(attrs, %{user: user, scopes: scopes})
+ oauth_token_factory(attrs)
+ end
+
def oauth_authorization_factory do
%Pleroma.Web.OAuth.Authorization{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
@@ -365,9 +396,15 @@ defmodule Pleroma.Factory do
end
def config_factory do
- %Pleroma.Web.AdminAPI.Config{
- key: sequence(:key, &"some_key_#{&1}"),
- group: "pleroma",
+ %Pleroma.ConfigDB{
+ key:
+ sequence(:key, fn key ->
+ # Atom dynamic registration hack in tests
+ "some_key_#{key}"
+ |> String.to_atom()
+ |> inspect()
+ end),
+ group: ":pleroma",
value:
sequence(
:value,
@@ -386,4 +423,13 @@ defmodule Pleroma.Factory do
last_read_id: "1"
}
end
+
+ def mfa_token_factory do
+ %Pleroma.MFA.Token{
+ token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
+ authorization: build(:oauth_authorization),
+ valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
+ user: build(:user)
+ }
+ end
end
diff --git a/test/support/helpers.ex b/test/support/helpers.ex
index ce39dd9d8..26281b45e 100644
--- a/test/support/helpers.ex
+++ b/test/support/helpers.ex
@@ -1,11 +1,12 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Tests.Helpers do
@moduledoc """
Helpers for use in tests.
"""
+ alias Pleroma.Config
defmacro clear_config(config_path) do
quote do
@@ -16,29 +17,17 @@ defmodule Pleroma.Tests.Helpers do
defmacro clear_config(config_path, do: yield) do
quote do
- setup do
- initial_setting = Pleroma.Config.get(unquote(config_path))
- unquote(yield)
- on_exit(fn -> Pleroma.Config.put(unquote(config_path), initial_setting) end)
- :ok
- end
+ initial_setting = Config.get(unquote(config_path))
+ unquote(yield)
+ on_exit(fn -> Config.put(unquote(config_path), initial_setting) end)
+ :ok
end
end
- defmacro clear_config_all(config_path) do
+ defmacro clear_config(config_path, temp_setting) do
quote do
- clear_config_all(unquote(config_path)) do
- end
- end
- end
-
- defmacro clear_config_all(config_path, do: yield) do
- quote do
- setup_all do
- initial_setting = Pleroma.Config.get(unquote(config_path))
- unquote(yield)
- on_exit(fn -> Pleroma.Config.put(unquote(config_path), initial_setting) end)
- :ok
+ clear_config(unquote(config_path)) do
+ Config.put(unquote(config_path), unquote(temp_setting))
end
end
end
@@ -48,11 +37,21 @@ defmodule Pleroma.Tests.Helpers do
import Pleroma.Tests.Helpers,
only: [
clear_config: 1,
- clear_config: 2,
- clear_config_all: 1,
- clear_config_all: 2
+ clear_config: 2
]
+ def to_datetime(%NaiveDateTime{} = naive_datetime) do
+ naive_datetime
+ |> DateTime.from_naive!("Etc/UTC")
+ |> DateTime.truncate(:second)
+ end
+
+ def to_datetime(datetime) when is_binary(datetime) do
+ datetime
+ |> NaiveDateTime.from_iso8601!()
+ |> to_datetime()
+ end
+
def collect_ids(collection) do
collection
|> Enum.map(& &1.id)
@@ -75,12 +74,29 @@ defmodule Pleroma.Tests.Helpers do
|> Poison.decode!()
end
+ def stringify_keys(nil), do: nil
+
+ def stringify_keys(key) when key in [true, false], do: key
+ def stringify_keys(key) when is_atom(key), do: Atom.to_string(key)
+
+ def stringify_keys(map) when is_map(map) do
+ map
+ |> Enum.map(fn {k, v} -> {stringify_keys(k), stringify_keys(v)} end)
+ |> Enum.into(%{})
+ end
+
+ def stringify_keys([head | rest] = list) when is_list(list) do
+ [stringify_keys(head) | stringify_keys(rest)]
+ end
+
+ def stringify_keys(key), do: key
+
defmacro guards_config(config_path) do
quote do
- initial_setting = Pleroma.Config.get(config_path)
+ initial_setting = Config.get(config_path)
- Pleroma.Config.put(config_path, true)
- on_exit(fn -> Pleroma.Config.put(config_path, initial_setting) end)
+ Config.put(config_path, true)
+ on_exit(fn -> Config.put(config_path, initial_setting) end)
end
end
end
diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex
index eba22c40b..3a95e92da 100644
--- a/test/support/http_request_mock.ex
+++ b/test/support/http_request_mock.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule HttpRequestMock do
@@ -19,7 +19,7 @@ defmodule HttpRequestMock do
else
error ->
with {:error, message} <- error do
- Logger.warn(message)
+ Logger.warn(to_string(message))
end
{_, _r} = error
@@ -107,7 +107,7 @@ defmodule HttpRequestMock do
"https://osada.macgirvin.com/.well-known/webfinger?resource=acct:mike@osada.macgirvin.com",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -120,7 +120,7 @@ defmodule HttpRequestMock do
"https://social.heldscal.la/.well-known/webfinger?resource=https://social.heldscal.la/user/29191",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -141,7 +141,7 @@ defmodule HttpRequestMock do
"https://pawoo.net/.well-known/webfinger?resource=acct:https://pawoo.net/users/pekorino",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -167,7 +167,7 @@ defmodule HttpRequestMock do
"https://social.stopwatchingus-heidelberg.de/.well-known/webfinger?resource=acct:https://social.stopwatchingus-heidelberg.de/user/18330",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -188,7 +188,7 @@ defmodule HttpRequestMock do
"https://mamot.fr/.well-known/webfinger?resource=acct:https://mamot.fr/users/Skruyb",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -201,7 +201,7 @@ defmodule HttpRequestMock do
"https://social.heldscal.la/.well-known/webfinger?resource=nonexistant@social.heldscal.la",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -211,10 +211,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://squeet.me/xrd/?uri=lain@squeet.me",
+ "https://squeet.me/xrd/?uri=acct:lain@squeet.me",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -227,7 +227,7 @@ defmodule HttpRequestMock do
"https://mst3k.interlinked.me/users/luciferMysticus",
_,
_,
- Accept: "application/activity+json"
+ [{"accept", "application/activity+json"}]
) do
{:ok,
%Tesla.Env{
@@ -248,7 +248,7 @@ defmodule HttpRequestMock do
"https://hubzilla.example.org/channel/kaniini",
_,
_,
- Accept: "application/activity+json"
+ [{"accept", "application/activity+json"}]
) do
{:ok,
%Tesla.Env{
@@ -257,7 +257,7 @@ defmodule HttpRequestMock do
}}
end
- def get("https://niu.moe/users/rye", _, _, Accept: "application/activity+json") do
+ def get("https://niu.moe/users/rye", _, _, [{"accept", "application/activity+json"}]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -265,7 +265,7 @@ defmodule HttpRequestMock do
}}
end
- def get("https://n1u.moe/users/rye", _, _, Accept: "application/activity+json") do
+ def get("https://n1u.moe/users/rye", _, _, [{"accept", "application/activity+json"}]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -284,7 +284,7 @@ defmodule HttpRequestMock do
}}
end
- def get("https://puckipedia.com/", _, _, Accept: "application/activity+json") do
+ def get("https://puckipedia.com/", _, _, [{"accept", "application/activity+json"}]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -308,6 +308,40 @@ defmodule HttpRequestMock do
}}
end
+ def get("https://peertube.social/accounts/craigmaloney", _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/craigmaloney.json")
+ }}
+ end
+
+ def get("https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe", _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/peertube-social.json")
+ }}
+ end
+
+ def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json")
+ }}
+ end
+
+ def get("https://mobilizon.org/@tcit", _, _, [{"accept", "application/activity+json"}]) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json")
+ }}
+ end
+
def get("https://baptiste.gelez.xyz/@/BaptisteGelez", _, _, _) do
{:ok,
%Tesla.Env{
@@ -340,7 +374,7 @@ defmodule HttpRequestMock do
}}
end
- def get("http://mastodon.example.org/users/admin", _, _, Accept: "application/activity+json") do
+ def get("http://mastodon.example.org/users/admin", _, _, _) do
{:ok,
%Tesla.Env{
status: 200,
@@ -348,7 +382,9 @@ defmodule HttpRequestMock do
}}
end
- def get("http://mastodon.example.org/users/relay", _, _, Accept: "application/activity+json") do
+ def get("http://mastodon.example.org/users/relay", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -356,7 +392,9 @@ defmodule HttpRequestMock do
}}
end
- def get("http://mastodon.example.org/users/gargron", _, _, Accept: "application/activity+json") do
+ def get("http://mastodon.example.org/users/gargron", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
{:error, :nxdomain}
end
@@ -539,7 +577,7 @@ defmodule HttpRequestMock do
"http://mastodon.example.org/@admin/99541947525187367",
_,
_,
- Accept: "application/activity+json"
+ _
) do
{:ok,
%Tesla.Env{
@@ -564,7 +602,7 @@ defmodule HttpRequestMock do
}}
end
- def get("https://mstdn.io/users/mayuutann", _, _, Accept: "application/activity+json") do
+ def get("https://mstdn.io/users/mayuutann", _, _, [{"accept", "application/activity+json"}]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -576,7 +614,7 @@ defmodule HttpRequestMock do
"https://mstdn.io/users/mayuutann/statuses/99568293732299394",
_,
_,
- Accept: "application/activity+json"
+ [{"accept", "application/activity+json"}]
) do
{:ok,
%Tesla.Env{
@@ -596,7 +634,7 @@ defmodule HttpRequestMock do
}}
end
- def get(url, _, _, Accept: "application/xrd+xml,application/jrd+json")
+ def get(url, _, _, [{"accept", "application/xrd+xml,application/jrd+json"}])
when url in [
"https://pleroma.soykaf.com/.well-known/webfinger?resource=acct:https://pleroma.soykaf.com/users/lain",
"https://pleroma.soykaf.com/.well-known/webfinger?resource=https://pleroma.soykaf.com/users/lain"
@@ -623,7 +661,7 @@ defmodule HttpRequestMock do
"https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/1",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -667,7 +705,7 @@ defmodule HttpRequestMock do
"https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/5381",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -720,7 +758,7 @@ defmodule HttpRequestMock do
"https://social.sakamoto.gq/.well-known/webfinger?resource=https://social.sakamoto.gq/users/eal",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -733,7 +771,7 @@ defmodule HttpRequestMock do
"https://social.sakamoto.gq/objects/0ccc1a2c-66b0-4305-b23a-7f7f2b040056",
_,
_,
- Accept: "application/atom+xml"
+ [{"accept", "application/atom+xml"}]
) do
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/sakamoto.atom")}}
end
@@ -750,7 +788,7 @@ defmodule HttpRequestMock do
"https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/lambadalambda",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -772,7 +810,7 @@ defmodule HttpRequestMock do
"http://gs.example.org/.well-known/webfinger?resource=http://gs.example.org:4040/index.php/user/1",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -786,7 +824,7 @@ defmodule HttpRequestMock do
"http://gs.example.org:4040/index.php/user/1",
_,
_,
- Accept: "application/activity+json"
+ [{"accept", "application/activity+json"}]
) do
{:ok, %Tesla.Env{status: 406, body: ""}}
end
@@ -822,7 +860,7 @@ defmodule HttpRequestMock do
"https://squeet.me/xrd?uri=lain@squeet.me",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -832,10 +870,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la",
+ "https://social.heldscal.la/.well-known/webfinger?resource=acct:shp@social.heldscal.la",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -845,10 +883,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://social.heldscal.la/.well-known/webfinger?resource=invalid_content@social.heldscal.la",
+ "https://social.heldscal.la/.well-known/webfinger?resource=acct:invalid_content@social.heldscal.la",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok, %Tesla.Env{status: 200, body: ""}}
end
@@ -862,10 +900,10 @@ defmodule HttpRequestMock do
end
def get(
- "http://framatube.org/main/xrd?uri=framasoft@framatube.org",
+ "http://framatube.org/main/xrd?uri=acct:framasoft@framatube.org",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -887,7 +925,7 @@ defmodule HttpRequestMock do
"http://gnusocial.de/main/xrd?uri=winterdienst@gnusocial.de",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -921,10 +959,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://gerzilla.de/xrd/?uri=kaniini@gerzilla.de",
+ "https://gerzilla.de/xrd/?uri=acct:kaniini@gerzilla.de",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -987,7 +1025,7 @@ defmodule HttpRequestMock do
%Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/osada-user-indio.json")}}
end
- def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do
+ def get("https://social.heldscal.la/user/23211", _, _, [{"accept", "application/activity+json"}]) do
{:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)}
end
@@ -1035,6 +1073,22 @@ defmodule HttpRequestMock do
}}
end
+ def get("http://localhost:8080/followers/fuser3", _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/users_mock/friendica_followers.json")
+ }}
+ end
+
+ def get("http://localhost:8080/following/fuser3", _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/users_mock/friendica_following.json")
+ }}
+ end
+
def get("http://localhost:4001/users/fuser2/followers", _, _, _) do
{:ok,
%Tesla.Env{
@@ -1101,10 +1155,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=lain@zetsubou.xn--q9jyb4c",
+ "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:lain@zetsubou.xn--q9jyb4c",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -1114,10 +1168,10 @@ defmodule HttpRequestMock do
end
def get(
- "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=https://zetsubou.xn--q9jyb4c/users/lain",
+ "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:https://zetsubou.xn--q9jyb4c/users/lain",
_,
_,
- Accept: "application/xrd+xml,application/jrd+json"
+ [{"accept", "application/xrd+xml,application/jrd+json"}]
) do
{:ok,
%Tesla.Env{
@@ -1139,7 +1193,9 @@ defmodule HttpRequestMock do
}}
end
- def get("https://info.pleroma.site/activity.json", _, _, Accept: "application/activity+json") do
+ def get("https://info.pleroma.site/activity.json", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -1151,7 +1207,9 @@ defmodule HttpRequestMock do
{:ok, %Tesla.Env{status: 404, body: ""}}
end
- def get("https://info.pleroma.site/activity2.json", _, _, Accept: "application/activity+json") do
+ def get("https://info.pleroma.site/activity2.json", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -1163,7 +1221,9 @@ defmodule HttpRequestMock do
{:ok, %Tesla.Env{status: 404, body: ""}}
end
- def get("https://info.pleroma.site/activity3.json", _, _, Accept: "application/activity+json") do
+ def get("https://info.pleroma.site/activity3.json", _, _, [
+ {"accept", "application/activity+json"}
+ ]) do
{:ok,
%Tesla.Env{
status: 200,
@@ -1183,6 +1243,30 @@ defmodule HttpRequestMock do
}}
end
+ def get("https://10.111.10.1/notice/9kCP7V", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end
+
+ def get("https://172.16.32.40/notice/9kCP7V", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end
+
+ def get("https://192.168.10.40/notice/9kCP7V", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end
+
+ def get("https://www.patreon.com/posts/mastodon-2-9-and-28121681", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end
+
+ def get("http://mastodon.example.org/@admin/99541947525187367", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/mastodon-post-activity.json")}}
+ end
+
+ def get("https://info.pleroma.site/activity4.json", _, _, _) do
+ {:ok, %Tesla.Env{status: 500, body: "Error occurred"}}
+ end
+
def get("http://example.com/rel_me/anchor", _, _, _) do
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/rel_me_anchor.html")}}
end
@@ -1215,6 +1299,29 @@ defmodule HttpRequestMock do
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/rin.json")}}
end
+ def get(
+ "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871",
+ _,
+ _,
+ _
+ ) do
+ {:ok,
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_audio.json")}}
+ end
+
+ def get("https://channels.tests.funkwhale.audio/federation/actors/compositions", _, _, _) do
+ {:ok,
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json")}}
+ end
+
+ def get("http://example.com/rel_me/error", _, _, _) do
+ {:ok, %Tesla.Env{status: 404, body: ""}}
+ end
+
+ def get("https://relay.mastodon.host/actor", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/relay/relay.json")}}
+ end
+
def get(url, query, body, headers) do
{:error,
"Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{
@@ -1227,6 +1334,10 @@ defmodule HttpRequestMock do
def post(url, query \\ [], body \\ [], headers \\ [])
+ def post("https://relay.mastodon.host/inbox", _, _, _) do
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end
+
def post("http://example.org/needs_refresh", _, _, _) do
{:ok,
%Tesla.Env{
diff --git a/test/support/mrf_module_mock.ex b/test/support/mrf_module_mock.ex
index 632c7ff1d..028ea542a 100644
--- a/test/support/mrf_module_mock.ex
+++ b/test/support/mrf_module_mock.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule MRFModuleMock do
diff --git a/test/support/oban_helpers.ex b/test/support/oban_helpers.ex
index 72792c064..e96994c57 100644
--- a/test/support/oban_helpers.ex
+++ b/test/support/oban_helpers.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Tests.ObanHelpers do
@@ -9,6 +9,10 @@ defmodule Pleroma.Tests.ObanHelpers do
alias Pleroma.Repo
+ def wipe_all do
+ Repo.delete_all(Oban.Job)
+ end
+
def perform_all do
Oban.Job
|> Repo.all()
diff --git a/test/support/web_push_http_client_mock.ex b/test/support/web_push_http_client_mock.ex
index 1d6ccff7e..3cd12957d 100644
--- a/test/support/web_push_http_client_mock.ex
+++ b/test/support/web_push_http_client_mock.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.WebPushHttpClientMock do
diff --git a/test/support/websocket_client.ex b/test/support/websocket_client.ex
index 121231452..8c9d4b2b4 100644
--- a/test/support/websocket_client.ex
+++ b/test/support/websocket_client.ex
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Integration.WebsocketClient do
diff --git a/test/tasks/app_test.exs b/test/tasks/app_test.exs
new file mode 100644
index 000000000..b8f03566d
--- /dev/null
+++ b/test/tasks/app_test.exs
@@ -0,0 +1,65 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Mix.Tasks.Pleroma.AppTest do
+ use Pleroma.DataCase, async: true
+
+ setup_all do
+ Mix.shell(Mix.Shell.Process)
+
+ on_exit(fn ->
+ Mix.shell(Mix.Shell.IO)
+ end)
+ end
+
+ describe "creates new app" do
+ test "with default scopes" do
+ name = "Some name"
+ redirect = "https://example.com"
+ Mix.Tasks.Pleroma.App.run(["create", "-n", name, "-r", redirect])
+
+ assert_app(name, redirect, ["read", "write", "follow", "push"])
+ end
+
+ test "with custom scopes" do
+ name = "Another name"
+ redirect = "https://example.com"
+
+ Mix.Tasks.Pleroma.App.run([
+ "create",
+ "-n",
+ name,
+ "-r",
+ redirect,
+ "-s",
+ "read,write,follow,push,admin"
+ ])
+
+ assert_app(name, redirect, ["read", "write", "follow", "push", "admin"])
+ end
+ end
+
+ test "with errors" do
+ Mix.Tasks.Pleroma.App.run(["create"])
+ {:mix_shell, :error, ["Creating failed:"]}
+ {:mix_shell, :error, ["name: can't be blank"]}
+ {:mix_shell, :error, ["redirect_uris: can't be blank"]}
+ end
+
+ defp assert_app(name, redirect, scopes) do
+ app = Repo.get_by(Pleroma.Web.OAuth.App, client_name: name)
+
+ assert_received {:mix_shell, :info, [message]}
+ assert message == "#{name} successfully created:"
+
+ assert_received {:mix_shell, :info, [message]}
+ assert message == "App client_id: #{app.client_id}"
+
+ assert_received {:mix_shell, :info, [message]}
+ assert message == "App client_secret: #{app.client_secret}"
+
+ assert app.scopes == scopes
+ assert app.redirect_uris == redirect
+ end
+end
diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs
index 9cd47380c..04bc947a9 100644
--- a/test/tasks/config_test.exs
+++ b/test/tasks/config_test.exs
@@ -1,66 +1,195 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.ConfigTest do
use Pleroma.DataCase
+
+ alias Pleroma.ConfigDB
alias Pleroma.Repo
- alias Pleroma.Web.AdminAPI.Config
setup_all do
Mix.shell(Mix.Shell.Process)
- temp_file = "config/temp.exported_from_db.secret.exs"
on_exit(fn ->
Mix.shell(Mix.Shell.IO)
Application.delete_env(:pleroma, :first_setting)
Application.delete_env(:pleroma, :second_setting)
- :ok = File.rm(temp_file)
end)
- {:ok, temp_file: temp_file}
+ :ok
end
- clear_config_all([:instance, :dynamic_configuration]) do
- Pleroma.Config.put([:instance, :dynamic_configuration], true)
+ setup_all do: clear_config(:configurable_from_database, true)
+
+ test "error if file with custom settings doesn't exist" do
+ Mix.Tasks.Pleroma.Config.migrate_to_db("config/not_existance_config_file.exs")
+
+ assert_receive {:mix_shell, :info,
+ [
+ "To migrate settings, you must define custom settings in config/not_existance_config_file.exs."
+ ]},
+ 15
end
- test "settings are migrated to db" do
- assert Repo.all(Config) == []
+ describe "migrate_to_db/1" do
+ setup do
+ initial = Application.get_env(:quack, :level)
+ on_exit(fn -> Application.put_env(:quack, :level, initial) end)
+ end
+
+ test "filtered settings are migrated to db" do
+ assert Repo.all(ConfigDB) == []
- Application.put_env(:pleroma, :first_setting, key: "value", key2: [Pleroma.Repo])
- Application.put_env(:pleroma, :second_setting, key: "value2", key2: [Pleroma.Activity])
+ Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs")
- Mix.Tasks.Pleroma.Config.run(["migrate_to_db"])
+ config1 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"})
+ config2 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":second_setting"})
+ config3 = ConfigDB.get_by_params(%{group: ":quack", key: ":level"})
+ refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"})
+ refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"})
- first_db = Config.get_by_params(%{group: "pleroma", key: ":first_setting"})
- second_db = Config.get_by_params(%{group: "pleroma", key: ":second_setting"})
- refute Config.get_by_params(%{group: "pleroma", key: "Pleroma.Repo"})
+ assert ConfigDB.from_binary(config1.value) == [key: "value", key2: [Repo]]
+ assert ConfigDB.from_binary(config2.value) == [key: "value2", key2: ["Activity"]]
+ assert ConfigDB.from_binary(config3.value) == :info
+ end
- assert Config.from_binary(first_db.value) == [key: "value", key2: [Pleroma.Repo]]
- assert Config.from_binary(second_db.value) == [key: "value2", key2: [Pleroma.Activity]]
+ test "config table is truncated before migration" do
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":first_setting",
+ value: [key: "value", key2: ["Activity"]]
+ })
+
+ assert Repo.aggregate(ConfigDB, :count, :id) == 1
+
+ Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs")
+
+ config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"})
+ assert ConfigDB.from_binary(config.value) == [key: "value", key2: [Repo]]
+ end
end
- test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do
- Config.create(%{
- group: "pleroma",
- key: ":setting_first",
- value: [key: "value", key2: [Pleroma.Activity]]
- })
+ describe "with deletion temp file" do
+ setup do
+ temp_file = "config/temp.exported_from_db.secret.exs"
+
+ on_exit(fn ->
+ :ok = File.rm(temp_file)
+ end)
+
+ {:ok, temp_file: temp_file}
+ end
+
+ test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":setting_first",
+ value: [key: "value", key2: ["Activity"]]
+ })
+
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":setting_second",
+ value: [key: "value2", key2: [Repo]]
+ })
+
+ ConfigDB.create(%{group: ":quack", key: ":level", value: :info})
+
+ Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"])
+
+ assert Repo.all(ConfigDB) == []
+
+ file = File.read!(temp_file)
+ assert file =~ "config :pleroma, :setting_first,"
+ assert file =~ "config :pleroma, :setting_second,"
+ assert file =~ "config :quack, :level, :info"
+ end
+
+ test "load a settings with large values and pass to file", %{temp_file: temp_file} do
+ ConfigDB.create(%{
+ group: ":pleroma",
+ key: ":instance",
+ value: [
+ name: "Pleroma",
+ email: "example@example.com",
+ notify_email: "noreply@example.com",
+ description: "A Pleroma instance, an alternative fediverse server",
+ limit: 5_000,
+ chat_limit: 5_000,
+ remote_limit: 100_000,
+ upload_limit: 16_000_000,
+ avatar_upload_limit: 2_000_000,
+ background_upload_limit: 4_000_000,
+ banner_upload_limit: 4_000_000,
+ poll_limits: %{
+ max_options: 20,
+ max_option_chars: 200,
+ min_expiration: 0,
+ max_expiration: 365 * 24 * 60 * 60
+ },
+ registrations_open: true,
+ federating: true,
+ federation_incoming_replies_max_depth: 100,
+ federation_reachability_timeout_days: 7,
+ federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],
+ allow_relay: true,
+ rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,
+ public: true,
+ quarantined_instances: [],
+ managed_config: true,
+ static_dir: "instance/static/",
+ allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"],
+ mrf_transparency: true,
+ mrf_transparency_exclusions: [],
+ autofollowed_nicknames: [],
+ max_pinned_statuses: 1,
+ attachment_links: false,
+ welcome_user_nickname: nil,
+ welcome_message: nil,
+ max_report_comment_size: 1000,
+ safe_dm_mentions: false,
+ healthcheck: false,
+ remote_post_retention_days: 90,
+ skip_thread_containment: true,
+ limit_to_local_content: :unauthenticated,
+ user_bio_length: 5000,
+ user_name_length: 100,
+ max_account_fields: 10,
+ max_remote_account_fields: 20,
+ account_field_name_length: 512,
+ account_field_value_length: 2048,
+ external_user_synchronization: true,
+ extended_nickname_format: true,
+ multi_factor_authentication: [
+ totp: [
+ # digits 6 or 8
+ digits: 6,
+ period: 30
+ ],
+ backup_codes: [
+ number: 2,
+ length: 6
+ ]
+ ]
+ ]
+ })
- Config.create(%{
- group: "pleroma",
- key: ":setting_second",
- value: [key: "valu2", key2: [Pleroma.Repo]]
- })
+ Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"])
- Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "temp", "true"])
+ assert Repo.all(ConfigDB) == []
+ assert File.exists?(temp_file)
+ {:ok, file} = File.read(temp_file)
- assert Repo.all(Config) == []
- assert File.exists?(temp_file)
- {:ok, file} = File.read(temp_file)
+ header =
+ if Code.ensure_loaded?(Config.Reader) do
+ "import Config"
+ else
+ "use Mix.Config"
+ end
- assert file =~ "config :pleroma, :setting_first,"
- assert file =~ "config :pleroma, :setting_second,"
+ assert file ==
+ "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n mrf_transparency: true,\n mrf_transparency_exclusions: [],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n"
+ end
end
end
diff --git a/test/tasks/count_statuses_test.exs b/test/tasks/count_statuses_test.exs
index 6035da3c3..c5cd16960 100644
--- a/test/tasks/count_statuses_test.exs
+++ b/test/tasks/count_statuses_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.CountStatusesTest do
@@ -13,27 +13,27 @@ defmodule Mix.Tasks.Pleroma.CountStatusesTest do
test "counts statuses" do
user = insert(:user)
- {:ok, _} = CommonAPI.post(user, %{"status" => "test"})
- {:ok, _} = CommonAPI.post(user, %{"status" => "test2"})
+ {:ok, _} = CommonAPI.post(user, %{status: "test"})
+ {:ok, _} = CommonAPI.post(user, %{status: "test2"})
user2 = insert(:user)
- {:ok, _} = CommonAPI.post(user2, %{"status" => "test3"})
+ {:ok, _} = CommonAPI.post(user2, %{status: "test3"})
user = refresh_record(user)
user2 = refresh_record(user2)
- assert %{info: %{note_count: 2}} = user
- assert %{info: %{note_count: 1}} = user2
+ assert %{note_count: 2} = user
+ assert %{note_count: 1} = user2
- {:ok, user} = User.update_info(user, &User.Info.set_note_count(&1, 0))
- {:ok, user2} = User.update_info(user2, &User.Info.set_note_count(&1, 0))
+ {:ok, user} = User.update_note_count(user, 0)
+ {:ok, user2} = User.update_note_count(user2, 0)
- assert %{info: %{note_count: 0}} = user
- assert %{info: %{note_count: 0}} = user2
+ assert %{note_count: 0} = user
+ assert %{note_count: 0} = user2
assert capture_io(fn -> Mix.Tasks.Pleroma.CountStatuses.run([]) end) == "Done\n"
- assert %{info: %{note_count: 2}} = refresh_record(user)
- assert %{info: %{note_count: 1}} = refresh_record(user2)
+ assert %{note_count: 2} = refresh_record(user)
+ assert %{note_count: 1} = refresh_record(user2)
end
end
diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs
index b63dcac00..883828d77 100644
--- a/test/tasks/database_test.exs
+++ b/test/tasks/database_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.DatabaseTest do
@@ -26,7 +26,7 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
describe "running remove_embedded_objects" do
test "it replaces objects with references" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test"})
new_data = Map.put(activity.data, "object", activity.object.data)
{:ok, activity} =
@@ -72,26 +72,26 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
describe "running update_users_following_followers_counts" do
test "following and followers count are updated" do
[user, user2] = insert_pair(:user)
- {:ok, %User{following: following, info: info} = user} = User.follow(user, user2)
+ {:ok, %User{} = user} = User.follow(user, user2)
+
+ following = User.following(user)
assert length(following) == 2
- assert info.follower_count == 0
+ assert user.follower_count == 0
{:ok, user} =
user
- |> Ecto.Changeset.change(%{following: following ++ following})
- |> User.change_info(&Ecto.Changeset.change(&1, %{follower_count: 3}))
+ |> Ecto.Changeset.change(%{follower_count: 3})
|> Repo.update()
- assert length(user.following) == 4
- assert user.info.follower_count == 3
+ assert user.follower_count == 3
assert :ok == Mix.Tasks.Pleroma.Database.run(["update_users_following_followers_counts"])
user = User.get_by_id(user.id)
- assert length(user.following) == 2
- assert user.info.follower_count == 0
+ assert length(User.following(user)) == 2
+ assert user.follower_count == 0
end
end
@@ -99,10 +99,10 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
test "it turns OrderedCollection likes into empty arrays" do
[user, user2] = insert_pair(:user)
- {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{"status" => "test"})
- {:ok, %{object: object2}} = CommonAPI.post(user, %{"status" => "test test"})
+ {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{status: "test"})
+ {:ok, %{object: object2}} = CommonAPI.post(user, %{status: "test test"})
- CommonAPI.favorite(id, user2)
+ CommonAPI.favorite(user2, id)
likes = %{
"first" =>
diff --git a/test/tasks/digest_test.exs b/test/tasks/digest_test.exs
index 96d762685..eefbc8936 100644
--- a/test/tasks/digest_test.exs
+++ b/test/tasks/digest_test.exs
@@ -25,7 +25,7 @@ defmodule Mix.Tasks.Pleroma.DigestTest do
Enum.each(0..10, fn i ->
{:ok, _activity} =
CommonAPI.post(user1, %{
- "status" => "hey ##{i} @#{user2.nickname}!"
+ status: "hey ##{i} @#{user2.nickname}!"
})
end)
diff --git a/test/tasks/ecto/ecto_test.exs b/test/tasks/ecto/ecto_test.exs
index a1b9ca174..3a028df83 100644
--- a/test/tasks/ecto/ecto_test.exs
+++ b/test/tasks/ecto/ecto_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.EctoTest do
diff --git a/test/tasks/ecto/migrate_test.exs b/test/tasks/ecto/migrate_test.exs
index 42f6cbf47..43df176a1 100644
--- a/test/tasks/ecto/migrate_test.exs
+++ b/test/tasks/ecto/migrate_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-onl
defmodule Mix.Tasks.Pleroma.Ecto.MigrateTest do
diff --git a/test/tasks/ecto/rollback_test.exs b/test/tasks/ecto/rollback_test.exs
index c33c4e940..0236e35d5 100644
--- a/test/tasks/ecto/rollback_test.exs
+++ b/test/tasks/ecto/rollback_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.Ecto.RollbackTest do
diff --git a/test/tasks/email_test.exs b/test/tasks/email_test.exs
new file mode 100644
index 000000000..944c07064
--- /dev/null
+++ b/test/tasks/email_test.exs
@@ -0,0 +1,52 @@
+defmodule Mix.Tasks.Pleroma.EmailTest do
+ use Pleroma.DataCase
+
+ import Swoosh.TestAssertions
+
+ alias Pleroma.Config
+ alias Pleroma.Tests.ObanHelpers
+
+ setup_all do
+ Mix.shell(Mix.Shell.Process)
+
+ on_exit(fn ->
+ Mix.shell(Mix.Shell.IO)
+ end)
+
+ :ok
+ end
+
+ describe "pleroma.email test" do
+ test "Sends test email with no given address" do
+ mail_to = Config.get([:instance, :email])
+
+ :ok = Mix.Tasks.Pleroma.Email.run(["test"])
+
+ ObanHelpers.perform_all()
+
+ assert_receive {:mix_shell, :info, [message]}
+ assert message =~ "Test email has been sent"
+
+ assert_email_sent(
+ to: mail_to,
+ html_body: ~r/a test email was requested./i
+ )
+ end
+
+ test "Sends test email with given address" do
+ mail_to = "hewwo@example.com"
+
+ :ok = Mix.Tasks.Pleroma.Email.run(["test", "--to", mail_to])
+
+ ObanHelpers.perform_all()
+
+ assert_receive {:mix_shell, :info, [message]}
+ assert message =~ "Test email has been sent"
+
+ assert_email_sent(
+ to: mail_to,
+ html_body: ~r/a test email was requested./i
+ )
+ end
+ end
+end
diff --git a/test/tasks/emoji_test.exs b/test/tasks/emoji_test.exs
new file mode 100644
index 000000000..f5de3ef0e
--- /dev/null
+++ b/test/tasks/emoji_test.exs
@@ -0,0 +1,226 @@
+defmodule Mix.Tasks.Pleroma.EmojiTest do
+ use ExUnit.Case, async: true
+
+ import ExUnit.CaptureIO
+ import Tesla.Mock
+
+ alias Mix.Tasks.Pleroma.Emoji
+
+ describe "ls-packs" do
+ test "with default manifest as url" do
+ mock(fn
+ %{
+ method: :get,
+ url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/default-manifest.json")
+ }
+ end)
+
+ capture_io(fn -> Emoji.run(["ls-packs"]) end) =~
+ "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip"
+ end
+
+ test "with passed manifest as file" do
+ capture_io(fn ->
+ Emoji.run(["ls-packs", "-m", "test/fixtures/emoji/packs/manifest.json"])
+ end) =~ "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip"
+ end
+ end
+
+ describe "get-packs" do
+ test "download pack from default manifest" do
+ mock(fn
+ %{
+ method: :get,
+ url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/default-manifest.json")
+ }
+
+ %{
+ method: :get,
+ url: "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/blank.png.zip")
+ }
+
+ %{
+ method: :get,
+ url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/finmoji.json"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/finmoji.json")
+ }
+ end)
+
+ assert capture_io(fn -> Emoji.run(["get-packs", "finmoji"]) end) =~ "Writing pack.json for"
+
+ emoji_path =
+ Path.join(
+ Pleroma.Config.get!([:instance, :static_dir]),
+ "emoji"
+ )
+
+ assert File.exists?(Path.join([emoji_path, "finmoji", "pack.json"]))
+ on_exit(fn -> File.rm_rf!("test/instance_static/emoji/finmoji") end)
+ end
+
+ test "pack not found" do
+ mock(fn
+ %{
+ method: :get,
+ url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/default-manifest.json")
+ }
+ end)
+
+ assert capture_io(fn -> Emoji.run(["get-packs", "not_found"]) end) =~
+ "No pack named \"not_found\" found"
+ end
+
+ test "raise on bad sha256" do
+ mock(fn
+ %{
+ method: :get,
+ url: "https://git.pleroma.social/pleroma/emoji-index/raw/master/packs/blobs_gg.zip"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/emoji/packs/blank.png.zip")
+ }
+ end)
+
+ assert_raise RuntimeError, ~r/^Bad SHA256 for blobs.gg/, fn ->
+ capture_io(fn ->
+ Emoji.run(["get-packs", "blobs.gg", "-m", "test/fixtures/emoji/packs/manifest.json"])
+ end)
+ end
+ end
+ end
+
+ describe "gen-pack" do
+ setup do
+ url = "https://finland.fi/wp-content/uploads/2017/06/finland-emojis.zip"
+
+ mock(fn %{
+ method: :get,
+ url: ^url
+ } ->
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/emoji/packs/blank.png.zip")}
+ end)
+
+ {:ok, url: url}
+ end
+
+ test "with default extensions", %{url: url} do
+ name = "pack1"
+ pack_json = "#{name}.json"
+ files_json = "#{name}_file.json"
+ refute File.exists?(pack_json)
+ refute File.exists?(files_json)
+
+ captured =
+ capture_io(fn ->
+ Emoji.run([
+ "gen-pack",
+ url,
+ "--name",
+ name,
+ "--license",
+ "license",
+ "--homepage",
+ "homepage",
+ "--description",
+ "description",
+ "--files",
+ files_json,
+ "--extensions",
+ ".png .gif"
+ ])
+ end)
+
+ assert captured =~ "#{pack_json} has been created with the pack1 pack"
+ assert captured =~ "Using .png .gif extensions"
+
+ assert File.exists?(pack_json)
+ assert File.exists?(files_json)
+
+ on_exit(fn ->
+ File.rm!(pack_json)
+ File.rm!(files_json)
+ end)
+ end
+
+ test "with custom extensions and update existing files", %{url: url} do
+ name = "pack2"
+ pack_json = "#{name}.json"
+ files_json = "#{name}_file.json"
+ refute File.exists?(pack_json)
+ refute File.exists?(files_json)
+
+ captured =
+ capture_io(fn ->
+ Emoji.run([
+ "gen-pack",
+ url,
+ "--name",
+ name,
+ "--license",
+ "license",
+ "--homepage",
+ "homepage",
+ "--description",
+ "description",
+ "--files",
+ files_json,
+ "--extensions",
+ " .png .gif .jpeg "
+ ])
+ end)
+
+ assert captured =~ "#{pack_json} has been created with the pack2 pack"
+ assert captured =~ "Using .png .gif .jpeg extensions"
+
+ assert File.exists?(pack_json)
+ assert File.exists?(files_json)
+
+ captured =
+ capture_io(fn ->
+ Emoji.run([
+ "gen-pack",
+ url,
+ "--name",
+ name,
+ "--license",
+ "license",
+ "--homepage",
+ "homepage",
+ "--description",
+ "description",
+ "--files",
+ files_json,
+ "--extensions",
+ " .png .gif .jpeg "
+ ])
+ end)
+
+ assert captured =~ "#{pack_json} has been updated with the pack2 pack"
+
+ on_exit(fn ->
+ File.rm!(pack_json)
+ File.rm!(files_json)
+ end)
+ end
+ end
+end
diff --git a/test/tasks/instance_test.exs b/test/tasks/instance_test.exs
index 6d7eed4c1..f6a4ba508 100644
--- a/test/tasks/instance_test.exs
+++ b/test/tasks/instance_test.exs
@@ -1,9 +1,9 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.InstanceTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case
setup do
File.mkdir_p!(tmp_path())
@@ -15,6 +15,8 @@ defmodule Pleroma.InstanceTest do
if File.exists?(static_dir) do
File.rm_rf(Path.join(static_dir, "robots.txt"))
end
+
+ Pleroma.Config.put([:instance, :static_dir], static_dir)
end)
:ok
@@ -78,7 +80,7 @@ defmodule Pleroma.InstanceTest do
assert generated_config =~ "database: \"dbname\""
assert generated_config =~ "username: \"dbuser\""
assert generated_config =~ "password: \"dbpass\""
- assert generated_config =~ "dynamic_configuration: true"
+ assert generated_config =~ "configurable_from_database: true"
assert generated_config =~ "http: [ip: {127, 0, 0, 1}, port: 4000]"
assert File.read!(tmp_path() <> "setup.psql") == generated_setup_psql()
end
diff --git a/test/tasks/pleroma_test.exs b/test/tasks/pleroma_test.exs
index a20bd9cf2..c3e47b285 100644
--- a/test/tasks/pleroma_test.exs
+++ b/test/tasks/pleroma_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.PleromaTest do
diff --git a/test/tasks/refresh_counter_cache_test.exs b/test/tasks/refresh_counter_cache_test.exs
new file mode 100644
index 000000000..851971a77
--- /dev/null
+++ b/test/tasks/refresh_counter_cache_test.exs
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Mix.Tasks.Pleroma.RefreshCounterCacheTest do
+ use Pleroma.DataCase
+ alias Pleroma.Web.CommonAPI
+ import ExUnit.CaptureIO, only: [capture_io: 1]
+ import Pleroma.Factory
+
+ test "counts statuses" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ CommonAPI.post(user, %{visibility: "public", status: "hey"})
+
+ Enum.each(0..1, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "unlisted",
+ status: "hey"
+ })
+ end)
+
+ Enum.each(0..2, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "direct",
+ status: "hey @#{other_user.nickname}"
+ })
+ end)
+
+ Enum.each(0..3, fn _ ->
+ CommonAPI.post(user, %{
+ visibility: "private",
+ status: "hey"
+ })
+ end)
+
+ assert capture_io(fn -> Mix.Tasks.Pleroma.RefreshCounterCache.run([]) end) =~ "Done\n"
+
+ assert %{direct: 3, private: 4, public: 1, unlisted: 2} =
+ Pleroma.Stats.get_status_visibility_count()
+ end
+end
diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs
index c866608ab..d3d88467d 100644
--- a/test/tasks/relay_test.exs
+++ b/test/tasks/relay_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.RelayTest do
@@ -38,6 +38,9 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
assert activity.data["type"] == "Follow"
assert activity.data["actor"] == local_user.ap_id
assert activity.data["object"] == target_user.ap_id
+
+ :ok = Mix.Tasks.Pleroma.Relay.run(["list"])
+ assert_receive {:mix_shell, :info, ["mastodon.example.org (no Accept received)"]}
end
end
@@ -51,7 +54,7 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
target_user = User.get_cached_by_ap_id(target_instance)
follow_activity = Utils.fetch_latest_follow(local_user, target_user)
User.follow(local_user, target_user)
- assert "#{target_instance}/followers" in refresh_record(local_user).following
+ assert "#{target_instance}/followers" in User.following(local_user)
Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance])
cancelled_activity = Activity.get_by_ap_id(follow_activity.data["id"])
@@ -68,7 +71,7 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
assert undo_activity.data["type"] == "Undo"
assert undo_activity.data["actor"] == local_user.ap_id
assert undo_activity.data["object"] == cancelled_activity.data
- refute "#{target_instance}/followers" in refresh_record(local_user).following
+ refute "#{target_instance}/followers" in User.following(local_user)
end
end
@@ -78,20 +81,18 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
refute_receive {:mix_shell, :info, _}
- Pleroma.Web.ActivityPub.Relay.get_actor()
- |> Ecto.Changeset.change(
- following: [
- "http://test-app.com/user/test1",
- "http://test-app.com/user/test1",
- "http://test-app-42.com/user/test1"
- ]
- )
- |> Pleroma.User.update_and_set_cache()
+ relay_user = Relay.get_actor()
+
+ ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"]
+ |> Enum.each(fn ap_id ->
+ {:ok, user} = User.get_or_fetch_by_ap_id(ap_id)
+ User.follow(relay_user, user)
+ end)
:ok = Mix.Tasks.Pleroma.Relay.run(["list"])
- assert_receive {:mix_shell, :info, ["test-app.com"]}
- assert_receive {:mix_shell, :info, ["test-app-42.com"]}
+ assert_receive {:mix_shell, :info, ["mstdn.io"]}
+ assert_receive {:mix_shell, :info, ["mastodon.example.org"]}
end
end
end
diff --git a/test/tasks/robots_txt_test.exs b/test/tasks/robots_txt_test.exs
index 917df2675..7040a0e4e 100644
--- a/test/tasks/robots_txt_test.exs
+++ b/test/tasks/robots_txt_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.RobotsTxtTest do
@@ -7,7 +7,7 @@ defmodule Mix.Tasks.Pleroma.RobotsTxtTest do
use Pleroma.Tests.Helpers
alias Mix.Tasks.Pleroma.RobotsTxt
- clear_config([:instance, :static_dir])
+ setup do: clear_config([:instance, :static_dir])
test "creates new dir" do
path = "test/fixtures/new_dir/"
diff --git a/test/tasks/uploads_test.exs b/test/tasks/uploads_test.exs
index b0b8eda11..d69e149a8 100644
--- a/test/tasks/uploads_test.exs
+++ b/test/tasks/uploads_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.UploadsTest do
diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs
index cf12d9ed6..4aa873f0b 100644
--- a/test/tasks/user_test.exs
+++ b/test/tasks/user_test.exs
@@ -1,17 +1,23 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.UserTest do
+ alias Pleroma.Activity
+ alias Pleroma.Object
alias Pleroma.Repo
+ alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token
use Pleroma.DataCase
+ use Oban.Testing, repo: Pleroma.Repo
- import Pleroma.Factory
import ExUnit.CaptureIO
+ import Mock
+ import Pleroma.Factory
setup_all do
Mix.shell(Mix.Shell.Process)
@@ -58,8 +64,8 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert user.name == unsaved.name
assert user.email == unsaved.email
assert user.bio == unsaved.bio
- assert user.info.is_moderator
- assert user.info.is_admin
+ assert user.is_moderator
+ assert user.is_admin
end
test "user is not created" do
@@ -87,12 +93,39 @@ defmodule Mix.Tasks.Pleroma.UserTest do
test "user is deleted" do
user = insert(:user)
- Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
+ ObanHelpers.perform_all()
- assert_received {:mix_shell, :info, [message]}
- assert message =~ " deleted"
+ assert_received {:mix_shell, :info, [message]}
+ assert message =~ " deleted"
+ assert %{deactivated: true} = User.get_by_nickname(user.nickname)
+
+ assert called(Pleroma.Web.Federator.publish(:_))
+ end
+ end
+
+ test "a remote user's create activity is deleted when the object has been pruned" do
+ user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "uguu"})
+ object = Object.normalize(post)
+ Object.prune(object)
+
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
+ ObanHelpers.perform_all()
- refute User.get_by_nickname(user.nickname)
+ assert_received {:mix_shell, :info, [message]}
+ assert message =~ " deleted"
+ assert %{deactivated: true} = User.get_by_nickname(user.nickname)
+
+ assert called(Pleroma.Web.Federator.publish(:_))
+ end
+
+ refute Activity.get_by_id(post.id)
end
test "no user to delete" do
@@ -113,11 +146,11 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert message =~ " deactivated"
user = User.get_cached_by_nickname(user.nickname)
- assert user.info.deactivated
+ assert user.deactivated
end
test "user is activated" do
- user = insert(:user, info: %{deactivated: true})
+ user = insert(:user, deactivated: true)
Mix.Tasks.Pleroma.User.run(["toggle_activated", user.nickname])
@@ -125,7 +158,7 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert message =~ " activated"
user = User.get_cached_by_nickname(user.nickname)
- refute user.info.deactivated
+ refute user.deactivated
end
test "no user to toggle" do
@@ -139,7 +172,8 @@ defmodule Mix.Tasks.Pleroma.UserTest do
describe "running unsubscribe" do
test "user is unsubscribed" do
followed = insert(:user)
- user = insert(:user, %{following: [User.ap_followers(followed)]})
+ user = insert(:user)
+ User.follow(user, followed, :follow_accept)
Mix.Tasks.Pleroma.User.run(["unsubscribe", user.nickname])
@@ -154,8 +188,8 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert message =~ "Successfully unsubscribed"
user = User.get_cached_by_nickname(user.nickname)
- assert Enum.empty?(user.following)
- assert user.info.deactivated
+ assert Enum.empty?(User.get_friends(user))
+ assert user.deactivated
end
test "no user to unsubscribe" do
@@ -182,13 +216,13 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert message =~ ~r/Admin status .* true/
user = User.get_cached_by_nickname(user.nickname)
- assert user.info.is_moderator
- assert user.info.locked
- assert user.info.is_admin
+ assert user.is_moderator
+ assert user.locked
+ assert user.is_admin
end
test "All statuses unset" do
- user = insert(:user, info: %{is_moderator: true, locked: true, is_admin: true})
+ user = insert(:user, locked: true, is_moderator: true, is_admin: true)
Mix.Tasks.Pleroma.User.run([
"set",
@@ -208,9 +242,9 @@ defmodule Mix.Tasks.Pleroma.UserTest do
assert message =~ ~r/Admin status .* false/
user = User.get_cached_by_nickname(user.nickname)
- refute user.info.is_moderator
- refute user.info.locked
- refute user.info.is_admin
+ refute user.is_moderator
+ refute user.locked
+ refute user.is_admin
end
test "no user to set status" do
@@ -358,28 +392,28 @@ defmodule Mix.Tasks.Pleroma.UserTest do
describe "running toggle_confirmed" do
test "user is confirmed" do
- %{id: id, nickname: nickname} = insert(:user, info: %{confirmation_pending: false})
+ %{id: id, nickname: nickname} = insert(:user, confirmation_pending: false)
assert :ok = Mix.Tasks.Pleroma.User.run(["toggle_confirmed", nickname])
assert_received {:mix_shell, :info, [message]}
assert message == "#{nickname} needs confirmation."
user = Repo.get(User, id)
- assert user.info.confirmation_pending
- assert user.info.confirmation_token
+ assert user.confirmation_pending
+ assert user.confirmation_token
end
test "user is not confirmed" do
%{id: id, nickname: nickname} =
- insert(:user, info: %{confirmation_pending: true, confirmation_token: "some token"})
+ insert(:user, confirmation_pending: true, confirmation_token: "some token")
assert :ok = Mix.Tasks.Pleroma.User.run(["toggle_confirmed", nickname])
assert_received {:mix_shell, :info, [message]}
assert message == "#{nickname} doesn't need confirmation."
user = Repo.get(User, id)
- refute user.info.confirmation_pending
- refute user.info.confirmation_token
+ refute user.confirmation_pending
+ refute user.confirmation_token
end
test "it prints an error message when user is not exist" do
diff --git a/test/test_helper.exs b/test/test_helper.exs
index c8dbee010..ee880e226 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1,11 +1,15 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
os_exclude = if :os.type() == {:unix, :darwin}, do: [skip_on_mac: true], else: []
-ExUnit.start(exclude: os_exclude)
+ExUnit.start(exclude: [:federated | os_exclude])
+
Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual)
+
Mox.defmock(Pleroma.ReverseProxy.ClientMock, for: Pleroma.ReverseProxy.Client)
+Mox.defmock(Pleroma.GunMock, for: Pleroma.Gun)
+
{:ok, _} = Application.ensure_all_started(:ex_machina)
ExUnit.after_suite(fn _results ->
diff --git a/test/upload/filter/anonymize_filename_test.exs b/test/upload/filter/anonymize_filename_test.exs
index 6b33e7395..2d5c580f1 100644
--- a/test/upload/filter/anonymize_filename_test.exs
+++ b/test/upload/filter/anonymize_filename_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.AnonymizeFilenameTest do
@@ -18,7 +18,7 @@ defmodule Pleroma.Upload.Filter.AnonymizeFilenameTest do
%{upload_file: upload_file}
end
- clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text])
+ setup do: clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text])
test "it replaces filename on pre-defined text", %{upload_file: upload_file} do
Config.put([Upload.Filter.AnonymizeFilename, :text], "custom-file.png")
diff --git a/test/upload/filter/dedupe_test.exs b/test/upload/filter/dedupe_test.exs
index 3de94dc20..966c353f7 100644
--- a/test/upload/filter/dedupe_test.exs
+++ b/test/upload/filter/dedupe_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.DedupeTest do
diff --git a/test/upload/filter/mogrifun_test.exs b/test/upload/filter/mogrifun_test.exs
index d5a8751cc..2426a8496 100644
--- a/test/upload/filter/mogrifun_test.exs
+++ b/test/upload/filter/mogrifun_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.MogrifunTest do
diff --git a/test/upload/filter/mogrify_test.exs b/test/upload/filter/mogrify_test.exs
index 210320d30..b6a463e8c 100644
--- a/test/upload/filter/mogrify_test.exs
+++ b/test/upload/filter/mogrify_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.Filter.MogrifyTest do
@@ -10,7 +10,7 @@ defmodule Pleroma.Upload.Filter.MogrifyTest do
alias Pleroma.Upload
alias Pleroma.Upload.Filter
- clear_config([Filter.Mogrify, :args])
+ setup do: clear_config([Filter.Mogrify, :args])
test "apply mogrify filter" do
Config.put([Filter.Mogrify, :args], [{"tint", "40"}])
diff --git a/test/upload/filter_test.exs b/test/upload/filter_test.exs
index 03887c06a..352b66402 100644
--- a/test/upload/filter_test.exs
+++ b/test/upload/filter_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Upload.FilterTest do
@@ -8,7 +8,7 @@ defmodule Pleroma.Upload.FilterTest do
alias Pleroma.Config
alias Pleroma.Upload.Filter
- clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text])
+ setup do: clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text])
test "applies filters" do
Config.put([Pleroma.Upload.Filter.AnonymizeFilename, :text], "custom-file.png")
diff --git a/test/upload_test.exs b/test/upload_test.exs
index 0ca5ebced..060a940bb 100644
--- a/test/upload_test.exs
+++ b/test/upload_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.UploadTest do
@@ -250,9 +250,7 @@ defmodule Pleroma.UploadTest do
end
describe "Setting a custom base_url for uploaded media" do
- clear_config([Pleroma.Upload, :base_url]) do
- Pleroma.Config.put([Pleroma.Upload, :base_url], "https://cache.pleroma.social")
- end
+ setup do: clear_config([Pleroma.Upload, :base_url], "https://cache.pleroma.social")
test "returns a media url with configured base_url" do
base_url = Pleroma.Config.get([Pleroma.Upload, :base_url])
diff --git a/test/uploaders/local_test.exs b/test/uploaders/local_test.exs
index fc442d0f1..ae2cfef94 100644
--- a/test/uploaders/local_test.exs
+++ b/test/uploaders/local_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Uploaders.LocalTest do
@@ -29,4 +29,25 @@ defmodule Pleroma.Uploaders.LocalTest do
|> File.exists?()
end
end
+
+ describe "delete_file/1" do
+ test "deletes local file" do
+ file_path = "local_upload/files/image.jpg"
+
+ file = %Pleroma.Upload{
+ name: "image.jpg",
+ content_type: "image/jpg",
+ path: file_path,
+ tempfile: Path.absname("test/fixtures/image_tmp.jpg")
+ }
+
+ :ok = Local.put_file(file)
+ local_path = Path.join([Local.upload_path(), file_path])
+ assert File.exists?(local_path)
+
+ Local.delete_file(file_path)
+
+ refute File.exists?(local_path)
+ end
+ end
end
diff --git a/test/uploaders/mdii_test.exs b/test/uploaders/mdii_test.exs
deleted file mode 100644
index d432d40f0..000000000
--- a/test/uploaders/mdii_test.exs
+++ /dev/null
@@ -1,50 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Uploaders.MDIITest do
- use Pleroma.DataCase
- alias Pleroma.Uploaders.MDII
- import Tesla.Mock
-
- describe "get_file/1" do
- test "it returns path to local folder for files" do
- assert MDII.get_file("") == {:ok, {:static_dir, "test/uploads"}}
- end
- end
-
- describe "put_file/1" do
- setup do
- file_upload = %Pleroma.Upload{
- name: "mdii-image.jpg",
- content_type: "image/jpg",
- path: "test_folder/mdii-image.jpg",
- tempfile: Path.absname("test/fixtures/image_tmp.jpg")
- }
-
- [file_upload: file_upload]
- end
-
- test "save file", %{file_upload: file_upload} do
- mock(fn
- %{method: :post, url: "https://mdii.sakura.ne.jp/mdii-post.cgi?jpg"} ->
- %Tesla.Env{status: 200, body: "mdii-image"}
- end)
-
- assert MDII.put_file(file_upload) ==
- {:ok, {:url, "https://mdii.sakura.ne.jp/mdii-image.jpg"}}
- end
-
- test "save file to local if MDII isn`t available", %{file_upload: file_upload} do
- mock(fn
- %{method: :post, url: "https://mdii.sakura.ne.jp/mdii-post.cgi?jpg"} ->
- %Tesla.Env{status: 500}
- end)
-
- assert MDII.put_file(file_upload) == :ok
-
- assert Path.join([Pleroma.Uploaders.Local.upload_path(), file_upload.path])
- |> File.exists?()
- end
- end
-end
diff --git a/test/uploaders/s3_test.exs b/test/uploaders/s3_test.exs
index 171316340..d949c90a5 100644
--- a/test/uploaders/s3_test.exs
+++ b/test/uploaders/s3_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Uploaders.S3Test do
@@ -11,12 +11,11 @@ defmodule Pleroma.Uploaders.S3Test do
import Mock
import ExUnit.CaptureLog
- clear_config([Pleroma.Uploaders.S3]) do
- Config.put([Pleroma.Uploaders.S3],
- bucket: "test_bucket",
- public_endpoint: "https://s3.amazonaws.com"
- )
- end
+ setup do:
+ clear_config(Pleroma.Uploaders.S3,
+ bucket: "test_bucket",
+ public_endpoint: "https://s3.amazonaws.com"
+ )
describe "get_file/1" do
test "it returns path to local folder for files" do
@@ -59,7 +58,7 @@ defmodule Pleroma.Uploaders.S3Test do
name: "image-tet.jpg",
content_type: "image/jpg",
path: "test_folder/image-tet.jpg",
- tempfile: Path.absname("test/fixtures/image_tmp.jpg")
+ tempfile: Path.absname("test/instance_static/add/shortcode.png")
}
[file_upload: file_upload]
@@ -79,4 +78,11 @@ defmodule Pleroma.Uploaders.S3Test do
end
end
end
+
+ describe "delete_file/1" do
+ test_with_mock "deletes file", ExAws, request: fn _req -> {:ok, %{status_code: 204}} end do
+ assert :ok = S3.delete_file("image.jpg")
+ assert_called(ExAws.request(:_))
+ end
+ end
end
diff --git a/test/user/notification_setting_test.exs b/test/user/notification_setting_test.exs
new file mode 100644
index 000000000..95bca22c4
--- /dev/null
+++ b/test/user/notification_setting_test.exs
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.User.NotificationSettingTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.User.NotificationSetting
+
+ describe "changeset/2" do
+ test "sets valid privacy option" do
+ changeset =
+ NotificationSetting.changeset(
+ %NotificationSetting{},
+ %{"privacy_option" => true}
+ )
+
+ assert %Ecto.Changeset{valid?: true} = changeset
+ end
+ end
+end
diff --git a/test/user_info_test.exs b/test/user_info_test.exs
deleted file mode 100644
index 2d795594e..000000000
--- a/test/user_info_test.exs
+++ /dev/null
@@ -1,24 +0,0 @@
-defmodule Pleroma.UserInfoTest do
- alias Pleroma.Repo
- alias Pleroma.User.Info
-
- use Pleroma.DataCase
-
- import Pleroma.Factory
-
- describe "update_email_notifications/2" do
- setup do
- user = insert(:user, %{info: %{email_notifications: %{"digest" => true}}})
-
- {:ok, user: user}
- end
-
- test "Notifications are updated", %{user: user} do
- true = user.info.email_notifications["digest"]
- changeset = Info.update_email_notifications(user.info, %{"digest" => false})
- assert changeset.valid?
- {:ok, result} = Ecto.Changeset.apply_action(changeset, :insert)
- assert result.email_notifications["digest"] == false
- end
- end
-end
diff --git a/test/user_invite_token_test.exs b/test/user_invite_token_test.exs
index 111e40361..63f18f13c 100644
--- a/test/user_invite_token_test.exs
+++ b/test/user_invite_token_test.exs
@@ -1,10 +1,9 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.UserInviteTokenTest do
use ExUnit.Case, async: true
- use Pleroma.DataCase
alias Pleroma.UserInviteToken
describe "valid_invite?/1 one time invites" do
@@ -64,7 +63,6 @@ defmodule Pleroma.UserInviteTokenTest do
test "expires yesterday returns false", %{invite: invite} do
invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)}
- invite = Repo.insert!(invite)
refute UserInviteToken.valid_invite?(invite)
end
end
@@ -82,7 +80,6 @@ defmodule Pleroma.UserInviteTokenTest do
test "overdue date and less uses returns false", %{invite: invite} do
invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)}
- invite = Repo.insert!(invite)
refute UserInviteToken.valid_invite?(invite)
end
@@ -93,7 +90,6 @@ defmodule Pleroma.UserInviteTokenTest do
test "overdue date with more uses returns false", %{invite: invite} do
invite = %{invite | expires_at: Date.add(Date.utc_today(), -1), uses: 5}
- invite = Repo.insert!(invite)
refute UserInviteToken.valid_invite?(invite)
end
end
diff --git a/test/user_relationship_test.exs b/test/user_relationship_test.exs
new file mode 100644
index 000000000..f12406097
--- /dev/null
+++ b/test/user_relationship_test.exs
@@ -0,0 +1,130 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.UserRelationshipTest do
+ alias Pleroma.UserRelationship
+
+ use Pleroma.DataCase
+
+ import Pleroma.Factory
+
+ describe "*_exists?/2" do
+ setup do
+ {:ok, users: insert_list(2, :user)}
+ end
+
+ test "returns false if record doesn't exist", %{users: [user1, user2]} do
+ refute UserRelationship.block_exists?(user1, user2)
+ refute UserRelationship.mute_exists?(user1, user2)
+ refute UserRelationship.notification_mute_exists?(user1, user2)
+ refute UserRelationship.reblog_mute_exists?(user1, user2)
+ refute UserRelationship.inverse_subscription_exists?(user1, user2)
+ end
+
+ test "returns true if record exists", %{users: [user1, user2]} do
+ for relationship_type <- [
+ :block,
+ :mute,
+ :notification_mute,
+ :reblog_mute,
+ :inverse_subscription
+ ] do
+ insert(:user_relationship,
+ source: user1,
+ target: user2,
+ relationship_type: relationship_type
+ )
+ end
+
+ assert UserRelationship.block_exists?(user1, user2)
+ assert UserRelationship.mute_exists?(user1, user2)
+ assert UserRelationship.notification_mute_exists?(user1, user2)
+ assert UserRelationship.reblog_mute_exists?(user1, user2)
+ assert UserRelationship.inverse_subscription_exists?(user1, user2)
+ end
+ end
+
+ describe "create_*/2" do
+ setup do
+ {:ok, users: insert_list(2, :user)}
+ end
+
+ test "creates user relationship record if it doesn't exist", %{users: [user1, user2]} do
+ for relationship_type <- [
+ :block,
+ :mute,
+ :notification_mute,
+ :reblog_mute,
+ :inverse_subscription
+ ] do
+ insert(:user_relationship,
+ source: user1,
+ target: user2,
+ relationship_type: relationship_type
+ )
+ end
+
+ UserRelationship.create_block(user1, user2)
+ UserRelationship.create_mute(user1, user2)
+ UserRelationship.create_notification_mute(user1, user2)
+ UserRelationship.create_reblog_mute(user1, user2)
+ UserRelationship.create_inverse_subscription(user1, user2)
+
+ assert UserRelationship.block_exists?(user1, user2)
+ assert UserRelationship.mute_exists?(user1, user2)
+ assert UserRelationship.notification_mute_exists?(user1, user2)
+ assert UserRelationship.reblog_mute_exists?(user1, user2)
+ assert UserRelationship.inverse_subscription_exists?(user1, user2)
+ end
+
+ test "if record already exists, returns it", %{users: [user1, user2]} do
+ user_block = UserRelationship.create_block(user1, user2)
+ assert user_block == UserRelationship.create_block(user1, user2)
+ end
+ end
+
+ describe "delete_*/2" do
+ setup do
+ {:ok, users: insert_list(2, :user)}
+ end
+
+ test "deletes user relationship record if it exists", %{users: [user1, user2]} do
+ for relationship_type <- [
+ :block,
+ :mute,
+ :notification_mute,
+ :reblog_mute,
+ :inverse_subscription
+ ] do
+ insert(:user_relationship,
+ source: user1,
+ target: user2,
+ relationship_type: relationship_type
+ )
+ end
+
+ assert {:ok, %UserRelationship{}} = UserRelationship.delete_block(user1, user2)
+ assert {:ok, %UserRelationship{}} = UserRelationship.delete_mute(user1, user2)
+ assert {:ok, %UserRelationship{}} = UserRelationship.delete_notification_mute(user1, user2)
+ assert {:ok, %UserRelationship{}} = UserRelationship.delete_reblog_mute(user1, user2)
+
+ assert {:ok, %UserRelationship{}} =
+ UserRelationship.delete_inverse_subscription(user1, user2)
+
+ refute UserRelationship.block_exists?(user1, user2)
+ refute UserRelationship.mute_exists?(user1, user2)
+ refute UserRelationship.notification_mute_exists?(user1, user2)
+ refute UserRelationship.reblog_mute_exists?(user1, user2)
+ refute UserRelationship.inverse_subscription_exists?(user1, user2)
+ end
+
+ test "if record does not exist, returns {:ok, nil}", %{users: [user1, user2]} do
+ assert {:ok, nil} = UserRelationship.delete_block(user1, user2)
+ assert {:ok, nil} = UserRelationship.delete_mute(user1, user2)
+ assert {:ok, nil} = UserRelationship.delete_notification_mute(user1, user2)
+ assert {:ok, nil} = UserRelationship.delete_reblog_mute(user1, user2)
+ assert {:ok, nil} = UserRelationship.delete_inverse_subscription(user1, user2)
+ end
+ end
+end
diff --git a/test/user_search_test.exs b/test/user_search_test.exs
index 78a02d536..17c63322a 100644
--- a/test/user_search_test.exs
+++ b/test/user_search_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.UserSearchTest do
@@ -15,6 +15,16 @@ defmodule Pleroma.UserSearchTest do
end
describe "User.search" do
+ setup do: clear_config([:instance, :limit_to_local_content])
+
+ test "excluded invisible users from results" do
+ user = insert(:user, %{nickname: "john t1000"})
+ insert(:user, %{invisible: true, nickname: "john t800"})
+
+ [found_user] = User.search("john")
+ assert found_user.id == user.id
+ end
+
test "accepts limit parameter" do
Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"}))
assert length(User.search("john", limit: 3)) == 3
@@ -51,13 +61,6 @@ defmodule Pleroma.UserSearchTest do
end)
end
- test "finds users, preferring nickname matches over name matches" do
- u1 = insert(:user, %{name: "lain", nickname: "nick1"})
- u2 = insert(:user, %{nickname: "lain", name: "nick1"})
-
- assert [u2.id, u1.id] == Enum.map(User.search("lain"), & &1.id)
- end
-
test "finds users, considering density of matched tokens" do
u1 = insert(:user, %{name: "Bar Bar plus Word Word"})
u2 = insert(:user, %{name: "Word Word Bar Bar Bar"})
@@ -126,8 +129,6 @@ defmodule Pleroma.UserSearchTest do
insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
assert [%{id: ^id}] = User.search("lain")
-
- Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated)
end
test "find all users for unauthenticated users when `limit_to_local_content` is `false`" do
@@ -144,8 +145,6 @@ defmodule Pleroma.UserSearchTest do
|> Enum.sort()
assert [u1.id, u2.id, u3.id] == results
-
- Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated)
end
test "does not yield false-positive matches" do
@@ -173,6 +172,8 @@ defmodule Pleroma.UserSearchTest do
|> Map.put(:search_rank, nil)
|> Map.put(:search_type, nil)
|> Map.put(:last_digest_emailed_at, nil)
+ |> Map.put(:multi_factor_authentication_settings, nil)
+ |> Map.put(:notification_settings, nil)
assert user == expected
end
diff --git a/test/user_test.exs b/test/user_test.exs
index 05bdb9a61..6b9df60a4 100644
--- a/test/user_test.exs
+++ b/test/user_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.UserTest do
@@ -15,15 +15,120 @@ defmodule Pleroma.UserTest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
- import Mock
import Pleroma.Factory
+ import ExUnit.CaptureLog
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
- clear_config([:instance, :account_activation_required])
+ setup do: clear_config([:instance, :account_activation_required])
+
+ describe "service actors" do
+ test "returns updated invisible actor" do
+ uri = "#{Pleroma.Web.Endpoint.url()}/relay"
+ followers_uri = "#{uri}/followers"
+
+ insert(
+ :user,
+ %{
+ nickname: "relay",
+ invisible: false,
+ local: true,
+ ap_id: uri,
+ follower_address: followers_uri
+ }
+ )
+
+ actor = User.get_or_create_service_actor_by_ap_id(uri, "relay")
+ assert actor.invisible
+ end
+
+ test "returns relay user" do
+ uri = "#{Pleroma.Web.Endpoint.url()}/relay"
+ followers_uri = "#{uri}/followers"
+
+ assert %User{
+ nickname: "relay",
+ invisible: true,
+ local: true,
+ ap_id: ^uri,
+ follower_address: ^followers_uri
+ } = User.get_or_create_service_actor_by_ap_id(uri, "relay")
+
+ assert capture_log(fn ->
+ refute User.get_or_create_service_actor_by_ap_id("/relay", "relay")
+ end) =~ "Cannot create service actor:"
+ end
+
+ test "returns invisible actor" do
+ uri = "#{Pleroma.Web.Endpoint.url()}/internal/fetch-test"
+ followers_uri = "#{uri}/followers"
+ user = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
+
+ assert %User{
+ nickname: "internal.fetch-test",
+ invisible: true,
+ local: true,
+ ap_id: ^uri,
+ follower_address: ^followers_uri
+ } = user
+
+ user2 = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
+ assert user.id == user2.id
+ end
+ end
+
+ describe "AP ID user relationships" do
+ setup do
+ {:ok, user: insert(:user)}
+ end
+
+ test "outgoing_relationships_ap_ids/1", %{user: user} do
+ rel_types = [:block, :mute, :notification_mute, :reblog_mute, :inverse_subscription]
+
+ ap_ids_by_rel =
+ Enum.into(
+ rel_types,
+ %{},
+ fn rel_type ->
+ rel_records =
+ insert_list(2, :user_relationship, %{source: user, relationship_type: rel_type})
+
+ ap_ids = Enum.map(rel_records, fn rr -> Repo.preload(rr, :target).target.ap_id end)
+ {rel_type, Enum.sort(ap_ids)}
+ end
+ )
+
+ assert ap_ids_by_rel[:block] == Enum.sort(User.blocked_users_ap_ids(user))
+ assert ap_ids_by_rel[:block] == Enum.sort(Enum.map(User.blocked_users(user), & &1.ap_id))
+
+ assert ap_ids_by_rel[:mute] == Enum.sort(User.muted_users_ap_ids(user))
+ assert ap_ids_by_rel[:mute] == Enum.sort(Enum.map(User.muted_users(user), & &1.ap_id))
+
+ assert ap_ids_by_rel[:notification_mute] ==
+ Enum.sort(User.notification_muted_users_ap_ids(user))
+
+ assert ap_ids_by_rel[:notification_mute] ==
+ Enum.sort(Enum.map(User.notification_muted_users(user), & &1.ap_id))
+
+ assert ap_ids_by_rel[:reblog_mute] == Enum.sort(User.reblog_muted_users_ap_ids(user))
+
+ assert ap_ids_by_rel[:reblog_mute] ==
+ Enum.sort(Enum.map(User.reblog_muted_users(user), & &1.ap_id))
+
+ assert ap_ids_by_rel[:inverse_subscription] == Enum.sort(User.subscriber_users_ap_ids(user))
+
+ assert ap_ids_by_rel[:inverse_subscription] ==
+ Enum.sort(Enum.map(User.subscriber_users(user), & &1.ap_id))
+
+ outgoing_relationships_ap_ids = User.outgoing_relationships_ap_ids(user, rel_types)
+
+ assert ap_ids_by_rel ==
+ Enum.into(outgoing_relationships_ap_ids, %{}, fn {k, v} -> {k, Enum.sort(v)} end)
+ end
+ end
describe "when tags are nil" do
test "tagging a user" do
@@ -68,7 +173,7 @@ defmodule Pleroma.UserTest do
test "returns all pending follow requests" do
unlocked = insert(:user)
- locked = insert(:user, %{info: %{locked: true}})
+ locked = insert(:user, locked: true)
follower = insert(:user)
CommonAPI.follow(follower, unlocked)
@@ -81,27 +186,27 @@ defmodule Pleroma.UserTest do
end
test "doesn't return already accepted or duplicate follow requests" do
- locked = insert(:user, %{info: %{locked: true}})
+ locked = insert(:user, locked: true)
pending_follower = insert(:user)
accepted_follower = insert(:user)
CommonAPI.follow(pending_follower, locked)
CommonAPI.follow(pending_follower, locked)
CommonAPI.follow(accepted_follower, locked)
- User.follow(accepted_follower, locked)
- assert [activity] = User.get_follow_requests(locked)
- assert activity
+ Pleroma.FollowingRelationship.update(accepted_follower, locked, :follow_accept)
+
+ assert [^pending_follower] = User.get_follow_requests(locked)
end
test "clears follow requests when requester is blocked" do
- followed = insert(:user, %{info: %{locked: true}})
+ followed = insert(:user, locked: true)
follower = insert(:user)
CommonAPI.follow(follower, followed)
assert [_activity] = User.get_follow_requests(followed)
- {:ok, _follower} = User.block(followed, follower)
+ {:ok, _user_relationship} = User.block(followed, follower)
assert [] = User.get_follow_requests(followed)
end
@@ -114,8 +219,8 @@ defmodule Pleroma.UserTest do
not_followed = insert(:user)
reverse_blocked = insert(:user)
- {:ok, user} = User.block(user, blocked)
- {:ok, reverse_blocked} = User.block(reverse_blocked, user)
+ {:ok, _user_relationship} = User.block(user, blocked)
+ {:ok, _user_relationship} = User.block(reverse_blocked, user)
{:ok, user} = User.follow(user, followed_zero)
@@ -136,10 +241,10 @@ defmodule Pleroma.UserTest do
followed_two = insert(:user)
{:ok, user} = User.follow_all(user, [followed_zero, followed_one])
- assert length(user.following) == 3
+ assert length(User.following(user)) == 3
{:ok, user} = User.follow_all(user, [followed_one, followed_two])
- assert length(user.following) == 4
+ assert length(User.following(user)) == 4
end
test "follow takes a user and another user" do
@@ -149,16 +254,17 @@ defmodule Pleroma.UserTest do
{:ok, user} = User.follow(user, followed)
user = User.get_cached_by_id(user.id)
-
followed = User.get_cached_by_ap_id(followed.ap_id)
- assert followed.info.follower_count == 1
- assert User.ap_followers(followed) in user.following
+ assert followed.follower_count == 1
+ assert user.following_count == 1
+
+ assert User.ap_followers(followed) in User.following(user)
end
test "can't follow a deactivated users" do
user = insert(:user)
- followed = insert(:user, info: %{deactivated: true})
+ followed = insert(:user, %{deactivated: true})
{:error, _} = User.follow(user, followed)
end
@@ -167,7 +273,7 @@ defmodule Pleroma.UserTest do
blocker = insert(:user)
blockee = insert(:user)
- {:ok, blocker} = User.block(blocker, blockee)
+ {:ok, _user_relationship} = User.block(blocker, blockee)
{:error, _} = User.follow(blockee, blocker)
end
@@ -176,14 +282,14 @@ defmodule Pleroma.UserTest do
blocker = insert(:user)
blocked = insert(:user)
- {:ok, blocker} = User.block(blocker, blocked)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
{:error, _} = User.subscribe(blocked, blocker)
end
test "local users do not automatically follow local locked accounts" do
- follower = insert(:user, info: %{locked: true})
- followed = insert(:user, info: %{locked: true})
+ follower = insert(:user, locked: true)
+ followed = insert(:user, locked: true)
{:ok, follower} = User.maybe_direct_follow(follower, followed)
@@ -191,15 +297,7 @@ defmodule Pleroma.UserTest do
end
describe "unfollow/2" do
- setup do
- setting = Pleroma.Config.get([:instance, :external_user_synchronization])
-
- on_exit(fn ->
- Pleroma.Config.put([:instance, :external_user_synchronization], setting)
- end)
-
- :ok
- end
+ setup do: clear_config([:instance, :external_user_synchronization])
test "unfollow with syncronizes external user" do
Pleroma.Config.put([:instance, :external_user_synchronization], true)
@@ -218,26 +316,29 @@ defmodule Pleroma.UserTest do
nickname: "fuser2",
ap_id: "http://localhost:4001/users/fuser2",
follower_address: "http://localhost:4001/users/fuser2/followers",
- following_address: "http://localhost:4001/users/fuser2/following",
- following: [User.ap_followers(followed)]
+ following_address: "http://localhost:4001/users/fuser2/following"
})
+ {:ok, user} = User.follow(user, followed, :follow_accept)
+
{:ok, user, _activity} = User.unfollow(user, followed)
user = User.get_cached_by_id(user.id)
- assert user.following == []
+ assert User.following(user) == []
end
test "unfollow takes a user and another user" do
followed = insert(:user)
- user = insert(:user, %{following: [User.ap_followers(followed)]})
+ user = insert(:user)
- {:ok, user, _activity} = User.unfollow(user, followed)
+ {:ok, user} = User.follow(user, followed, :follow_accept)
- user = User.get_cached_by_id(user.id)
+ assert User.following(user) == [user.follower_address, followed.follower_address]
+
+ {:ok, user, _activity} = User.unfollow(user, followed)
- assert user.following == []
+ assert User.following(user) == [user.follower_address]
end
test "unfollow doesn't unfollow yourself" do
@@ -245,14 +346,14 @@ defmodule Pleroma.UserTest do
{:error, _} = User.unfollow(user, user)
- user = User.get_cached_by_id(user.id)
- assert user.following == [user.ap_id]
+ assert User.following(user) == [user.follower_address]
end
end
test "test if a user is following another user" do
followed = insert(:user)
- user = insert(:user, %{following: [User.ap_followers(followed)]})
+ user = insert(:user)
+ User.follow(user, followed, :follow_accept)
assert User.following?(user, followed)
refute User.following?(followed, user)
@@ -274,9 +375,9 @@ defmodule Pleroma.UserTest do
password_confirmation: "test",
email: "email@example.com"
}
- clear_config([:instance, :autofollowed_nicknames])
- clear_config([:instance, :welcome_message])
- clear_config([:instance, :welcome_user_nickname])
+ setup do: clear_config([:instance, :autofollowed_nicknames])
+ setup do: clear_config([:instance, :welcome_message])
+ setup do: clear_config([:instance, :welcome_user_nickname])
test "it autofollows accounts that are set for it" do
user = insert(:user)
@@ -310,7 +411,11 @@ defmodule Pleroma.UserTest do
assert activity.actor == welcome_user.ap_id
end
- test "it requires an email, name, nickname and password, bio is optional" do
+ setup do: clear_config([:instance, :account_activation_required])
+
+ test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+
@full_user_data
|> Map.keys()
|> Enum.each(fn key ->
@@ -321,6 +426,19 @@ defmodule Pleroma.UserTest do
end)
end
+ test "it requires an name, nickname and password, bio and email are optional when account_activation_required is disabled" do
+ Pleroma.Config.put([:instance, :account_activation_required], false)
+
+ @full_user_data
+ |> Map.keys()
+ |> Enum.each(fn key ->
+ params = Map.delete(@full_user_data, key)
+ changeset = User.register_changeset(%User{}, params)
+
+ assert if key in [:bio, :email], do: changeset.valid?, else: not changeset.valid?
+ end)
+ end
+
test "it restricts certain nicknames" do
[restricted_name | _] = Pleroma.Config.get([User, :restricted_nicknames])
@@ -335,7 +453,7 @@ defmodule Pleroma.UserTest do
refute changeset.valid?
end
- test "it sets the password_hash, ap_id and following fields" do
+ test "it sets the password_hash and ap_id" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
@@ -343,24 +461,8 @@ defmodule Pleroma.UserTest do
assert is_binary(changeset.changes[:password_hash])
assert changeset.changes[:ap_id] == User.ap_id(%User{nickname: @full_user_data.nickname})
- assert changeset.changes[:following] == [
- User.ap_followers(%User{nickname: @full_user_data.nickname})
- ]
-
assert changeset.changes.follower_address == "#{changeset.changes.ap_id}/followers"
end
-
- test "it ensures info is not nil" do
- changeset = User.register_changeset(%User{}, @full_user_data)
-
- assert changeset.valid?
-
- {:ok, user} =
- changeset
- |> Repo.insert()
-
- refute is_nil(user.info)
- end
end
describe "user registration, with :account_activation_required" do
@@ -372,10 +474,7 @@ defmodule Pleroma.UserTest do
password_confirmation: "test",
email: "email@example.com"
}
-
- clear_config([:instance, :account_activation_required]) do
- Pleroma.Config.put([:instance, :account_activation_required], true)
- end
+ setup do: clear_config([:instance, :account_activation_required], true)
test "it creates unconfirmed user" do
changeset = User.register_changeset(%User{}, @full_user_data)
@@ -383,8 +482,8 @@ defmodule Pleroma.UserTest do
{:ok, user} = Repo.insert(changeset)
- assert user.info.confirmation_pending
- assert user.info.confirmation_token
+ assert user.confirmation_pending
+ assert user.confirmation_token
end
test "it creates confirmed user if :confirmed option is given" do
@@ -393,8 +492,8 @@ defmodule Pleroma.UserTest do
{:ok, user} = Repo.insert(changeset)
- refute user.info.confirmation_pending
- refute user.info.confirmation_token
+ refute user.confirmation_pending
+ refute user.confirmation_token
end
end
@@ -414,8 +513,7 @@ defmodule Pleroma.UserTest do
:user,
local: false,
nickname: "admin@mastodon.example.org",
- ap_id: ap_id,
- info: %{}
+ ap_id: ap_id
)
{:ok, fetched_user} = User.get_or_fetch(ap_id)
@@ -476,14 +574,14 @@ defmodule Pleroma.UserTest do
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/admin",
- last_refreshed_at: a_week_ago,
- info: %{}
+ last_refreshed_at: a_week_ago
)
assert orig_user.last_refreshed_at == a_week_ago
{:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
- assert user.info.source_data["endpoints"]
+
+ assert user.inbox
refute user.last_refreshed_at == orig_user.last_refreshed_at
end
@@ -493,7 +591,7 @@ defmodule Pleroma.UserTest do
user = insert(:user)
assert User.ap_id(user) ==
- Pleroma.Web.Router.Helpers.feed_url(
+ Pleroma.Web.Router.Helpers.user_feed_url(
Pleroma.Web.Endpoint,
:feed_redirect,
user.nickname
@@ -504,49 +602,47 @@ defmodule Pleroma.UserTest do
user = insert(:user)
assert User.ap_followers(user) ==
- Pleroma.Web.Router.Helpers.feed_url(
+ Pleroma.Web.Router.Helpers.user_feed_url(
Pleroma.Web.Endpoint,
:feed_redirect,
user.nickname
) <> "/followers"
end
- describe "remote user creation changeset" do
+ describe "remote user changeset" do
@valid_remote %{
bio: "hello",
name: "Someone",
nickname: "a@b.de",
ap_id: "http...",
- info: %{some: "info"},
avatar: %{some: "avatar"}
}
-
- clear_config([:instance, :user_bio_length])
- clear_config([:instance, :user_name_length])
+ setup do: clear_config([:instance, :user_bio_length])
+ setup do: clear_config([:instance, :user_name_length])
test "it confirms validity" do
- cs = User.remote_user_creation(@valid_remote)
+ cs = User.remote_user_changeset(@valid_remote)
assert cs.valid?
end
test "it sets the follower_adress" do
- cs = User.remote_user_creation(@valid_remote)
+ cs = User.remote_user_changeset(@valid_remote)
# remote users get a fake local follower address
assert cs.changes.follower_address ==
User.ap_followers(%User{nickname: @valid_remote[:nickname]})
end
test "it enforces the fqn format for nicknames" do
- cs = User.remote_user_creation(%{@valid_remote | nickname: "bla"})
+ cs = User.remote_user_changeset(%{@valid_remote | nickname: "bla"})
assert Ecto.Changeset.get_field(cs, :local) == false
assert cs.changes.avatar
refute cs.valid?
end
test "it has required fields" do
- [:name, :ap_id]
+ [:ap_id]
|> Enum.each(fn field ->
- cs = User.remote_user_creation(Map.delete(@valid_remote, field))
+ cs = User.remote_user_changeset(Map.delete(@valid_remote, field))
refute cs.valid?
end)
end
@@ -589,94 +685,63 @@ defmodule Pleroma.UserTest do
end
describe "updating note and follower count" do
- test "it sets the info->note_count property" do
+ test "it sets the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
- assert user.info.note_count == 0
+ assert user.note_count == 0
{:ok, user} = User.update_note_count(user)
- assert user.info.note_count == 1
+ assert user.note_count == 1
end
- test "it increases the info->note_count property" do
+ test "it increases the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
- assert user.info.note_count == 0
+ assert user.note_count == 0
{:ok, user} = User.increase_note_count(user)
- assert user.info.note_count == 1
+ assert user.note_count == 1
{:ok, user} = User.increase_note_count(user)
- assert user.info.note_count == 2
+ assert user.note_count == 2
end
- test "it decreases the info->note_count property" do
+ test "it decreases the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
- assert user.info.note_count == 0
+ assert user.note_count == 0
{:ok, user} = User.increase_note_count(user)
- assert user.info.note_count == 1
+ assert user.note_count == 1
{:ok, user} = User.decrease_note_count(user)
- assert user.info.note_count == 0
+ assert user.note_count == 0
{:ok, user} = User.decrease_note_count(user)
- assert user.info.note_count == 0
+ assert user.note_count == 0
end
- test "it sets the info->follower_count property" do
+ test "it sets the follower_count property" do
user = insert(:user)
follower = insert(:user)
User.follow(follower, user)
- assert user.info.follower_count == 0
+ assert user.follower_count == 0
{:ok, user} = User.update_follower_count(user)
- assert user.info.follower_count == 1
- end
- end
-
- describe "remove duplicates from following list" do
- test "it removes duplicates" do
- user = insert(:user)
- follower = insert(:user)
-
- {:ok, %User{following: following} = follower} = User.follow(follower, user)
- assert length(following) == 2
-
- {:ok, follower} =
- follower
- |> User.update_changeset(%{following: following ++ following})
- |> Repo.update()
-
- assert length(follower.following) == 4
-
- {:ok, follower} = User.remove_duplicated_following(follower)
- assert length(follower.following) == 2
- end
-
- test "it does nothing when following is uniq" do
- user = insert(:user)
- follower = insert(:user)
-
- {:ok, follower} = User.follow(follower, user)
- assert length(follower.following) == 2
-
- {:ok, follower} = User.remove_duplicated_following(follower)
- assert length(follower.following) == 2
+ assert user.follower_count == 1
end
end
@@ -690,8 +755,8 @@ defmodule Pleroma.UserTest do
]
{:ok, job} = User.follow_import(user1, identifiers)
- result = ObanHelpers.perform(job)
+ assert {:ok, result} = ObanHelpers.perform(job)
assert is_list(result)
assert result == [user2, user3]
end
@@ -705,7 +770,7 @@ defmodule Pleroma.UserTest do
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
- {:ok, user} = User.mute(user, muted_user)
+ {:ok, _user_relationships} = User.mute(user, muted_user)
assert User.mutes?(user, muted_user)
assert User.muted_notifications?(user, muted_user)
@@ -715,8 +780,8 @@ defmodule Pleroma.UserTest do
user = insert(:user)
muted_user = insert(:user)
- {:ok, user} = User.mute(user, muted_user)
- {:ok, user} = User.unmute(user, muted_user)
+ {:ok, _user_relationships} = User.mute(user, muted_user)
+ {:ok, _user_mute} = User.unmute(user, muted_user)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
@@ -729,7 +794,7 @@ defmodule Pleroma.UserTest do
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
- {:ok, user} = User.mute(user, muted_user, false)
+ {:ok, _user_relationships} = User.mute(user, muted_user, false)
assert User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
@@ -743,7 +808,7 @@ defmodule Pleroma.UserTest do
refute User.blocks?(user, blocked_user)
- {:ok, user} = User.block(user, blocked_user)
+ {:ok, _user_relationship} = User.block(user, blocked_user)
assert User.blocks?(user, blocked_user)
end
@@ -752,8 +817,8 @@ defmodule Pleroma.UserTest do
user = insert(:user)
blocked_user = insert(:user)
- {:ok, user} = User.block(user, blocked_user)
- {:ok, user} = User.unblock(user, blocked_user)
+ {:ok, _user_relationship} = User.block(user, blocked_user)
+ {:ok, _user_block} = User.unblock(user, blocked_user)
refute User.blocks?(user, blocked_user)
end
@@ -768,7 +833,7 @@ defmodule Pleroma.UserTest do
assert User.following?(blocker, blocked)
assert User.following?(blocked, blocker)
- {:ok, blocker} = User.block(blocker, blocked)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
@@ -786,7 +851,7 @@ defmodule Pleroma.UserTest do
assert User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
- {:ok, blocker} = User.block(blocker, blocked)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
@@ -804,7 +869,7 @@ defmodule Pleroma.UserTest do
refute User.following?(blocker, blocked)
assert User.following?(blocked, blocker)
- {:ok, blocker} = User.block(blocker, blocked)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
@@ -817,12 +882,12 @@ defmodule Pleroma.UserTest do
blocker = insert(:user)
blocked = insert(:user)
- {:ok, blocker} = User.subscribe(blocked, blocker)
+ {:ok, _subscription} = User.subscribe(blocked, blocker)
assert User.subscribed_to?(blocked, blocker)
refute User.subscribed_to?(blocker, blocked)
- {:ok, blocker} = User.block(blocker, blocked)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
assert User.blocks?(blocker, blocked)
refute User.subscribed_to?(blocker, blocked)
@@ -891,6 +956,16 @@ defmodule Pleroma.UserTest do
refute User.blocks?(user, collateral_user)
end
+
+ test "follows take precedence over domain blocks" do
+ user = insert(:user)
+ good_eggo = insert(:user, %{ap_id: "https://meanies.social/user/cuteposter"})
+
+ {:ok, user} = User.block_domain(user, "meanies.social")
+ {:ok, user} = User.follow(user, good_eggo)
+
+ refute User.blocks?(user, good_eggo)
+ end
end
describe "blocks_import" do
@@ -903,56 +978,91 @@ defmodule Pleroma.UserTest do
]
{:ok, job} = User.blocks_import(user1, identifiers)
- result = ObanHelpers.perform(job)
+ assert {:ok, result} = ObanHelpers.perform(job)
assert is_list(result)
assert result == [user2, user3]
end
end
- test "get recipients from activity" do
- actor = insert(:user)
- user = insert(:user, local: true)
- user_two = insert(:user, local: false)
- addressed = insert(:user, local: true)
- addressed_remote = insert(:user, local: false)
-
- {:ok, activity} =
- CommonAPI.post(actor, %{
- "status" => "hey @#{addressed.nickname} @#{addressed_remote.nickname}"
- })
-
- assert Enum.map([actor, addressed], & &1.ap_id) --
- Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == []
-
- {:ok, user} = User.follow(user, actor)
- {:ok, _user_two} = User.follow(user_two, actor)
- recipients = User.get_recipients_from_activity(activity)
- assert length(recipients) == 3
- assert user in recipients
- assert addressed in recipients
+ describe "get_recipients_from_activity" do
+ test "works for announces" do
+ actor = insert(:user)
+ user = insert(:user, local: true)
+
+ {:ok, activity} = CommonAPI.post(actor, %{status: "hello"})
+ {:ok, announce, _} = CommonAPI.repeat(activity.id, user)
+
+ recipients = User.get_recipients_from_activity(announce)
+
+ assert user in recipients
+ end
+
+ test "get recipients" do
+ actor = insert(:user)
+ user = insert(:user, local: true)
+ user_two = insert(:user, local: false)
+ addressed = insert(:user, local: true)
+ addressed_remote = insert(:user, local: false)
+
+ {:ok, activity} =
+ CommonAPI.post(actor, %{
+ status: "hey @#{addressed.nickname} @#{addressed_remote.nickname}"
+ })
+
+ assert Enum.map([actor, addressed], & &1.ap_id) --
+ Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == []
+
+ {:ok, user} = User.follow(user, actor)
+ {:ok, _user_two} = User.follow(user_two, actor)
+ recipients = User.get_recipients_from_activity(activity)
+ assert length(recipients) == 3
+ assert user in recipients
+ assert addressed in recipients
+ end
+
+ test "has following" do
+ actor = insert(:user)
+ user = insert(:user)
+ user_two = insert(:user)
+ addressed = insert(:user, local: true)
+
+ {:ok, activity} =
+ CommonAPI.post(actor, %{
+ status: "hey @#{addressed.nickname}"
+ })
+
+ assert Enum.map([actor, addressed], & &1.ap_id) --
+ Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == []
+
+ {:ok, _actor} = User.follow(actor, user)
+ {:ok, _actor} = User.follow(actor, user_two)
+ recipients = User.get_recipients_from_activity(activity)
+ assert length(recipients) == 2
+ assert addressed in recipients
+ end
end
describe ".deactivate" do
test "can de-activate then re-activate a user" do
user = insert(:user)
- assert false == user.info.deactivated
+ assert false == user.deactivated
{:ok, user} = User.deactivate(user)
- assert true == user.info.deactivated
+ assert true == user.deactivated
{:ok, user} = User.deactivate(user, false)
- assert false == user.info.deactivated
+ assert false == user.deactivated
end
- test "hide a user from followers " do
+ test "hide a user from followers" do
user = insert(:user)
user2 = insert(:user)
{:ok, user} = User.follow(user, user2)
{:ok, _user} = User.deactivate(user)
- info = User.get_cached_user_info(user2)
+ user2 = User.get_cached_by_id(user2.id)
- assert info.follower_count == 0
+ assert user2.follower_count == 0
assert [] = User.get_followers(user2)
end
@@ -961,13 +1071,15 @@ defmodule Pleroma.UserTest do
user2 = insert(:user)
{:ok, user2} = User.follow(user2, user)
+ assert user2.following_count == 1
assert User.following_count(user2) == 1
{:ok, _user} = User.deactivate(user)
- info = User.get_cached_user_info(user2)
+ user2 = User.get_cached_by_id(user2.id)
- assert info.following_count == 0
+ assert refresh_record(user2).following_count == 0
+ assert user2.following_count == 0
assert User.following_count(user2) == 0
assert [] = User.get_friends(user2)
end
@@ -978,7 +1090,7 @@ defmodule Pleroma.UserTest do
{:ok, user2} = User.follow(user2, user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{user2.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{user2.nickname}"})
activity = Repo.preload(activity, :bookmark)
@@ -988,7 +1100,9 @@ defmodule Pleroma.UserTest do
assert [activity] == ActivityPub.fetch_public_activities(%{}) |> Repo.preload(:bookmark)
assert [%{activity | thread_muted?: CommonAPI.thread_muted?(user2, activity)}] ==
- ActivityPub.fetch_activities([user2.ap_id | user2.following], %{"user" => user2})
+ ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{
+ "user" => user2
+ })
{:ok, _user} = User.deactivate(user)
@@ -996,7 +1110,9 @@ defmodule Pleroma.UserTest do
assert [] == Pleroma.Notification.for_user(user2)
assert [] ==
- ActivityPub.fetch_activities([user2.ap_id | user2.following], %{"user" => user2})
+ ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{
+ "user" => user2
+ })
end
end
@@ -1007,27 +1123,18 @@ defmodule Pleroma.UserTest do
[user: user]
end
- clear_config([:instance, :federating])
+ setup do: clear_config([:instance, :federating])
test ".delete_user_activities deletes all create activities", %{user: user} do
- {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "2hu"})
User.delete_user_activities(user)
- # TODO: Remove favorites, repeats, delete activities.
+ # TODO: Test removal favorites, repeats, delete activities.
refute Activity.get_by_id(activity.id)
end
- test "it deletes deactivated user" do
- {:ok, user} = insert(:user, info: %{deactivated: true}) |> User.set_cache()
-
- {:ok, job} = User.delete(user)
- {:ok, _user} = ObanHelpers.perform(job)
-
- refute User.get_by_id(user.id)
- end
-
- test "it deletes a user, all follow relationships and all activities", %{user: user} do
+ test "it deactivates a user, all follow relationships and all activities", %{user: user} do
follower = insert(:user)
{:ok, follower} = User.follow(follower, user)
@@ -1037,8 +1144,8 @@ defmodule Pleroma.UserTest do
object_two = insert(:note, user: follower)
activity_two = insert(:note_activity, user: follower, note: object_two)
- {:ok, like, _} = CommonAPI.favorite(activity_two.id, user)
- {:ok, like_two, _} = CommonAPI.favorite(activity.id, follower)
+ {:ok, like} = CommonAPI.favorite(user, activity_two.id)
+ {:ok, like_two} = CommonAPI.favorite(follower, activity.id)
{:ok, repeat, _} = CommonAPI.repeat(activity_two.id, user)
{:ok, job} = User.delete(user)
@@ -1047,8 +1154,7 @@ defmodule Pleroma.UserTest do
follower = User.get_cached_by_id(follower.id)
refute User.following?(follower, user)
- refute User.get_by_id(user.id)
- assert {:ok, nil} == Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
+ assert %{deactivated: true} = User.get_by_id(user.id)
user_activities =
user.ap_id
@@ -1063,93 +1169,12 @@ defmodule Pleroma.UserTest do
refute Activity.get_by_id(like_two.id)
refute Activity.get_by_id(repeat.id)
end
-
- test_with_mock "it sends out User Delete activity",
- %{user: user},
- Pleroma.Web.ActivityPub.Publisher,
- [:passthrough],
- [] do
- Pleroma.Config.put([:instance, :federating], true)
-
- {:ok, follower} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
- {:ok, _} = User.follow(follower, user)
-
- {:ok, job} = User.delete(user)
- {:ok, _user} = ObanHelpers.perform(job)
-
- assert ObanHelpers.member?(
- %{
- "op" => "publish_one",
- "params" => %{
- "inbox" => "http://mastodon.example.org/inbox",
- "id" => "pleroma:fakeid"
- }
- },
- all_enqueued(worker: Pleroma.Workers.PublisherWorker)
- )
- end
end
test "get_public_key_for_ap_id fetches a user that's not in the db" do
assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin")
end
- describe "insert or update a user from given data" do
- test "with normal data" do
- user = insert(:user, %{nickname: "nick@name.de"})
- data = %{ap_id: user.ap_id <> "xxx", name: user.name, nickname: user.nickname}
-
- assert {:ok, %User{}} = User.insert_or_update_user(data)
- end
-
- test "with overly long fields" do
- current_max_length = Pleroma.Config.get([:instance, :account_field_value_length], 255)
- user = insert(:user, nickname: "nickname@supergood.domain")
-
- data = %{
- ap_id: user.ap_id,
- name: user.name,
- nickname: user.nickname,
- info: %{
- fields: [
- %{"name" => "myfield", "value" => String.duplicate("h", current_max_length + 1)}
- ]
- }
- }
-
- assert {:ok, %User{}} = User.insert_or_update_user(data)
- end
-
- test "with an overly long bio" do
- current_max_length = Pleroma.Config.get([:instance, :user_bio_length], 5000)
- user = insert(:user, nickname: "nickname@supergood.domain")
-
- data = %{
- ap_id: user.ap_id,
- name: user.name,
- nickname: user.nickname,
- bio: String.duplicate("h", current_max_length + 1),
- info: %{}
- }
-
- assert {:ok, %User{}} = User.insert_or_update_user(data)
- end
-
- test "with an overly long display name" do
- current_max_length = Pleroma.Config.get([:instance, :user_name_length], 100)
- user = insert(:user, nickname: "nickname@supergood.domain")
-
- data = %{
- ap_id: user.ap_id,
- name: String.duplicate("h", current_max_length + 1),
- nickname: user.nickname,
- info: %{}
- }
-
- assert {:ok, %User{}} = User.insert_or_update_user(data)
- end
- end
-
describe "per-user rich-text filtering" do
test "html_filter_policy returns default policies, when rich-text is enabled" do
user = insert(:user)
@@ -1158,7 +1183,7 @@ defmodule Pleroma.UserTest do
end
test "html_filter_policy returns TwitterText scrubber when rich-text is disabled" do
- user = insert(:user, %{info: %{no_rich_text: true}})
+ user = insert(:user, no_rich_text: true)
assert Pleroma.HTML.Scrubber.TwitterText == User.html_filter_policy(user)
end
@@ -1167,13 +1192,12 @@ defmodule Pleroma.UserTest do
describe "caching" do
test "invalidate_cache works" do
user = insert(:user)
- _user_info = User.get_cached_user_info(user)
+ User.set_cache(user)
User.invalidate_cache(user)
{:ok, nil} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
{:ok, nil} = Cachex.get(:user_cache, "nickname:#{user.nickname}")
- {:ok, nil} = Cachex.get(:user_cache, "user_info:#{user.id}")
end
test "User.delete() plugs any possible zombie objects" do
@@ -1192,16 +1216,35 @@ defmodule Pleroma.UserTest do
end
end
- test "auth_active?/1 works correctly" do
- Pleroma.Config.put([:instance, :account_activation_required], true)
+ describe "account_status/1" do
+ setup do: clear_config([:instance, :account_activation_required])
- local_user = insert(:user, local: true, info: %{confirmation_pending: true})
- confirmed_user = insert(:user, local: true, info: %{confirmation_pending: false})
- remote_user = insert(:user, local: false)
+ test "return confirmation_pending for unconfirm user" do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+ user = insert(:user, confirmation_pending: true)
+ assert User.account_status(user) == :confirmation_pending
+ end
- refute User.auth_active?(local_user)
- assert User.auth_active?(confirmed_user)
- assert User.auth_active?(remote_user)
+ test "return active for confirmed user" do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+ user = insert(:user, confirmation_pending: false)
+ assert User.account_status(user) == :active
+ end
+
+ test "return active for remote user" do
+ user = insert(:user, local: false)
+ assert User.account_status(user) == :active
+ end
+
+ test "returns :password_reset_pending for user with reset password" do
+ user = insert(:user, password_reset_pending: true)
+ assert User.account_status(user) == :password_reset_pending
+ end
+
+ test "returns :deactivated for deactivated user" do
+ user = insert(:user, local: true, confirmation_pending: false, deactivated: true)
+ assert User.account_status(user) == :deactivated
+ end
end
describe "superuser?/1" do
@@ -1213,20 +1256,20 @@ defmodule Pleroma.UserTest do
test "returns false for remote users" do
user = insert(:user, local: false)
- remote_admin_user = insert(:user, local: false, info: %{is_admin: true})
+ remote_admin_user = insert(:user, local: false, is_admin: true)
refute User.superuser?(user)
refute User.superuser?(remote_admin_user)
end
test "returns true for local moderators" do
- user = insert(:user, local: true, info: %{is_moderator: true})
+ user = insert(:user, local: true, is_moderator: true)
assert User.superuser?(user)
end
test "returns true for local admins" do
- user = insert(:user, local: true, info: %{is_admin: true})
+ user = insert(:user, local: true, is_admin: true)
assert User.superuser?(user)
end
@@ -1234,7 +1277,7 @@ defmodule Pleroma.UserTest do
describe "invisible?/1" do
test "returns true for an invisible user" do
- user = insert(:user, local: true, info: %{invisible: true})
+ user = insert(:user, local: true, invisible: true)
assert User.invisible?(user)
end
@@ -1256,14 +1299,14 @@ defmodule Pleroma.UserTest do
test "returns false when the account is unauthenticated and auth is required" do
Pleroma.Config.put([:instance, :account_activation_required], true)
- user = insert(:user, local: true, info: %{confirmation_pending: true})
+ user = insert(:user, local: true, confirmation_pending: true)
other_user = insert(:user, local: true)
refute User.visible_for?(user, other_user)
end
test "returns true when the account is unauthenticated and auth is not required" do
- user = insert(:user, local: true, info: %{confirmation_pending: true})
+ user = insert(:user, local: true, confirmation_pending: true)
other_user = insert(:user, local: true)
assert User.visible_for?(user, other_user)
@@ -1272,8 +1315,8 @@ defmodule Pleroma.UserTest do
test "returns true when the account is unauthenticated and being viewed by a privileged account (auth required)" do
Pleroma.Config.put([:instance, :account_activation_required], true)
- user = insert(:user, local: true, info: %{confirmation_pending: true})
- other_user = insert(:user, local: true, info: %{is_admin: true})
+ user = insert(:user, local: true, confirmation_pending: true)
+ other_user = insert(:user, local: true, is_admin: true)
assert User.visible_for?(user, other_user)
end
@@ -1286,7 +1329,7 @@ defmodule Pleroma.UserTest do
bio = "A.k.a. @nick@domain.com"
expected_text =
- ~s(A.k.a. <span class="h-card"><a data-user="#{remote_user.id}" class="u-url mention" href="#{
+ ~s(A.k.a. <span class="h-card"><a class="u-url mention" data-user="#{remote_user.id}" href="#{
remote_user.ap_id
}" rel="ugc">@<span>nick@domain.com</span></a></span>)
@@ -1320,9 +1363,10 @@ defmodule Pleroma.UserTest do
{:ok, _follower2} = User.follow(follower2, user)
{:ok, _follower3} = User.follow(follower3, user)
- {:ok, user} = User.block(user, follower)
+ {:ok, _user_relationship} = User.block(user, follower)
+ user = refresh_record(user)
- assert User.user_info(user).follower_count == 2
+ assert user.follower_count == 2
end
describe "list_inactive_users_query/1" do
@@ -1339,7 +1383,7 @@ defmodule Pleroma.UserTest do
users =
Enum.map(1..total, fn _ ->
- insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false})
+ insert(:user, last_digest_emailed_at: days_ago(20), deactivated: false)
end)
inactive_users_ids =
@@ -1357,7 +1401,7 @@ defmodule Pleroma.UserTest do
users =
Enum.map(1..total, fn _ ->
- insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false})
+ insert(:user, last_digest_emailed_at: days_ago(20), deactivated: false)
end)
{inactive, active} = Enum.split(users, trunc(total / 2))
@@ -1367,7 +1411,7 @@ defmodule Pleroma.UserTest do
{:ok, _} =
CommonAPI.post(user, %{
- "status" => "hey @#{to.nickname}"
+ status: "hey @#{to.nickname}"
})
end)
@@ -1390,7 +1434,7 @@ defmodule Pleroma.UserTest do
users =
Enum.map(1..total, fn _ ->
- insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false})
+ insert(:user, last_digest_emailed_at: days_ago(20), deactivated: false)
end)
[sender | recipients] = users
@@ -1399,12 +1443,12 @@ defmodule Pleroma.UserTest do
Enum.each(recipients, fn to ->
{:ok, _} =
CommonAPI.post(sender, %{
- "status" => "hey @#{to.nickname}"
+ status: "hey @#{to.nickname}"
})
{:ok, _} =
CommonAPI.post(sender, %{
- "status" => "hey again @#{to.nickname}"
+ status: "hey again @#{to.nickname}"
})
end)
@@ -1430,19 +1474,19 @@ defmodule Pleroma.UserTest do
describe "toggle_confirmation/1" do
test "if user is confirmed" do
- user = insert(:user, info: %{confirmation_pending: false})
+ user = insert(:user, confirmation_pending: false)
{:ok, user} = User.toggle_confirmation(user)
- assert user.info.confirmation_pending
- assert user.info.confirmation_token
+ assert user.confirmation_pending
+ assert user.confirmation_token
end
test "if user is unconfirmed" do
- user = insert(:user, info: %{confirmation_pending: true, confirmation_token: "some token"})
+ user = insert(:user, confirmation_pending: true, confirmation_token: "some token")
{:ok, user} = User.toggle_confirmation(user)
- refute user.info.confirmation_pending
- refute user.info.confirmation_token
+ refute user.confirmation_pending
+ refute user.confirmation_token
end
end
@@ -1478,7 +1522,7 @@ defmodule Pleroma.UserTest do
user1 = insert(:user, local: false, ap_id: "http://localhost:4001/users/masto_closed")
user2 = insert(:user, local: false, ap_id: "http://localhost:4001/users/fuser2")
insert(:user, local: true)
- insert(:user, local: false, info: %{deactivated: true})
+ insert(:user, local: false, deactivated: true)
{:ok, user1: user1, user2: user2}
end
@@ -1499,51 +1543,6 @@ defmodule Pleroma.UserTest do
end
end
- describe "set_info_cache/2" do
- setup do
- user = insert(:user)
- {:ok, user: user}
- end
-
- test "update from args", %{user: user} do
- User.set_info_cache(user, %{following_count: 15, follower_count: 18})
-
- %{follower_count: followers, following_count: following} = User.get_cached_user_info(user)
- assert followers == 18
- assert following == 15
- end
-
- test "without args", %{user: user} do
- User.set_info_cache(user, %{})
-
- %{follower_count: followers, following_count: following} = User.get_cached_user_info(user)
- assert followers == 0
- assert following == 0
- end
- end
-
- describe "user_info/2" do
- setup do
- user = insert(:user)
- {:ok, user: user}
- end
-
- test "update from args", %{user: user} do
- %{follower_count: followers, following_count: following} =
- User.user_info(user, %{following_count: 15, follower_count: 18})
-
- assert followers == 18
- assert following == 15
- end
-
- test "without args", %{user: user} do
- %{follower_count: followers, following_count: following} = User.user_info(user)
-
- assert followers == 0
- assert following == 0
- end
- end
-
describe "is_internal_user?/1" do
test "non-internal user returns false" do
user = insert(:user)
@@ -1586,7 +1585,7 @@ defmodule Pleroma.UserTest do
end
describe "following/followers synchronization" do
- clear_config([:instance, :external_user_synchronization])
+ setup do: clear_config([:instance, :external_user_synchronization])
test "updates the counters normally on following/getting a follow when disabled" do
Pleroma.Config.put([:instance, :external_user_synchronization], false)
@@ -1597,17 +1596,17 @@ defmodule Pleroma.UserTest do
local: false,
follower_address: "http://localhost:4001/users/masto_closed/followers",
following_address: "http://localhost:4001/users/masto_closed/following",
- info: %{ap_enabled: true}
+ ap_enabled: true
)
- assert User.user_info(other_user).following_count == 0
- assert User.user_info(other_user).follower_count == 0
+ assert other_user.following_count == 0
+ assert other_user.follower_count == 0
{:ok, user} = Pleroma.User.follow(user, other_user)
other_user = Pleroma.User.get_by_id(other_user.id)
- assert User.user_info(user).following_count == 1
- assert User.user_info(other_user).follower_count == 1
+ assert user.following_count == 1
+ assert other_user.follower_count == 1
end
test "syncronizes the counters with the remote instance for the followed when enabled" do
@@ -1620,17 +1619,17 @@ defmodule Pleroma.UserTest do
local: false,
follower_address: "http://localhost:4001/users/masto_closed/followers",
following_address: "http://localhost:4001/users/masto_closed/following",
- info: %{ap_enabled: true}
+ ap_enabled: true
)
- assert User.user_info(other_user).following_count == 0
- assert User.user_info(other_user).follower_count == 0
+ assert other_user.following_count == 0
+ assert other_user.follower_count == 0
Pleroma.Config.put([:instance, :external_user_synchronization], true)
{:ok, _user} = User.follow(user, other_user)
other_user = User.get_by_id(other_user.id)
- assert User.user_info(other_user).follower_count == 437
+ assert other_user.follower_count == 437
end
test "syncronizes the counters with the remote instance for the follower when enabled" do
@@ -1643,16 +1642,16 @@ defmodule Pleroma.UserTest do
local: false,
follower_address: "http://localhost:4001/users/masto_closed/followers",
following_address: "http://localhost:4001/users/masto_closed/following",
- info: %{ap_enabled: true}
+ ap_enabled: true
)
- assert User.user_info(other_user).following_count == 0
- assert User.user_info(other_user).follower_count == 0
+ assert other_user.following_count == 0
+ assert other_user.follower_count == 0
Pleroma.Config.put([:instance, :external_user_synchronization], true)
{:ok, other_user} = User.follow(other_user, user)
- assert User.user_info(other_user).following_count == 152
+ assert other_user.following_count == 152
end
end
@@ -1683,54 +1682,16 @@ defmodule Pleroma.UserTest do
end
end
- describe "set_password_reset_pending/2" do
- setup do
- [user: insert(:user)]
- end
-
- test "sets password_reset_pending to true", %{user: user} do
- %{password_reset_pending: password_reset_pending} = user.info
-
- refute password_reset_pending
-
- {:ok, %{info: %{password_reset_pending: password_reset_pending}}} =
- User.force_password_reset(user)
-
- assert password_reset_pending
- end
- end
-
- test "change_info/2" do
- user = insert(:user)
- assert user.info.hide_follows == false
-
- changeset = User.change_info(user, &User.Info.profile_update(&1, %{hide_follows: true}))
- assert changeset.changes.info.changes.hide_follows == true
- end
-
- test "update_info/2" do
- user = insert(:user)
- assert user.info.hide_follows == false
-
- assert {:ok, _} = User.update_info(user, &User.Info.profile_update(&1, %{hide_follows: true}))
-
- assert %{info: %{hide_follows: true}} = Repo.get(User, user.id)
- assert {:ok, %{info: %{hide_follows: true}}} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
- end
-
describe "get_cached_by_nickname_or_id" do
setup do
- limit_to_local_content = Pleroma.Config.get([:instance, :limit_to_local_content])
local_user = insert(:user)
remote_user = insert(:user, nickname: "nickname@example.com", local: false)
- on_exit(fn ->
- Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local_content)
- end)
-
[local_user: local_user, remote_user: remote_user]
end
+ setup do: clear_config([:instance, :limit_to_local_content])
+
test "allows getting remote users by id no matter what :limit_to_local_content is set to", %{
remote_user: remote_user
} do
@@ -1774,4 +1735,18 @@ defmodule Pleroma.UserTest do
assert %User{} = User.get_cached_by_nickname_or_id(local_user.nickname)
end
end
+
+ describe "update_email_notifications/2" do
+ setup do
+ user = insert(:user, email_notifications: %{"digest" => true})
+
+ {:ok, user: user}
+ end
+
+ test "Notifications are updated", %{user: user} do
+ true = user.email_notifications["digest"]
+ assert {:ok, result} = User.update_email_notifications(user, %{"digest" => false})
+ assert result.email_notifications["digest"] == false
+ end
+ end
end
diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs
index 6a3e48b5e..c432c90e3 100644
--- a/test/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/web/activity_pub/activity_pub_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
@@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
import Pleroma.Factory
alias Pleroma.Activity
+ alias Pleroma.Config
alias Pleroma.Delivery
alias Pleroma.Instances
alias Pleroma.Object
@@ -25,12 +26,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
:ok
end
- clear_config_all([:instance, :federating],
- do: Pleroma.Config.put([:instance, :federating], true)
- )
+ setup do: clear_config([:instance, :federating], true)
describe "/relay" do
- clear_config([:instance, :allow_relay])
+ setup do: clear_config([:instance, :allow_relay])
test "with the relay active, it returns the relay user", %{conn: conn} do
res =
@@ -42,12 +41,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
end
test "with the relay disabled, it returns 404", %{conn: conn} do
- Pleroma.Config.put([:instance, :allow_relay], false)
+ Config.put([:instance, :allow_relay], false)
conn
|> get(activity_pub_path(conn, :relay))
|> json_response(404)
- |> assert
+ end
+
+ test "on non-federating instance, it returns 404", %{conn: conn} do
+ Config.put([:instance, :federating], false)
+ user = insert(:user)
+
+ conn
+ |> assign(:user, user)
+ |> get(activity_pub_path(conn, :relay))
+ |> json_response(404)
end
end
@@ -60,6 +68,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert res["id"] =~ "/fetch"
end
+
+ test "on non-federating instance, it returns 404", %{conn: conn} do
+ Config.put([:instance, :federating], false)
+ user = insert(:user)
+
+ conn
+ |> assign(:user, user)
+ |> get(activity_pub_path(conn, :internal_fetch))
+ |> json_response(404)
+ end
end
describe "/users/:nickname" do
@@ -110,9 +128,47 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
+
+ test "it returns 404 for remote users", %{
+ conn: conn
+ } do
+ user = insert(:user, local: false, nickname: "remoteuser@example.com")
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> get("/users/#{user.nickname}.json")
+
+ assert json_response(conn, 404)
+ end
+
+ test "it returns error when user is not found", %{conn: conn} do
+ response =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> get("/users/jimm")
+ |> json_response(404)
+
+ assert response == "Not found"
+ end
+
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn
+ } do
+ user = insert(:user)
+
+ conn =
+ put_req_header(
+ conn,
+ "accept",
+ "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
+ )
+
+ ensure_federating_or_authenticated(conn, "/users/#{user.nickname}.json", user)
+ end
end
- describe "/object/:uuid" do
+ describe "/objects/:uuid" do
test "it returns a json representation of the object with accept application/json", %{
conn: conn
} do
@@ -223,6 +279,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "Not found" == json_response(conn2, :not_found)
end
+
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ note = insert(:note)
+ uuid = String.split(note.data["id"], "/") |> List.last()
+
+ conn = put_req_header(conn, "accept", "application/activity+json")
+
+ ensure_federating_or_authenticated(conn, "/objects/#{uuid}", user)
+ end
end
describe "/activities/:uuid" do
@@ -273,7 +341,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
test "cached purged after activity deletion", %{conn: conn} do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "cofe"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "cofe"})
uuid = String.split(activity.data["id"], "/") |> List.last()
@@ -285,7 +353,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert json_response(conn1, :ok)
assert Enum.any?(conn1.resp_headers, &(&1 == {"x-cache", "MISS from Pleroma"}))
- Activity.delete_by_ap_id(activity.object.data["id"])
+ Activity.delete_all_by_object_ap_id(activity.object.data["id"])
conn2 =
conn
@@ -294,6 +362,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "Not found" == json_response(conn2, :not_found)
end
+
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ activity = insert(:note_activity)
+ uuid = String.split(activity.data["id"], "/") |> List.last()
+
+ conn = put_req_header(conn, "accept", "application/activity+json")
+
+ ensure_federating_or_authenticated(conn, "/activities/#{uuid}", user)
+ end
end
describe "/inbox" do
@@ -328,6 +408,72 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert "ok" == json_response(conn, 200)
assert Instances.reachable?(sender_url)
end
+
+ test "accept follow activity", %{conn: conn} do
+ Pleroma.Config.put([:instance, :federating], true)
+ relay = Relay.get_actor()
+
+ assert {:ok, %Activity{} = activity} = Relay.follow("https://relay.mastodon.host/actor")
+
+ followed_relay = Pleroma.User.get_by_ap_id("https://relay.mastodon.host/actor")
+ relay = refresh_record(relay)
+
+ accept =
+ File.read!("test/fixtures/relay/accept-follow.json")
+ |> String.replace("{{ap_id}}", relay.ap_id)
+ |> String.replace("{{activity_id}}", activity.data["id"])
+
+ assert "ok" ==
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/inbox", accept)
+ |> json_response(200)
+
+ ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
+
+ assert Pleroma.FollowingRelationship.following?(
+ relay,
+ followed_relay
+ )
+
+ Mix.shell(Mix.Shell.Process)
+
+ on_exit(fn ->
+ Mix.shell(Mix.Shell.IO)
+ end)
+
+ :ok = Mix.Tasks.Pleroma.Relay.run(["list"])
+ assert_receive {:mix_shell, :info, ["relay.mastodon.host"]}
+ end
+
+ test "without valid signature, " <>
+ "it only accepts Create activities and requires enabled federation",
+ %{conn: conn} do
+ data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!()
+ non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!()
+
+ conn = put_req_header(conn, "content-type", "application/activity+json")
+
+ Config.put([:instance, :federating], false)
+
+ conn
+ |> post("/inbox", data)
+ |> json_response(403)
+
+ conn
+ |> post("/inbox", non_create_data)
+ |> json_response(403)
+
+ Config.put([:instance, :federating], true)
+
+ ret_conn = post(conn, "/inbox", data)
+ assert "ok" == json_response(ret_conn, 200)
+
+ conn
+ |> post("/inbox", non_create_data)
+ |> json_response(400)
+ end
end
describe "/users/:nickname/inbox" do
@@ -354,6 +500,87 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert Activity.get_by_ap_id(data["id"])
end
+ test "it accepts messages with to as string instead of array", %{conn: conn, data: data} do
+ user = insert(:user)
+
+ data =
+ Map.put(data, "to", user.ap_id)
+ |> Map.delete("cc")
+
+ conn =
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/users/#{user.nickname}/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+ ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
+ assert Activity.get_by_ap_id(data["id"])
+ end
+
+ test "it accepts messages with cc as string instead of array", %{conn: conn, data: data} do
+ user = insert(:user)
+
+ data =
+ Map.put(data, "cc", user.ap_id)
+ |> Map.delete("to")
+
+ conn =
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/users/#{user.nickname}/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+ ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
+ %Activity{} = activity = Activity.get_by_ap_id(data["id"])
+ assert user.ap_id in activity.recipients
+ end
+
+ test "it accepts messages with bcc as string instead of array", %{conn: conn, data: data} do
+ user = insert(:user)
+
+ data =
+ Map.put(data, "bcc", user.ap_id)
+ |> Map.delete("to")
+ |> Map.delete("cc")
+
+ conn =
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/users/#{user.nickname}/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+ ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
+ assert Activity.get_by_ap_id(data["id"])
+ end
+
+ test "it accepts announces with to as string instead of array", %{conn: conn} do
+ user = insert(:user)
+
+ data = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "actor" => "http://mastodon.example.org/users/admin",
+ "id" => "http://mastodon.example.org/users/admin/statuses/19512778738411822/activity",
+ "object" => "https://mastodon.social/users/emelie/statuses/101849165031453009",
+ "to" => "https://www.w3.org/ns/activitystreams#Public",
+ "cc" => [user.ap_id],
+ "type" => "Announce"
+ }
+
+ conn =
+ conn
+ |> assign(:valid_signature, true)
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/users/#{user.nickname}/inbox", data)
+
+ assert "ok" == json_response(conn, 200)
+ ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
+ %Activity{} = activity = Activity.get_by_ap_id(data["id"])
+ assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients
+ end
+
test "it accepts messages from actors that are followed by the user", %{
conn: conn,
data: data
@@ -385,22 +612,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
test "it rejects reads from other users", %{conn: conn} do
user = insert(:user)
- otheruser = insert(:user)
-
- conn =
- conn
- |> assign(:user, otheruser)
- |> put_req_header("accept", "application/activity+json")
- |> get("/users/#{user.nickname}/inbox")
-
- assert json_response(conn, 403)
- end
-
- test "it doesn't crash without an authenticated user", %{conn: conn} do
- user = insert(:user)
+ other_user = insert(:user)
conn =
conn
+ |> assign(:user, other_user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/inbox")
@@ -481,14 +697,30 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
refute recipient.follower_address in activity.data["cc"]
refute recipient.follower_address in activity.data["to"]
end
+
+ test "it requires authentication", %{conn: conn} do
+ user = insert(:user)
+ conn = put_req_header(conn, "accept", "application/activity+json")
+
+ ret_conn = get(conn, "/users/#{user.nickname}/inbox")
+ assert json_response(ret_conn, 403)
+
+ ret_conn =
+ conn
+ |> assign(:user, user)
+ |> get("/users/#{user.nickname}/inbox")
+
+ assert json_response(ret_conn, 200)
+ end
end
- describe "/users/:nickname/outbox" do
- test "it will not bomb when there is no activity", %{conn: conn} do
+ describe "GET /users/:nickname/outbox" do
+ test "it returns 200 even if there're no activities", %{conn: conn} do
user = insert(:user)
conn =
conn
+ |> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox")
@@ -503,6 +735,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
+ |> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
@@ -515,54 +748,127 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
+ |> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
assert response(conn, 200) =~ announce_activity.data["object"]
end
- test "it rejects posts from other users", %{conn: conn} do
- data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!()
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn
+ } do
user = insert(:user)
- otheruser = insert(:user)
+ conn = put_req_header(conn, "accept", "application/activity+json")
- conn =
+ ensure_federating_or_authenticated(conn, "/users/#{user.nickname}/outbox", user)
+ end
+ end
+
+ describe "POST /users/:nickname/outbox (C2S)" do
+ setup do
+ [
+ activity: %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "type" => "Create",
+ "object" => %{"type" => "Note", "content" => "AP C2S test"},
+ "to" => "https://www.w3.org/ns/activitystreams#Public",
+ "cc" => []
+ }
+ ]
+ end
+
+ test "it rejects posts from other users / unauthenticated users", %{
+ conn: conn,
+ activity: activity
+ } do
+ user = insert(:user)
+ other_user = insert(:user)
+ conn = put_req_header(conn, "content-type", "application/activity+json")
+
+ conn
+ |> post("/users/#{user.nickname}/outbox", activity)
+ |> json_response(403)
+
+ conn
+ |> assign(:user, other_user)
+ |> post("/users/#{user.nickname}/outbox", activity)
+ |> json_response(403)
+ end
+
+ test "it inserts an incoming create activity into the database", %{
+ conn: conn,
+ activity: activity
+ } do
+ user = insert(:user)
+
+ result =
conn
- |> assign(:user, otheruser)
+ |> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
- |> post("/users/#{user.nickname}/outbox", data)
+ |> post("/users/#{user.nickname}/outbox", activity)
+ |> json_response(201)
- assert json_response(conn, 403)
+ assert Activity.get_by_ap_id(result["id"])
+ assert result["object"]
+ assert %Object{data: object} = Object.normalize(result["object"])
+ assert object["content"] == activity["object"]["content"]
end
- test "it inserts an incoming create activity into the database", %{conn: conn} do
- data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!()
+ test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do
user = insert(:user)
- conn =
+ activity =
+ activity
+ |> put_in(["object", "type"], "Benis")
+
+ _result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
- |> post("/users/#{user.nickname}/outbox", data)
+ |> post("/users/#{user.nickname}/outbox", activity)
+ |> json_response(400)
+ end
- result = json_response(conn, 201)
+ test "it inserts an incoming sensitive activity into the database", %{
+ conn: conn,
+ activity: activity
+ } do
+ user = insert(:user)
+ conn = assign(conn, :user, user)
+ object = Map.put(activity["object"], "sensitive", true)
+ activity = Map.put(activity, "object", object)
- assert Activity.get_by_ap_id(result["id"])
+ response =
+ conn
+ |> put_req_header("content-type", "application/activity+json")
+ |> post("/users/#{user.nickname}/outbox", activity)
+ |> json_response(201)
+
+ assert Activity.get_by_ap_id(response["id"])
+ assert response["object"]
+ assert %Object{data: response_object} = Object.normalize(response["object"])
+ assert response_object["sensitive"] == true
+ assert response_object["content"] == activity["object"]["content"]
+
+ representation =
+ conn
+ |> put_req_header("accept", "application/activity+json")
+ |> get(response["id"])
+ |> json_response(200)
+
+ assert representation["object"]["sensitive"] == true
end
- test "it rejects an incoming activity with bogus type", %{conn: conn} do
- data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!()
+ test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do
user = insert(:user)
-
- data =
- data
- |> Map.put("type", "BadType")
+ activity = Map.put(activity, "type", "BadType")
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
- |> post("/users/#{user.nickname}/outbox", data)
+ |> post("/users/#{user.nickname}/outbox", activity)
assert json_response(conn, 400)
end
@@ -647,24 +953,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
- |> assign(:relay, true)
|> get("/relay/followers")
|> json_response(200)
assert result["first"]["orderedItems"] == [user.ap_id]
end
+
+ test "on non-federating instance, it returns 404", %{conn: conn} do
+ Config.put([:instance, :federating], false)
+ user = insert(:user)
+
+ conn
+ |> assign(:user, user)
+ |> get("/relay/followers")
+ |> json_response(404)
+ end
end
describe "/relay/following" do
test "it returns relay following", %{conn: conn} do
result =
conn
- |> assign(:relay, true)
|> get("/relay/following")
|> json_response(200)
assert result["first"]["orderedItems"] == []
end
+
+ test "on non-federating instance, it returns 404", %{conn: conn} do
+ Config.put([:instance, :federating], false)
+ user = insert(:user)
+
+ conn
+ |> assign(:user, user)
+ |> get("/relay/following")
+ |> json_response(404)
+ end
end
describe "/users/:nickname/followers" do
@@ -675,32 +999,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user_two)
|> get("/users/#{user_two.nickname}/followers")
|> json_response(200)
assert result["first"]["orderedItems"] == [user.ap_id]
end
- test "it returns returns a uri if the user has 'hide_followers' set", %{conn: conn} do
+ test "it returns a uri if the user has 'hide_followers' set", %{conn: conn} do
user = insert(:user)
- user_two = insert(:user, %{info: %{hide_followers: true}})
+ user_two = insert(:user, hide_followers: true)
User.follow(user, user_two)
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user_two.nickname}/followers")
|> json_response(200)
assert is_binary(result["first"])
end
- test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is not authenticated",
+ test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is from another user",
%{conn: conn} do
- user = insert(:user, %{info: %{hide_followers: true}})
+ user = insert(:user)
+ other_user = insert(:user, hide_followers: true)
result =
conn
- |> get("/users/#{user.nickname}/followers?page=1")
+ |> assign(:user, user)
+ |> get("/users/#{other_user.nickname}/followers?page=1")
assert result.status == 403
assert result.resp_body == ""
@@ -708,7 +1036,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
test "it renders the page, if the user has 'hide_followers' set and the request is authenticated with the same user",
%{conn: conn} do
- user = insert(:user, %{info: %{hide_followers: true}})
+ user = insert(:user, hide_followers: true)
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
@@ -732,6 +1060,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user.nickname}/followers")
|> json_response(200)
@@ -741,12 +1070,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user.nickname}/followers?page=2")
|> json_response(200)
assert length(result["orderedItems"]) == 5
assert result["totalItems"] == 15
end
+
+ test "does not require authentication", %{conn: conn} do
+ user = insert(:user)
+
+ conn
+ |> get("/users/#{user.nickname}/followers")
+ |> json_response(200)
+ end
end
describe "/users/:nickname/following" do
@@ -757,6 +1095,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user.nickname}/following")
|> json_response(200)
@@ -764,25 +1103,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
end
test "it returns a uri if the user has 'hide_follows' set", %{conn: conn} do
- user = insert(:user, %{info: %{hide_follows: true}})
- user_two = insert(:user)
+ user = insert(:user)
+ user_two = insert(:user, hide_follows: true)
User.follow(user, user_two)
result =
conn
- |> get("/users/#{user.nickname}/following")
+ |> assign(:user, user)
+ |> get("/users/#{user_two.nickname}/following")
|> json_response(200)
assert is_binary(result["first"])
end
- test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is not authenticated",
+ test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is from another user",
%{conn: conn} do
- user = insert(:user, %{info: %{hide_follows: true}})
+ user = insert(:user)
+ user_two = insert(:user, hide_follows: true)
result =
conn
- |> get("/users/#{user.nickname}/following?page=1")
+ |> assign(:user, user)
+ |> get("/users/#{user_two.nickname}/following?page=1")
assert result.status == 403
assert result.resp_body == ""
@@ -790,7 +1132,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
test "it renders the page, if the user has 'hide_follows' set and the request is authenticated with the same user",
%{conn: conn} do
- user = insert(:user, %{info: %{hide_follows: true}})
+ user = insert(:user, hide_follows: true)
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
@@ -815,6 +1157,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user.nickname}/following")
|> json_response(200)
@@ -824,12 +1167,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
result =
conn
+ |> assign(:user, user)
|> get("/users/#{user.nickname}/following?page=2")
|> json_response(200)
assert length(result["orderedItems"]) == 5
assert result["totalItems"] == 15
end
+
+ test "does not require authentication", %{conn: conn} do
+ user = insert(:user)
+
+ conn
+ |> get("/users/#{user.nickname}/following")
+ |> json_response(200)
+ end
end
describe "delivery tracking" do
@@ -914,8 +1266,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
end
end
- describe "Additionnal ActivityPub C2S endpoints" do
- test "/api/ap/whoami", %{conn: conn} do
+ describe "Additional ActivityPub C2S endpoints" do
+ test "GET /api/ap/whoami", %{conn: conn} do
user = insert(:user)
conn =
@@ -926,12 +1278,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
user = User.get_cached_by_id(user.id)
assert UserView.render("user.json", %{user: user}) == json_response(conn, 200)
+
+ conn
+ |> get("/api/ap/whoami")
+ |> json_response(403)
end
- clear_config([:media_proxy])
- clear_config([Pleroma.Upload])
+ setup do: clear_config([:media_proxy])
+ setup do: clear_config([Pleroma.Upload])
- test "uploadMedia", %{conn: conn} do
+ test "POST /api/ap/upload_media", %{conn: conn} do
user = insert(:user)
desc = "Description of the image"
@@ -942,15 +1298,59 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
filename: "an_image.jpg"
}
- conn =
+ object =
conn
|> assign(:user, user)
|> post("/api/ap/upload_media", %{"file" => image, "description" => desc})
+ |> json_response(:created)
- assert object = json_response(conn, :created)
assert object["name"] == desc
assert object["type"] == "Document"
assert object["actor"] == user.ap_id
+ assert [%{"href" => object_href, "mediaType" => object_mediatype}] = object["url"]
+ assert is_binary(object_href)
+ assert object_mediatype == "image/jpeg"
+
+ activity_request = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "type" => "Create",
+ "object" => %{
+ "type" => "Note",
+ "content" => "AP C2S test, attachment",
+ "attachment" => [object]
+ },
+ "to" => "https://www.w3.org/ns/activitystreams#Public",
+ "cc" => []
+ }
+
+ activity_response =
+ conn
+ |> assign(:user, user)
+ |> post("/users/#{user.nickname}/outbox", activity_request)
+ |> json_response(:created)
+
+ assert activity_response["id"]
+ assert activity_response["object"]
+ assert activity_response["actor"] == user.ap_id
+
+ assert %Object{data: %{"attachment" => [attachment]}} =
+ Object.normalize(activity_response["object"])
+
+ assert attachment["type"] == "Document"
+ assert attachment["name"] == desc
+
+ assert [
+ %{
+ "href" => ^object_href,
+ "type" => "Link",
+ "mediaType" => ^object_mediatype
+ }
+ ] = attachment["url"]
+
+ # Fails if unauthenticated
+ conn
+ |> post("/api/ap/upload_media", %{"file" => image, "description" => desc})
+ |> json_response(403)
end
end
end
diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs
index 8ae946969..77bd07edf 100644
--- a/test/web/activity_pub/activity_pub_test.exs
+++ b/test/web/activity_pub/activity_pub_test.exs
@@ -1,32 +1,38 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
use Pleroma.DataCase
+ use Oban.Testing, repo: Pleroma.Repo
+
alias Pleroma.Activity
alias Pleroma.Builders.ActivityBuilder
+ alias Pleroma.Config
+ alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
+ import ExUnit.CaptureLog
+ import Mock
import Pleroma.Factory
import Tesla.Mock
- import Mock
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
- clear_config([:instance, :federating])
+ setup do: clear_config([:instance, :federating])
describe "streaming out participations" do
test "it streams them out" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, conversation} = Pleroma.Conversation.create_or_bump_for(activity)
@@ -50,8 +56,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
stream: fn _, _ -> nil end do
{:ok, activity} =
CommonAPI.post(user_one, %{
- "status" => "@#{user_two.nickname}",
- "visibility" => "direct"
+ status: "@#{user_two.nickname}",
+ visibility: "direct"
})
conversation =
@@ -68,15 +74,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "it restricts by the appropriate visibility" do
user = insert(:user)
- {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
+ {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
- {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
- {:ok, unlisted_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"})
+ {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
- {:ok, private_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities =
ActivityPub.fetch_activities([], %{:visibility => "direct", "actor_id" => user.ap_id})
@@ -112,15 +116,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "it excludes by the appropriate visibility" do
user = insert(:user)
- {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
+ {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
- {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
- {:ok, unlisted_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"})
+ {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
- {:ok, private_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities =
ActivityPub.fetch_activities([], %{
@@ -174,8 +176,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.ap_id == user_id
assert user.nickname == "admin@mastodon.example.org"
- assert user.info.source_data
- assert user.info.ap_enabled
+ assert user.ap_enabled
assert user.follower_address == "http://mastodon.example.org/users/admin/followers"
end
@@ -188,9 +189,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "it fetches the appropriate tag-restricted posts" do
user = insert(:user)
- {:ok, status_one} = CommonAPI.post(user, %{"status" => ". #test"})
- {:ok, status_two} = CommonAPI.post(user, %{"status" => ". #essais"})
- {:ok, status_three} = CommonAPI.post(user, %{"status" => ". #test #reject"})
+ {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"})
+ {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"})
+ {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"})
fetch_one = ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => "test"})
@@ -220,7 +221,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
describe "insertion" do
test "drops activities beyond a certain limit" do
- limit = Pleroma.Config.get([:instance, :remote_limit])
+ limit = Config.get([:instance, :remote_limit])
random_text =
:crypto.strong_rand_bytes(limit + 1)
@@ -366,7 +367,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert activity.actor == user.ap_id
user = User.get_cached_by_id(user.id)
- assert user.info.note_count == 0
+ assert user.note_count == 0
end
test "can be fetched into a timeline" do
@@ -381,6 +382,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
describe "create activities" do
+ test "it reverts create" do
+ user = insert(:user)
+
+ with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
+ assert {:error, :reverted} =
+ ActivityPub.create(%{
+ to: ["user1", "user2"],
+ actor: user,
+ context: "",
+ object: %{
+ "to" => ["user1", "user2"],
+ "type" => "Note",
+ "content" => "testing"
+ }
+ })
+ end
+
+ assert Repo.aggregate(Activity, :count, :id) == 0
+ assert Repo.aggregate(Object, :count, :id) == 0
+ end
+
test "removes doubled 'to' recipients" do
user = insert(:user)
@@ -406,57 +428,57 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "1",
- "visibility" => "public"
+ status: "1",
+ visibility: "public"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "2",
- "visibility" => "unlisted"
+ status: "2",
+ visibility: "unlisted"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "2",
- "visibility" => "private"
+ status: "2",
+ visibility: "private"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "3",
- "visibility" => "direct"
+ status: "3",
+ visibility: "direct"
})
user = User.get_cached_by_id(user.id)
- assert user.info.note_count == 2
+ assert user.note_count == 2
end
test "increases replies count" do
user = insert(:user)
user2 = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"})
ap_id = activity.data["id"]
- reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id}
+ reply_data = %{status: "1", in_reply_to_status_id: activity.id}
# public
- {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public"))
+ {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "public"))
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 1
# unlisted
- {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted"))
+ {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "unlisted"))
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# private
- {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private"))
+ {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "private"))
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# direct
- {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct"))
+ {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "direct"))
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
end
@@ -483,7 +505,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
activity_five = insert(:note_activity)
user = insert(:user)
- {:ok, user} = User.block(user, %{ap_id: activity_five.data["actor"]})
+ {:ok, _user_relationship} = User.block(user, %{ap_id: activity_five.data["actor"]})
activities = ActivityPub.fetch_activities_for_context("2hu", %{"blocking_user" => user})
assert activities == [activity_two, activity]
@@ -496,7 +518,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
- {:ok, user} = User.block(user, %{ap_id: activity_one.data["actor"]})
+ {:ok, _user_relationship} = User.block(user, %{ap_id: activity_one.data["actor"]})
activities =
ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true})
@@ -505,7 +527,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_one)
- {:ok, user} = User.unblock(user, %{ap_id: activity_one.data["actor"]})
+ {:ok, _user_block} = User.unblock(user, %{ap_id: activity_one.data["actor"]})
activities =
ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true})
@@ -514,7 +536,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
- {:ok, user} = User.block(user, %{ap_id: activity_three.data["actor"]})
+ {:ok, _user_relationship} = User.block(user, %{ap_id: activity_three.data["actor"]})
{:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
@@ -541,15 +563,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
blockee = insert(:user)
friend = insert(:user)
- {:ok, blocker} = User.block(blocker, blockee)
+ {:ok, _user_relationship} = User.block(blocker, blockee)
- {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey!"})
+ {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
- {:ok, activity_two} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"})
+ {:ok, activity_two} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
- {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
+ {:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
- {:ok, activity_four} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"})
+ {:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker})
@@ -564,11 +586,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
blockee = insert(:user)
friend = insert(:user)
- {:ok, blocker} = User.block(blocker, blockee)
+ {:ok, _user_relationship} = User.block(blocker, blockee)
- {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey!"})
+ {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
- {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
+ {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
{:ok, activity_three, _} = CommonAPI.repeat(activity_two.id, friend)
@@ -604,13 +626,48 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
refute repeat_activity in activities
end
+ test "does return activities from followed users on blocked domains" do
+ domain = "meanies.social"
+ domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
+ blocker = insert(:user)
+
+ {:ok, blocker} = User.follow(blocker, domain_user)
+ {:ok, blocker} = User.block_domain(blocker, domain)
+
+ assert User.following?(blocker, domain_user)
+ assert User.blocks_domain?(blocker, domain_user)
+ refute User.blocks?(blocker, domain_user)
+
+ note = insert(:note, %{data: %{"actor" => domain_user.ap_id}})
+ activity = insert(:note_activity, %{note: note})
+
+ activities =
+ ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true})
+
+ assert activity in activities
+
+ # And check that if the guy we DO follow boosts someone else from their domain,
+ # that should be hidden
+ another_user = insert(:user, %{ap_id: "https://#{domain}/@meanie2"})
+ bad_note = insert(:note, %{data: %{"actor" => another_user.ap_id}})
+ bad_activity = insert(:note_activity, %{note: bad_note})
+ {:ok, repeat_activity, _} = CommonAPI.repeat(bad_activity.id, domain_user)
+
+ activities =
+ ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true})
+
+ refute repeat_activity in activities
+ end
+
test "doesn't return muted activities" do
activity_one = insert(:note_activity)
activity_two = insert(:note_activity)
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
- {:ok, user} = User.mute(user, %User{ap_id: activity_one.data["actor"]})
+
+ activity_one_actor = User.get_by_ap_id(activity_one.data["actor"])
+ {:ok, _user_relationships} = User.mute(user, activity_one_actor)
activities =
ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true})
@@ -631,7 +688,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
- {:ok, user} = User.unmute(user, %User{ap_id: activity_one.data["actor"]})
+ {:ok, _user_mute} = User.unmute(user, activity_one_actor)
activities =
ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true})
@@ -640,7 +697,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
- {:ok, user} = User.mute(user, %User{ap_id: activity_three.data["actor"]})
+ activity_three_actor = User.get_by_ap_id(activity_three.data["actor"])
+ {:ok, _user_relationships} = User.mute(user, activity_three_actor)
{:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
@@ -693,7 +751,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, announce, _object} = CommonAPI.repeat(activity_three.id, booster)
- [announce_activity] = ActivityPub.fetch_activities([user.ap_id | user.following])
+ [announce_activity] = ActivityPub.fetch_activities([user.ap_id | User.following(user)])
assert announce_activity.id == announce.id
end
@@ -712,10 +770,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "doesn't retrieve unlisted activities" do
user = insert(:user)
- {:ok, _unlisted_activity} =
- CommonAPI.post(user, %{"status" => "yeah", "visibility" => "unlisted"})
+ {:ok, _unlisted_activity} = CommonAPI.post(user, %{status: "yeah", visibility: "unlisted"})
- {:ok, listed_activity} = CommonAPI.post(user, %{"status" => "yeah"})
+ {:ok, listed_activity} = CommonAPI.post(user, %{status: "yeah"})
[activity] = ActivityPub.fetch_public_activities()
@@ -733,63 +790,61 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
test "retrieves a maximum of 20 activities" do
- activities = ActivityBuilder.insert_list(30)
- last_expected = List.last(activities)
+ ActivityBuilder.insert_list(10)
+ expected_activities = ActivityBuilder.insert_list(20)
activities = ActivityPub.fetch_public_activities()
- last = List.last(activities)
+ assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 20
- assert last == last_expected
end
test "retrieves ids starting from a since_id" do
activities = ActivityBuilder.insert_list(30)
- later_activities = ActivityBuilder.insert_list(10)
+ expected_activities = ActivityBuilder.insert_list(10)
since_id = List.last(activities).id
- last_expected = List.last(later_activities)
activities = ActivityPub.fetch_public_activities(%{"since_id" => since_id})
- last = List.last(activities)
+ assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 10
- assert last == last_expected
end
test "retrieves ids up to max_id" do
- _first_activities = ActivityBuilder.insert_list(10)
- activities = ActivityBuilder.insert_list(20)
- later_activities = ActivityBuilder.insert_list(10)
- max_id = List.first(later_activities).id
- last_expected = List.last(activities)
+ ActivityBuilder.insert_list(10)
+ expected_activities = ActivityBuilder.insert_list(20)
+
+ %{id: max_id} =
+ 10
+ |> ActivityBuilder.insert_list()
+ |> List.first()
activities = ActivityPub.fetch_public_activities(%{"max_id" => max_id})
- last = List.last(activities)
assert length(activities) == 20
- assert last == last_expected
+ assert collect_ids(activities) == collect_ids(expected_activities)
end
test "paginates via offset/limit" do
- _first_activities = ActivityBuilder.insert_list(10)
- activities = ActivityBuilder.insert_list(10)
- _later_activities = ActivityBuilder.insert_list(10)
- first_expected = List.first(activities)
+ _first_part_activities = ActivityBuilder.insert_list(10)
+ second_part_activities = ActivityBuilder.insert_list(10)
+
+ later_activities = ActivityBuilder.insert_list(10)
activities =
ActivityPub.fetch_public_activities(%{"page" => "2", "page_size" => "20"}, :offset)
- first = List.first(activities)
-
assert length(activities) == 20
- assert first == first_expected
+
+ assert collect_ids(activities) ==
+ collect_ids(second_part_activities) ++ collect_ids(later_activities)
end
test "doesn't return reblogs for users for whom reblogs have been muted" do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
- {:ok, user} = CommonAPI.hide_reblogs(user, booster)
+ {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, booster)
{:ok, activity, _} = CommonAPI.repeat(activity.id, booster)
@@ -802,8 +857,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
- {:ok, user} = CommonAPI.hide_reblogs(user, booster)
- {:ok, user} = CommonAPI.show_reblogs(user, booster)
+ {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, booster)
+ {:ok, _reblog_mute} = CommonAPI.show_reblogs(user, booster)
{:ok, activity, _} = CommonAPI.repeat(activity.id, booster)
@@ -813,99 +868,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
end
- describe "like an object" do
- test_with_mock "sends an activity to federation", Pleroma.Web.Federator, [:passthrough], [] do
- Pleroma.Config.put([:instance, :federating], true)
- note_activity = insert(:note_activity)
- assert object_activity = Object.normalize(note_activity)
-
- user = insert(:user)
-
- {:ok, like_activity, _object} = ActivityPub.like(user, object_activity)
- assert called(Pleroma.Web.Federator.publish(like_activity))
- end
-
- test "returns exist activity if object already liked" do
- note_activity = insert(:note_activity)
- assert object_activity = Object.normalize(note_activity)
-
- user = insert(:user)
-
- {:ok, like_activity, _object} = ActivityPub.like(user, object_activity)
-
- {:ok, like_activity_exist, _object} = ActivityPub.like(user, object_activity)
- assert like_activity == like_activity_exist
- end
-
- test "adds a like activity to the db" do
- note_activity = insert(:note_activity)
- assert object = Object.normalize(note_activity)
-
- user = insert(:user)
- user_two = insert(:user)
-
- {:ok, like_activity, object} = ActivityPub.like(user, object)
-
- assert like_activity.data["actor"] == user.ap_id
- assert like_activity.data["type"] == "Like"
- assert like_activity.data["object"] == object.data["id"]
- assert like_activity.data["to"] == [User.ap_followers(user), note_activity.data["actor"]]
- assert like_activity.data["context"] == object.data["context"]
- assert object.data["like_count"] == 1
- assert object.data["likes"] == [user.ap_id]
-
- # Just return the original activity if the user already liked it.
- {:ok, same_like_activity, object} = ActivityPub.like(user, object)
-
- assert like_activity == same_like_activity
- assert object.data["likes"] == [user.ap_id]
- assert object.data["like_count"] == 1
-
- {:ok, _like_activity, object} = ActivityPub.like(user_two, object)
- assert object.data["like_count"] == 2
- end
- end
-
- describe "unliking" do
- test_with_mock "sends an activity to federation", Pleroma.Web.Federator, [:passthrough], [] do
- Pleroma.Config.put([:instance, :federating], true)
-
- note_activity = insert(:note_activity)
- object = Object.normalize(note_activity)
- user = insert(:user)
-
- {:ok, object} = ActivityPub.unlike(user, object)
- refute called(Pleroma.Web.Federator.publish())
-
- {:ok, _like_activity, object} = ActivityPub.like(user, object)
- assert object.data["like_count"] == 1
-
- {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
- assert object.data["like_count"] == 0
-
- assert called(Pleroma.Web.Federator.publish(unlike_activity))
- end
-
- test "unliking a previously liked object" do
- note_activity = insert(:note_activity)
- object = Object.normalize(note_activity)
- user = insert(:user)
-
- # Unliking something that hasn't been liked does nothing
- {:ok, object} = ActivityPub.unlike(user, object)
- assert object.data["like_count"] == 0
-
- {:ok, like_activity, object} = ActivityPub.like(user, object)
- assert object.data["like_count"] == 1
-
- {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
- assert object.data["like_count"] == 0
-
- assert Activity.get_by_id(like_activity.id) == nil
- assert note_activity.actor in unlike_activity.recipients
- end
- end
-
describe "announcing an object" do
test "adds an announce activity to the db" do
note_activity = insert(:note_activity)
@@ -925,12 +887,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert announce_activity.data["actor"] == user.ap_id
assert announce_activity.data["context"] == object.data["context"]
end
+
+ test "reverts annouce from object on error" do
+ note_activity = insert(:note_activity)
+ object = Object.normalize(note_activity)
+ user = insert(:user)
+
+ with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
+ assert {:error, :reverted} = ActivityPub.announce(user, object)
+ end
+
+ reloaded_object = Object.get_by_ap_id(object.data["id"])
+ assert reloaded_object == object
+ refute reloaded_object.data["announcement_count"]
+ refute reloaded_object.data["announcements"]
+ end
end
describe "announcing a private object" do
test "adds an announce activity to the db if the audience is not widened" do
user = insert(:user)
- {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
object = Object.normalize(note_activity)
{:ok, announce_activity, object} = ActivityPub.announce(user, object, nil, true, false)
@@ -944,7 +921,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "does not add an announce activity to the db if the audience is widened" do
user = insert(:user)
- {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
object = Object.normalize(note_activity)
assert {:error, _} = ActivityPub.announce(user, object, nil, true, true)
@@ -953,43 +930,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "does not add an announce activity to the db if the announcer is not the author" do
user = insert(:user)
announcer = insert(:user)
- {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
object = Object.normalize(note_activity)
assert {:error, _} = ActivityPub.announce(announcer, object, nil, true, false)
end
end
- describe "unannouncing an object" do
- test "unannouncing a previously announced object" do
- note_activity = insert(:note_activity)
- object = Object.normalize(note_activity)
- user = insert(:user)
-
- # Unannouncing an object that is not announced does nothing
- # {:ok, object} = ActivityPub.unannounce(user, object)
- # assert object.data["announcement_count"] == 0
-
- {:ok, announce_activity, object} = ActivityPub.announce(user, object)
- assert object.data["announcement_count"] == 1
-
- {:ok, unannounce_activity, object} = ActivityPub.unannounce(user, object)
- assert object.data["announcement_count"] == 0
-
- assert unannounce_activity.data["to"] == [
- User.ap_followers(user),
- object.data["actor"]
- ]
-
- assert unannounce_activity.data["type"] == "Undo"
- assert unannounce_activity.data["object"] == announce_activity.data
- assert unannounce_activity.data["actor"] == user.ap_id
- assert unannounce_activity.data["context"] == announce_activity.data["context"]
-
- assert Activity.get_by_id(announce_activity.id) == nil
- end
- end
-
describe "uploading files" do
test "copies the file to the configured folder" do
file = %Plug.Upload{
@@ -1004,7 +951,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "works with base64 encoded images" do
file = %{
- "img" => data_uri()
+ img: data_uri()
}
{:ok, %Object{}} = ActivityPub.upload(file)
@@ -1022,6 +969,35 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
describe "following / unfollowing" do
+ test "it reverts follow activity" do
+ follower = insert(:user)
+ followed = insert(:user)
+
+ with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
+ assert {:error, :reverted} = ActivityPub.follow(follower, followed)
+ end
+
+ assert Repo.aggregate(Activity, :count, :id) == 0
+ assert Repo.aggregate(Object, :count, :id) == 0
+ end
+
+ test "it reverts unfollow activity" do
+ follower = insert(:user)
+ followed = insert(:user)
+
+ {:ok, follow_activity} = ActivityPub.follow(follower, followed)
+
+ with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
+ assert {:error, :reverted} = ActivityPub.unfollow(follower, followed)
+ end
+
+ activity = Activity.get_by_id(follow_activity.id)
+ assert activity.data["type"] == "Follow"
+ assert activity.data["actor"] == follower.ap_id
+
+ assert activity.data["object"] == followed.ap_id
+ end
+
test "creates a follow activity" do
follower = insert(:user)
followed = insert(:user)
@@ -1048,139 +1024,70 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert embedded_object["object"] == followed.ap_id
assert embedded_object["id"] == follow_activity.data["id"]
end
- end
- describe "blocking / unblocking" do
- test "creates a block activity" do
- blocker = insert(:user)
- blocked = insert(:user)
-
- {:ok, activity} = ActivityPub.block(blocker, blocked)
-
- assert activity.data["type"] == "Block"
- assert activity.data["actor"] == blocker.ap_id
- assert activity.data["object"] == blocked.ap_id
- end
-
- test "creates an undo activity for the last block" do
- blocker = insert(:user)
- blocked = insert(:user)
+ test "creates an undo activity for a pending follow request" do
+ follower = insert(:user)
+ followed = insert(:user, %{locked: true})
- {:ok, block_activity} = ActivityPub.block(blocker, blocked)
- {:ok, activity} = ActivityPub.unblock(blocker, blocked)
+ {:ok, follow_activity} = ActivityPub.follow(follower, followed)
+ {:ok, activity} = ActivityPub.unfollow(follower, followed)
assert activity.data["type"] == "Undo"
- assert activity.data["actor"] == blocker.ap_id
+ assert activity.data["actor"] == follower.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
- assert embedded_object["type"] == "Block"
- assert embedded_object["object"] == blocked.ap_id
- assert embedded_object["id"] == block_activity.data["id"]
+ assert embedded_object["type"] == "Follow"
+ assert embedded_object["object"] == followed.ap_id
+ assert embedded_object["id"] == follow_activity.data["id"]
end
end
- describe "deletion" do
- test "it creates a delete activity and deletes the original object" do
- note = insert(:note_activity)
- object = Object.normalize(note)
- {:ok, delete} = ActivityPub.delete(object)
-
- assert delete.data["type"] == "Delete"
- assert delete.data["actor"] == note.data["actor"]
- assert delete.data["object"] == object.data["id"]
+ describe "blocking" do
+ test "reverts block activity on error" do
+ [blocker, blocked] = insert_list(2, :user)
- assert Activity.get_by_id(delete.id) != nil
+ with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
+ assert {:error, :reverted} = ActivityPub.block(blocker, blocked)
+ end
- assert Repo.get(Object, object.id).data["type"] == "Tombstone"
+ assert Repo.aggregate(Activity, :count, :id) == 0
+ assert Repo.aggregate(Object, :count, :id) == 0
end
- test "decrements user note count only for public activities" do
- user = insert(:user, info: %{note_count: 10})
-
- {:ok, a1} =
- CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "yeah",
- "visibility" => "public"
- })
-
- {:ok, a2} =
- CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "yeah",
- "visibility" => "unlisted"
- })
-
- {:ok, a3} =
- CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "yeah",
- "visibility" => "private"
- })
-
- {:ok, a4} =
- CommonAPI.post(User.get_cached_by_id(user.id), %{
- "status" => "yeah",
- "visibility" => "direct"
- })
+ test "creates a block activity" do
+ clear_config([:instance, :federating], true)
+ blocker = insert(:user)
+ blocked = insert(:user)
- {:ok, _} = Object.normalize(a1) |> ActivityPub.delete()
- {:ok, _} = Object.normalize(a2) |> ActivityPub.delete()
- {:ok, _} = Object.normalize(a3) |> ActivityPub.delete()
- {:ok, _} = Object.normalize(a4) |> ActivityPub.delete()
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ {:ok, activity} = ActivityPub.block(blocker, blocked)
- user = User.get_cached_by_id(user.id)
- assert user.info.note_count == 10
- end
-
- test "it creates a delete activity and checks that it is also sent to users mentioned by the deleted object" do
- user = insert(:user)
- note = insert(:note_activity)
- object = Object.normalize(note)
-
- {:ok, object} =
- object
- |> Object.change(%{
- data: %{
- "actor" => object.data["actor"],
- "id" => object.data["id"],
- "to" => [user.ap_id],
- "type" => "Note"
- }
- })
- |> Object.update_and_set_cache()
+ assert activity.data["type"] == "Block"
+ assert activity.data["actor"] == blocker.ap_id
+ assert activity.data["object"] == blocked.ap_id
- {:ok, delete} = ActivityPub.delete(object)
-
- assert user.ap_id in delete.data["to"]
+ assert called(Pleroma.Web.Federator.publish(activity))
+ end
end
- test "decreases reply count" do
- user = insert(:user)
- user2 = insert(:user)
-
- {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"})
- reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id}
- ap_id = activity.data["id"]
-
- {:ok, public_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public"))
- {:ok, unlisted_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted"))
- {:ok, private_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private"))
- {:ok, direct_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct"))
-
- _ = CommonAPI.delete(direct_reply.id, user2)
- assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
- assert object.data["repliesCount"] == 2
+ test "works with outgoing blocks disabled, but doesn't federate" do
+ clear_config([:instance, :federating], true)
+ clear_config([:activitypub, :outgoing_blocks], false)
+ blocker = insert(:user)
+ blocked = insert(:user)
- _ = CommonAPI.delete(private_reply.id, user2)
- assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
- assert object.data["repliesCount"] == 2
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ {:ok, activity} = ActivityPub.block(blocker, blocked)
- _ = CommonAPI.delete(public_reply.id, user2)
- assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
- assert object.data["repliesCount"] == 1
+ assert activity.data["type"] == "Block"
+ assert activity.data["actor"] == blocker.ap_id
+ assert activity.data["object"] == blocked.ap_id
- _ = CommonAPI.delete(unlisted_reply.id, user2)
- assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
- assert object.data["repliesCount"] == 0
+ refute called(Pleroma.Web.Federator.publish(:_))
+ end
end
end
@@ -1199,27 +1106,26 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, user3} = User.follow(user3, user2)
assert User.following?(user3, user2)
- {:ok, public_activity} = CommonAPI.post(user3, %{"status" => "hi 1"})
+ {:ok, public_activity} = CommonAPI.post(user3, %{status: "hi 1"})
- {:ok, private_activity_1} =
- CommonAPI.post(user3, %{"status" => "hi 2", "visibility" => "private"})
+ {:ok, private_activity_1} = CommonAPI.post(user3, %{status: "hi 2", visibility: "private"})
{:ok, private_activity_2} =
CommonAPI.post(user2, %{
- "status" => "hi 3",
- "visibility" => "private",
- "in_reply_to_status_id" => private_activity_1.id
+ status: "hi 3",
+ visibility: "private",
+ in_reply_to_status_id: private_activity_1.id
})
{:ok, private_activity_3} =
CommonAPI.post(user3, %{
- "status" => "hi 4",
- "visibility" => "private",
- "in_reply_to_status_id" => private_activity_2.id
+ status: "hi 4",
+ visibility: "private",
+ in_reply_to_status_id: private_activity_2.id
})
activities =
- ActivityPub.fetch_activities([user1.ap_id | user1.following])
+ ActivityPub.fetch_activities([user1.ap_id | User.following(user1)])
|> Enum.map(fn a -> a.id end)
private_activity_1 = Activity.get_by_ap_id_with_object(private_activity_1.data["id"])
@@ -1229,7 +1135,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert length(activities) == 3
activities =
- ActivityPub.fetch_activities([user1.ap_id | user1.following], %{"user" => user1})
+ ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{"user" => user1})
|> Enum.map(fn a -> a.id end)
assert [public_activity.id, private_activity_1.id] == activities
@@ -1238,6 +1144,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
describe "update" do
+ setup do: clear_config([:instance, :max_pinned_statuses])
+
test "it creates an update activity with the new user data" do
user = insert(:user)
{:ok, user} = User.ensure_keys_present(user)
@@ -1260,12 +1168,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
end
test "returned pinned statuses" do
- Pleroma.Config.put([:instance, :max_pinned_statuses], 3)
+ Config.put([:instance, :max_pinned_statuses], 3)
user = insert(:user)
- {:ok, activity_one} = CommonAPI.post(user, %{"status" => "HI!!!"})
- {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"})
- {:ok, activity_three} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ {:ok, activity_one} = CommonAPI.post(user, %{status: "HI!!!"})
+ {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"})
+ {:ok, activity_three} = CommonAPI.post(user, %{status: "HI!!!"})
CommonAPI.pin(activity_one.id, user)
user = refresh_record(user)
@@ -1281,35 +1189,99 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
assert 3 = length(activities)
end
- test "it can create a Flag activity" do
- reporter = insert(:user)
- target_account = insert(:user)
- {:ok, activity} = CommonAPI.post(target_account, %{"status" => "foobar"})
- context = Utils.generate_context_id()
- content = "foobar"
-
- reporter_ap_id = reporter.ap_id
- target_ap_id = target_account.ap_id
- activity_ap_id = activity.data["id"]
-
- assert {:ok, activity} =
- ActivityPub.flag(%{
- actor: reporter,
- context: context,
- account: target_account,
- statuses: [activity],
- content: content
- })
-
- assert %Activity{
- actor: ^reporter_ap_id,
- data: %{
- "type" => "Flag",
- "content" => ^content,
- "context" => ^context,
- "object" => [^target_ap_id, ^activity_ap_id]
- }
- } = activity
+ describe "flag/1" do
+ setup do
+ reporter = insert(:user)
+ target_account = insert(:user)
+ content = "foobar"
+ {:ok, activity} = CommonAPI.post(target_account, %{status: content})
+ context = Utils.generate_context_id()
+
+ reporter_ap_id = reporter.ap_id
+ target_ap_id = target_account.ap_id
+ activity_ap_id = activity.data["id"]
+
+ activity_with_object = Activity.get_by_ap_id_with_object(activity_ap_id)
+
+ {:ok,
+ %{
+ reporter: reporter,
+ context: context,
+ target_account: target_account,
+ reported_activity: activity,
+ content: content,
+ activity_ap_id: activity_ap_id,
+ activity_with_object: activity_with_object,
+ reporter_ap_id: reporter_ap_id,
+ target_ap_id: target_ap_id
+ }}
+ end
+
+ test "it can create a Flag activity",
+ %{
+ reporter: reporter,
+ context: context,
+ target_account: target_account,
+ reported_activity: reported_activity,
+ content: content,
+ activity_ap_id: activity_ap_id,
+ activity_with_object: activity_with_object,
+ reporter_ap_id: reporter_ap_id,
+ target_ap_id: target_ap_id
+ } do
+ assert {:ok, activity} =
+ ActivityPub.flag(%{
+ actor: reporter,
+ context: context,
+ account: target_account,
+ statuses: [reported_activity],
+ content: content
+ })
+
+ note_obj = %{
+ "type" => "Note",
+ "id" => activity_ap_id,
+ "content" => content,
+ "published" => activity_with_object.object.data["published"],
+ "actor" => AccountView.render("show.json", %{user: target_account})
+ }
+
+ assert %Activity{
+ actor: ^reporter_ap_id,
+ data: %{
+ "type" => "Flag",
+ "content" => ^content,
+ "context" => ^context,
+ "object" => [^target_ap_id, ^note_obj]
+ }
+ } = activity
+ end
+
+ test_with_mock "strips status data from Flag, before federating it",
+ %{
+ reporter: reporter,
+ context: context,
+ target_account: target_account,
+ reported_activity: reported_activity,
+ content: content
+ },
+ Utils,
+ [:passthrough],
+ [] do
+ {:ok, activity} =
+ ActivityPub.flag(%{
+ actor: reporter,
+ context: context,
+ account: target_account,
+ statuses: [reported_activity],
+ content: content
+ })
+
+ new_data =
+ put_in(activity.data, ["object"], [target_account.ap_id, reported_activity.data["id"]])
+
+ assert_called(Utils.maybe_federate(%{activity | data: new_data}))
+ end
end
test "fetch_activities/2 returns activities addressed to a list " do
@@ -1318,8 +1290,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, list} = Pleroma.List.follow(list, member)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
activity = Repo.preload(activity, :bookmark)
activity = %Activity{activity | thread_muted?: !!activity.thread_muted?}
@@ -1337,8 +1308,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "thought I looked cute might delete later :3",
- "visibility" => "private"
+ status: "thought I looked cute might delete later :3",
+ visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([user.follower_address], [])
@@ -1347,12 +1318,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
test "fetches only public posts for other users" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe", "visibility" => "public"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe", visibility: "public"})
{:ok, _private_activity} =
CommonAPI.post(user, %{
- "status" => "why is tenshi eating a corndog so cute?",
- "visibility" => "private"
+ status: "why is tenshi eating a corndog so cute?",
+ visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([], [user.follower_address])
@@ -1392,9 +1363,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
following_address: "http://localhost:4001/users/masto_closed/following"
)
- {:ok, info} = ActivityPub.fetch_follow_information_for_user(user)
- assert info.hide_followers == true
- assert info.hide_follows == false
+ {:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
+ assert follow_info.hide_followers == true
+ assert follow_info.hide_follows == false
end
test "detects hidden follows" do
@@ -1415,9 +1386,688 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
following_address: "http://localhost:4001/users/masto_closed/following"
)
- {:ok, info} = ActivityPub.fetch_follow_information_for_user(user)
- assert info.hide_followers == false
- assert info.hide_follows == true
+ {:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
+ assert follow_info.hide_followers == false
+ assert follow_info.hide_follows == true
+ end
+
+ test "detects hidden follows/followers for friendica" do
+ user =
+ insert(:user,
+ local: false,
+ follower_address: "http://localhost:8080/followers/fuser3",
+ following_address: "http://localhost:8080/following/fuser3"
+ )
+
+ {:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
+ assert follow_info.hide_followers == true
+ assert follow_info.follower_count == 296
+ assert follow_info.following_count == 32
+ assert follow_info.hide_follows == true
+ end
+
+ test "doesn't crash when follower and following counters are hidden" do
+ mock(fn env ->
+ case env.url do
+ "http://localhost:4001/users/masto_hidden_counters/following" ->
+ json(%{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => "http://localhost:4001/users/masto_hidden_counters/followers"
+ })
+
+ "http://localhost:4001/users/masto_hidden_counters/following?page=1" ->
+ %Tesla.Env{status: 403, body: ""}
+
+ "http://localhost:4001/users/masto_hidden_counters/followers" ->
+ json(%{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => "http://localhost:4001/users/masto_hidden_counters/following"
+ })
+
+ "http://localhost:4001/users/masto_hidden_counters/followers?page=1" ->
+ %Tesla.Env{status: 403, body: ""}
+ end
+ end)
+
+ user =
+ insert(:user,
+ local: false,
+ follower_address: "http://localhost:4001/users/masto_hidden_counters/followers",
+ following_address: "http://localhost:4001/users/masto_hidden_counters/following"
+ )
+
+ {:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
+
+ assert follow_info.hide_followers == true
+ assert follow_info.follower_count == 0
+ assert follow_info.hide_follows == true
+ assert follow_info.following_count == 0
+ end
+ end
+
+ describe "fetch_favourites/3" do
+ test "returns a favourite activities sorted by adds to favorite" do
+ user = insert(:user)
+ other_user = insert(:user)
+ user1 = insert(:user)
+ user2 = insert(:user)
+ {:ok, a1} = CommonAPI.post(user1, %{status: "bla"})
+ {:ok, _a2} = CommonAPI.post(user2, %{status: "traps are happy"})
+ {:ok, a3} = CommonAPI.post(user2, %{status: "Trees Are "})
+ {:ok, a4} = CommonAPI.post(user2, %{status: "Agent Smith "})
+ {:ok, a5} = CommonAPI.post(user1, %{status: "Red or Blue "})
+
+ {:ok, _} = CommonAPI.favorite(user, a4.id)
+ {:ok, _} = CommonAPI.favorite(other_user, a3.id)
+ {:ok, _} = CommonAPI.favorite(user, a3.id)
+ {:ok, _} = CommonAPI.favorite(other_user, a5.id)
+ {:ok, _} = CommonAPI.favorite(user, a5.id)
+ {:ok, _} = CommonAPI.favorite(other_user, a4.id)
+ {:ok, _} = CommonAPI.favorite(user, a1.id)
+ {:ok, _} = CommonAPI.favorite(other_user, a1.id)
+ result = ActivityPub.fetch_favourites(user)
+
+ assert Enum.map(result, & &1.id) == [a1.id, a5.id, a3.id, a4.id]
+
+ result = ActivityPub.fetch_favourites(user, %{"limit" => 2})
+ assert Enum.map(result, & &1.id) == [a1.id, a5.id]
+ end
+ end
+
+ describe "Move activity" do
+ test "create" do
+ %{ap_id: old_ap_id} = old_user = insert(:user)
+ %{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
+ follower = insert(:user)
+ follower_move_opted_out = insert(:user, allow_following_move: false)
+
+ User.follow(follower, old_user)
+ User.follow(follower_move_opted_out, old_user)
+
+ assert User.following?(follower, old_user)
+ assert User.following?(follower_move_opted_out, old_user)
+
+ assert {:ok, activity} = ActivityPub.move(old_user, new_user)
+
+ assert %Activity{
+ actor: ^old_ap_id,
+ data: %{
+ "actor" => ^old_ap_id,
+ "object" => ^old_ap_id,
+ "target" => ^new_ap_id,
+ "type" => "Move"
+ },
+ local: true
+ } = activity
+
+ params = %{
+ "op" => "move_following",
+ "origin_id" => old_user.id,
+ "target_id" => new_user.id
+ }
+
+ assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
+
+ Pleroma.Workers.BackgroundWorker.perform(params, nil)
+
+ refute User.following?(follower, old_user)
+ assert User.following?(follower, new_user)
+
+ assert User.following?(follower_move_opted_out, old_user)
+ refute User.following?(follower_move_opted_out, new_user)
+
+ activity = %Activity{activity | object: nil}
+
+ assert [%Notification{activity: ^activity}] = Notification.for_user(follower)
+
+ assert [%Notification{activity: ^activity}] = Notification.for_user(follower_move_opted_out)
+ end
+
+ test "old user must be in the new user's `also_known_as` list" do
+ old_user = insert(:user)
+ new_user = insert(:user)
+
+ assert {:error, "Target account must have the origin in `alsoKnownAs`"} =
+ ActivityPub.move(old_user, new_user)
+ end
+ end
+
+ test "doesn't retrieve replies activities with exclude_replies" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "yeah"})
+
+ {:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id})
+
+ [result] = ActivityPub.fetch_public_activities(%{"exclude_replies" => "true"})
+
+ assert result.id == activity.id
+
+ assert length(ActivityPub.fetch_public_activities()) == 2
+ end
+
+ describe "replies filtering with public messages" do
+ setup :public_messages
+
+ test "public timeline", %{users: %{u1: user}} do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("reply_filtering_user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 16
+ end
+
+ test "public timeline with reply_visibility `following`", %{
+ users: %{u1: user},
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4,
+ activities: activities
+ } do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("reply_visibility", "following")
+ |> Map.put("reply_filtering_user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 14
+
+ visible_ids =
+ Map.values(u1) ++ Map.values(u2) ++ Map.values(u4) ++ Map.values(activities) ++ [u3[:r1]]
+
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+
+ test "public timeline with reply_visibility `self`", %{
+ users: %{u1: user},
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4,
+ activities: activities
+ } do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("reply_visibility", "self")
+ |> Map.put("reply_filtering_user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 10
+ visible_ids = Map.values(u1) ++ [u2[:r1], u3[:r1], u4[:r1]] ++ Map.values(activities)
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+
+ test "home timeline", %{
+ users: %{u1: user},
+ activities: activities,
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4
+ } do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> Map.put("reply_filtering_user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 13
+
+ visible_ids =
+ Map.values(u1) ++
+ Map.values(u3) ++
+ [
+ activities[:a1],
+ activities[:a2],
+ activities[:a4],
+ u2[:r1],
+ u2[:r3],
+ u4[:r1],
+ u4[:r2]
+ ]
+
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+
+ test "home timeline with reply_visibility `following`", %{
+ users: %{u1: user},
+ activities: activities,
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4
+ } do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> Map.put("reply_visibility", "following")
+ |> Map.put("reply_filtering_user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 11
+
+ visible_ids =
+ Map.values(u1) ++
+ [
+ activities[:a1],
+ activities[:a2],
+ activities[:a4],
+ u2[:r1],
+ u2[:r3],
+ u3[:r1],
+ u4[:r1],
+ u4[:r2]
+ ]
+
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+
+ test "home timeline with reply_visibility `self`", %{
+ users: %{u1: user},
+ activities: activities,
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4
+ } do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> Map.put("reply_visibility", "self")
+ |> Map.put("reply_filtering_user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 9
+
+ visible_ids =
+ Map.values(u1) ++
+ [
+ activities[:a1],
+ activities[:a2],
+ activities[:a4],
+ u2[:r1],
+ u3[:r1],
+ u4[:r1]
+ ]
+
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+ end
+
+ describe "replies filtering with private messages" do
+ setup :private_messages
+
+ test "public timeline", %{users: %{u1: user}} do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert activities_ids == []
+ end
+
+ test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("reply_visibility", "following")
+ |> Map.put("reply_filtering_user", user)
+ |> Map.put("user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert activities_ids == []
+ end
+
+ test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do
+ activities_ids =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("local_only", false)
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("reply_visibility", "self")
+ |> Map.put("reply_filtering_user", user)
+ |> Map.put("user", user)
+ |> ActivityPub.fetch_public_activities()
+ |> Enum.map(& &1.id)
+
+ assert activities_ids == []
+ end
+
+ test "home timeline", %{users: %{u1: user}} do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 12
+ end
+
+ test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> Map.put("reply_visibility", "following")
+ |> Map.put("reply_filtering_user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 12
+ end
+
+ test "home timeline with default reply_visibility `self`", %{
+ users: %{u1: user},
+ activities: activities,
+ u1: u1,
+ u2: u2,
+ u3: u3,
+ u4: u4
+ } do
+ params =
+ %{}
+ |> Map.put("type", ["Create", "Announce"])
+ |> Map.put("blocking_user", user)
+ |> Map.put("muting_user", user)
+ |> Map.put("user", user)
+ |> Map.put("reply_visibility", "self")
+ |> Map.put("reply_filtering_user", user)
+
+ activities_ids =
+ ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
+ |> Enum.map(& &1.id)
+
+ assert length(activities_ids) == 10
+
+ visible_ids =
+ Map.values(u1) ++ Map.values(u4) ++ [u2[:r1], u3[:r1]] ++ Map.values(activities)
+
+ assert Enum.all?(visible_ids, &(&1 in activities_ids))
+ end
+ end
+
+ defp public_messages(_) do
+ [u1, u2, u3, u4] = insert_list(4, :user)
+ {:ok, u1} = User.follow(u1, u2)
+ {:ok, u2} = User.follow(u2, u1)
+ {:ok, u1} = User.follow(u1, u4)
+ {:ok, u4} = User.follow(u4, u1)
+
+ {:ok, u2} = User.follow(u2, u3)
+ {:ok, u3} = User.follow(u3, u2)
+
+ {:ok, a1} = CommonAPI.post(u1, %{status: "Status"})
+
+ {:ok, r1_1} =
+ CommonAPI.post(u2, %{
+ status: "@#{u1.nickname} reply from u2 to u1",
+ in_reply_to_status_id: a1.id
+ })
+
+ {:ok, r1_2} =
+ CommonAPI.post(u3, %{
+ status: "@#{u1.nickname} reply from u3 to u1",
+ in_reply_to_status_id: a1.id
+ })
+
+ {:ok, r1_3} =
+ CommonAPI.post(u4, %{
+ status: "@#{u1.nickname} reply from u4 to u1",
+ in_reply_to_status_id: a1.id
+ })
+
+ {:ok, a2} = CommonAPI.post(u2, %{status: "Status"})
+
+ {:ok, r2_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u2.nickname} reply from u1 to u2",
+ in_reply_to_status_id: a2.id
+ })
+
+ {:ok, r2_2} =
+ CommonAPI.post(u3, %{
+ status: "@#{u2.nickname} reply from u3 to u2",
+ in_reply_to_status_id: a2.id
+ })
+
+ {:ok, r2_3} =
+ CommonAPI.post(u4, %{
+ status: "@#{u2.nickname} reply from u4 to u2",
+ in_reply_to_status_id: a2.id
+ })
+
+ {:ok, a3} = CommonAPI.post(u3, %{status: "Status"})
+
+ {:ok, r3_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u3.nickname} reply from u1 to u3",
+ in_reply_to_status_id: a3.id
+ })
+
+ {:ok, r3_2} =
+ CommonAPI.post(u2, %{
+ status: "@#{u3.nickname} reply from u2 to u3",
+ in_reply_to_status_id: a3.id
+ })
+
+ {:ok, r3_3} =
+ CommonAPI.post(u4, %{
+ status: "@#{u3.nickname} reply from u4 to u3",
+ in_reply_to_status_id: a3.id
+ })
+
+ {:ok, a4} = CommonAPI.post(u4, %{status: "Status"})
+
+ {:ok, r4_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u4.nickname} reply from u1 to u4",
+ in_reply_to_status_id: a4.id
+ })
+
+ {:ok, r4_2} =
+ CommonAPI.post(u2, %{
+ status: "@#{u4.nickname} reply from u2 to u4",
+ in_reply_to_status_id: a4.id
+ })
+
+ {:ok, r4_3} =
+ CommonAPI.post(u3, %{
+ status: "@#{u4.nickname} reply from u3 to u4",
+ in_reply_to_status_id: a4.id
+ })
+
+ {:ok,
+ users: %{u1: u1, u2: u2, u3: u3, u4: u4},
+ activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
+ u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
+ u2: %{r1: r2_1.id, r2: r2_2.id, r3: r2_3.id},
+ u3: %{r1: r3_1.id, r2: r3_2.id, r3: r3_3.id},
+ u4: %{r1: r4_1.id, r2: r4_2.id, r3: r4_3.id}}
+ end
+
+ defp private_messages(_) do
+ [u1, u2, u3, u4] = insert_list(4, :user)
+ {:ok, u1} = User.follow(u1, u2)
+ {:ok, u2} = User.follow(u2, u1)
+ {:ok, u1} = User.follow(u1, u3)
+ {:ok, u3} = User.follow(u3, u1)
+ {:ok, u1} = User.follow(u1, u4)
+ {:ok, u4} = User.follow(u4, u1)
+
+ {:ok, u2} = User.follow(u2, u3)
+ {:ok, u3} = User.follow(u3, u2)
+
+ {:ok, a1} = CommonAPI.post(u1, %{status: "Status", visibility: "private"})
+
+ {:ok, r1_1} =
+ CommonAPI.post(u2, %{
+ status: "@#{u1.nickname} reply from u2 to u1",
+ in_reply_to_status_id: a1.id,
+ visibility: "private"
+ })
+
+ {:ok, r1_2} =
+ CommonAPI.post(u3, %{
+ status: "@#{u1.nickname} reply from u3 to u1",
+ in_reply_to_status_id: a1.id,
+ visibility: "private"
+ })
+
+ {:ok, r1_3} =
+ CommonAPI.post(u4, %{
+ status: "@#{u1.nickname} reply from u4 to u1",
+ in_reply_to_status_id: a1.id,
+ visibility: "private"
+ })
+
+ {:ok, a2} = CommonAPI.post(u2, %{status: "Status", visibility: "private"})
+
+ {:ok, r2_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u2.nickname} reply from u1 to u2",
+ in_reply_to_status_id: a2.id,
+ visibility: "private"
+ })
+
+ {:ok, r2_2} =
+ CommonAPI.post(u3, %{
+ status: "@#{u2.nickname} reply from u3 to u2",
+ in_reply_to_status_id: a2.id,
+ visibility: "private"
+ })
+
+ {:ok, a3} = CommonAPI.post(u3, %{status: "Status", visibility: "private"})
+
+ {:ok, r3_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u3.nickname} reply from u1 to u3",
+ in_reply_to_status_id: a3.id,
+ visibility: "private"
+ })
+
+ {:ok, r3_2} =
+ CommonAPI.post(u2, %{
+ status: "@#{u3.nickname} reply from u2 to u3",
+ in_reply_to_status_id: a3.id,
+ visibility: "private"
+ })
+
+ {:ok, a4} = CommonAPI.post(u4, %{status: "Status", visibility: "private"})
+
+ {:ok, r4_1} =
+ CommonAPI.post(u1, %{
+ status: "@#{u4.nickname} reply from u1 to u4",
+ in_reply_to_status_id: a4.id,
+ visibility: "private"
+ })
+
+ {:ok,
+ users: %{u1: u1, u2: u2, u3: u3, u4: u4},
+ activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
+ u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
+ u2: %{r1: r2_1.id, r2: r2_2.id},
+ u3: %{r1: r3_1.id, r2: r3_2.id},
+ u4: %{r1: r4_1.id}}
+ end
+
+ describe "maybe_update_follow_information/1" do
+ setup do
+ clear_config([:instance, :external_user_synchronization], true)
+
+ user = %{
+ local: false,
+ ap_id: "https://gensokyo.2hu/users/raymoo",
+ following_address: "https://gensokyo.2hu/users/following",
+ follower_address: "https://gensokyo.2hu/users/followers",
+ type: "Person"
+ }
+
+ %{user: user}
+ end
+
+ test "logs an error when it can't fetch the info", %{user: user} do
+ assert capture_log(fn ->
+ ActivityPub.maybe_update_follow_information(user)
+ end) =~ "Follower/Following counter update for #{user.ap_id} failed"
+ end
+
+ test "just returns the input if the user type is Application", %{
+ user: user
+ } do
+ user =
+ user
+ |> Map.put(:type, "Application")
+
+ refute capture_log(fn ->
+ assert ^user = ActivityPub.maybe_update_follow_information(user)
+ end) =~ "Follower/Following counter update for #{user.ap_id} failed"
+ end
+
+ test "it just returns the input if the user has no following/follower addresses", %{
+ user: user
+ } do
+ user =
+ user
+ |> Map.put(:following_address, nil)
+ |> Map.put(:follower_address, nil)
+
+ refute capture_log(fn ->
+ assert ^user = ActivityPub.maybe_update_follow_information(user)
+ end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
end
end
diff --git a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs
index 37a7bfcf7..fca0de7c6 100644
--- a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs
+++ b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicyTest do
diff --git a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs
index 03dc299ec..1a13699be 100644
--- a/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs
+++ b/test/web/activity_pub/mrf/anti_link_spam_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
@@ -35,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
test "it allows posts without links" do
user = insert(:user)
- assert user.info.note_count == 0
+ assert user.note_count == 0
message =
@linkless_message
@@ -47,7 +47,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
test "it disallows posts with links" do
user = insert(:user)
- assert user.info.note_count == 0
+ assert user.note_count == 0
message =
@linkful_message
@@ -59,9 +59,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
describe "with old user" do
test "it allows posts without links" do
- user = insert(:user, info: %{note_count: 1})
+ user = insert(:user, note_count: 1)
- assert user.info.note_count == 1
+ assert user.note_count == 1
message =
@linkless_message
@@ -71,9 +71,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
end
test "it allows posts with links" do
- user = insert(:user, info: %{note_count: 1})
+ user = insert(:user, note_count: 1)
- assert user.info.note_count == 1
+ assert user.note_count == 1
message =
@linkful_message
@@ -85,9 +85,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
describe "with followed new user" do
test "it allows posts without links" do
- user = insert(:user, info: %{follower_count: 1})
+ user = insert(:user, follower_count: 1)
- assert user.info.follower_count == 1
+ assert user.follower_count == 1
message =
@linkless_message
@@ -97,9 +97,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
end
test "it allows posts with links" do
- user = insert(:user, info: %{follower_count: 1})
+ user = insert(:user, follower_count: 1)
- assert user.info.follower_count == 1
+ assert user.follower_count == 1
message =
@linkful_message
@@ -110,6 +110,15 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
end
describe "with unknown actors" do
+ setup do
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "http://invalid.actor"} ->
+ %Tesla.Env{status: 500, body: ""}
+ end)
+
+ :ok
+ end
+
test "it rejects posts without links" do
message =
@linkless_message
@@ -133,7 +142,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
describe "with contentless-objects" do
test "it does not reject them or error out" do
- user = insert(:user, info: %{note_count: 1})
+ user = insert(:user, note_count: 1)
message =
@response_message
diff --git a/test/web/activity_pub/mrf/ensure_re_prepended_test.exs b/test/web/activity_pub/mrf/ensure_re_prepended_test.exs
index dbc8b9e80..38ddec5bb 100644
--- a/test/web/activity_pub/mrf/ensure_re_prepended_test.exs
+++ b/test/web/activity_pub/mrf/ensure_re_prepended_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
diff --git a/test/web/activity_pub/mrf/hellthread_policy_test.exs b/test/web/activity_pub/mrf/hellthread_policy_test.exs
index eb6ee4d04..95ef0b168 100644
--- a/test/web/activity_pub/mrf/hellthread_policy_test.exs
+++ b/test/web/activity_pub/mrf/hellthread_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do
@@ -26,6 +26,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do
[user: user, message: message]
end
+ setup do: clear_config(:mrf_hellthread)
+
describe "reject" do
test "rejects the message if the recipient count is above reject_threshold", %{
message: message
diff --git a/test/web/activity_pub/mrf/keyword_policy_test.exs b/test/web/activity_pub/mrf/keyword_policy_test.exs
index 602892a37..fd1f7aec8 100644
--- a/test/web/activity_pub/mrf/keyword_policy_test.exs
+++ b/test/web/activity_pub/mrf/keyword_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
@@ -7,6 +7,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
alias Pleroma.Web.ActivityPub.MRF.KeywordPolicy
+ setup do: clear_config(:mrf_keyword)
+
setup do
Pleroma.Config.put([:mrf_keyword], %{reject: [], federated_timeline_removal: [], replace: []})
end
diff --git a/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs b/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs
index 95a809d25..313d59a66 100644
--- a/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs
+++ b/test/web/activity_pub/mrf/mediaproxy_warming_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
diff --git a/test/web/activity_pub/mrf/mention_policy_test.exs b/test/web/activity_pub/mrf/mention_policy_test.exs
index 9fd9c31df..aa003bef5 100644
--- a/test/web/activity_pub/mrf/mention_policy_test.exs
+++ b/test/web/activity_pub/mrf/mention_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicyTest do
@@ -7,6 +7,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicyTest do
alias Pleroma.Web.ActivityPub.MRF.MentionPolicy
+ setup do: clear_config(:mrf_mention)
+
test "pass filter if allow list is empty" do
Pleroma.Config.delete([:mrf_mention])
diff --git a/test/web/activity_pub/mrf/mrf_test.exs b/test/web/activity_pub/mrf/mrf_test.exs
index 04709df17..c941066f2 100644
--- a/test/web/activity_pub/mrf/mrf_test.exs
+++ b/test/web/activity_pub/mrf/mrf_test.exs
@@ -60,7 +60,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
end
describe "describe/0" do
- clear_config([:instance, :rewrite_policy])
+ setup do: clear_config([:instance, :rewrite_policy])
test "it works as expected with noop policy" do
expected = %{
diff --git a/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs b/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs
index 63ed71129..64ea61dd4 100644
--- a/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs
+++ b/test/web/activity_pub/mrf/no_placeholder_text_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
diff --git a/test/web/activity_pub/mrf/normalize_markup_test.exs b/test/web/activity_pub/mrf/normalize_markup_test.exs
index 3916a1f35..9b39c45bd 100644
--- a/test/web/activity_pub/mrf/normalize_markup_test.exs
+++ b/test/web/activity_pub/mrf/normalize_markup_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
@@ -20,11 +20,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
expected = """
<b>this is in bold</b>
<p>this is a paragraph</p>
- this is a linebreak<br />
- this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
- this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
- this is an image: <img src="http://example.com/image.jpg" /><br />
- alert('hacked')
+ this is a linebreak<br/>
+ this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
+ this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
+ this is an image: <img src="http://example.com/image.jpg"/><br/>
+ alert(&#39;hacked&#39;)
"""
message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
diff --git a/test/web/activity_pub/mrf/object_age_policy_test.exs b/test/web/activity_pub/mrf/object_age_policy_test.exs
new file mode 100644
index 000000000..b0fb753bd
--- /dev/null
+++ b/test/web/activity_pub/mrf/object_age_policy_test.exs
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do
+ use Pleroma.DataCase
+ alias Pleroma.Config
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy
+ alias Pleroma.Web.ActivityPub.Visibility
+
+ setup do:
+ clear_config(:mrf_object_age,
+ threshold: 172_800,
+ actions: [:delist, :strip_followers]
+ )
+
+ setup_all do
+ Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
+ defp get_old_message do
+ File.read!("test/fixtures/mastodon-post-activity.json")
+ |> Poison.decode!()
+ end
+
+ defp get_new_message do
+ old_message = get_old_message()
+
+ new_object =
+ old_message
+ |> Map.get("object")
+ |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601())
+
+ old_message
+ |> Map.put("object", new_object)
+ end
+
+ describe "with reject action" do
+ test "it rejects an old post" do
+ Config.put([:mrf_object_age, :actions], [:reject])
+
+ data = get_old_message()
+
+ assert match?({:reject, _}, ObjectAgePolicy.filter(data))
+ end
+
+ test "it allows a new post" do
+ Config.put([:mrf_object_age, :actions], [:reject])
+
+ data = get_new_message()
+
+ assert match?({:ok, _}, ObjectAgePolicy.filter(data))
+ end
+ end
+
+ describe "with delist action" do
+ test "it delists an old post" do
+ Config.put([:mrf_object_age, :actions], [:delist])
+
+ data = get_old_message()
+
+ {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"])
+
+ {:ok, data} = ObjectAgePolicy.filter(data)
+
+ assert Visibility.get_visibility(%{data: data}) == "unlisted"
+ end
+
+ test "it allows a new post" do
+ Config.put([:mrf_object_age, :actions], [:delist])
+
+ data = get_new_message()
+
+ {:ok, _user} = User.get_or_fetch_by_ap_id(data["actor"])
+
+ assert match?({:ok, ^data}, ObjectAgePolicy.filter(data))
+ end
+ end
+
+ describe "with strip_followers action" do
+ test "it strips followers collections from an old post" do
+ Config.put([:mrf_object_age, :actions], [:strip_followers])
+
+ data = get_old_message()
+
+ {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"])
+
+ {:ok, data} = ObjectAgePolicy.filter(data)
+
+ refute user.follower_address in data["to"]
+ refute user.follower_address in data["cc"]
+ end
+
+ test "it allows a new post" do
+ Config.put([:mrf_object_age, :actions], [:strip_followers])
+
+ data = get_new_message()
+
+ {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"])
+
+ assert match?({:ok, ^data}, ObjectAgePolicy.filter(data))
+ end
+ end
+end
diff --git a/test/web/activity_pub/mrf/reject_non_public_test.exs b/test/web/activity_pub/mrf/reject_non_public_test.exs
index fc1d190bb..f36299b86 100644
--- a/test/web/activity_pub/mrf/reject_non_public_test.exs
+++ b/test/web/activity_pub/mrf/reject_non_public_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublicTest do
@@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublicTest do
alias Pleroma.Web.ActivityPub.MRF.RejectNonPublic
- clear_config([:mrf_rejectnonpublic])
+ setup do: clear_config([:mrf_rejectnonpublic])
describe "public message" do
test "it's allowed when address is public" do
diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs
index df0f223f8..b7b9bc6a2 100644
--- a/test/web/activity_pub/mrf/simple_policy_test.exs
+++ b/test/web/activity_pub/mrf/simple_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
@@ -8,18 +8,18 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
alias Pleroma.Config
alias Pleroma.Web.ActivityPub.MRF.SimplePolicy
- clear_config([:mrf_simple]) do
- Config.put(:mrf_simple,
- media_removal: [],
- media_nsfw: [],
- federated_timeline_removal: [],
- report_removal: [],
- reject: [],
- accept: [],
- avatar_removal: [],
- banner_removal: []
- )
- end
+ setup do:
+ clear_config(:mrf_simple,
+ media_removal: [],
+ media_nsfw: [],
+ federated_timeline_removal: [],
+ report_removal: [],
+ reject: [],
+ accept: [],
+ avatar_removal: [],
+ banner_removal: [],
+ reject_deletes: []
+ )
describe "when :media_removal" do
test "is empty" do
@@ -383,6 +383,66 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
end
end
+ describe "when :reject_deletes is empty" do
+ setup do: Config.put([:mrf_simple, :reject_deletes], [])
+
+ test "it accepts deletions even from rejected servers" do
+ Config.put([:mrf_simple, :reject], ["remote.instance"])
+
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message}
+ end
+
+ test "it accepts deletions even from non-whitelisted servers" do
+ Config.put([:mrf_simple, :accept], ["non.matching.remote"])
+
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message}
+ end
+ end
+
+ describe "when :reject_deletes is not empty but it doesn't have a matching host" do
+ setup do: Config.put([:mrf_simple, :reject_deletes], ["non.matching.remote"])
+
+ test "it accepts deletions even from rejected servers" do
+ Config.put([:mrf_simple, :reject], ["remote.instance"])
+
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message}
+ end
+
+ test "it accepts deletions even from non-whitelisted servers" do
+ Config.put([:mrf_simple, :accept], ["non.matching.remote"])
+
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message}
+ end
+ end
+
+ describe "when :reject_deletes has a matching host" do
+ setup do: Config.put([:mrf_simple, :reject_deletes], ["remote.instance"])
+
+ test "it rejects the deletion" do
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:reject, nil}
+ end
+ end
+
+ describe "when :reject_deletes match with wildcard domain" do
+ setup do: Config.put([:mrf_simple, :reject_deletes], ["*.remote.instance"])
+
+ test "it rejects the deletion" do
+ deletion_message = build_remote_deletion_message()
+
+ assert SimplePolicy.filter(deletion_message) == {:reject, nil}
+ end
+ end
+
defp build_local_message do
%{
"actor" => "#{Pleroma.Web.base_url()}/users/alice",
@@ -409,4 +469,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
"type" => "Person"
}
end
+
+ defp build_remote_deletion_message do
+ %{
+ "type" => "Delete",
+ "actor" => "https://remote.instance/users/bob"
+ }
+ end
end
diff --git a/test/web/activity_pub/mrf/subchain_policy_test.exs b/test/web/activity_pub/mrf/subchain_policy_test.exs
index f7cbcad48..fff66cb7e 100644
--- a/test/web/activity_pub/mrf/subchain_policy_test.exs
+++ b/test/web/activity_pub/mrf/subchain_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicyTest do
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicyTest do
"type" => "Create",
"object" => %{"content" => "hi"}
}
+ setup do: clear_config([:mrf_subchain, :match_actor])
test "it matches and processes subchains when the actor matches a configured target" do
Pleroma.Config.put([:mrf_subchain, :match_actor], %{
diff --git a/test/web/activity_pub/mrf/tag_policy_test.exs b/test/web/activity_pub/mrf/tag_policy_test.exs
index 4aa35311e..e7793641a 100644
--- a/test/web/activity_pub/mrf/tag_policy_test.exs
+++ b/test/web/activity_pub/mrf/tag_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.TagPolicyTest do
diff --git a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs
index 72084c0fd..724bae058 100644
--- a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs
+++ b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicyTest do
use Pleroma.DataCase
@@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicyTest do
alias Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy
- clear_config([:mrf_user_allowlist, :localhost])
+ setup do: clear_config([:mrf_user_allowlist, :localhost])
test "pass filter if allow list is empty" do
actor = insert(:user)
diff --git a/test/web/activity_pub/mrf/vocabulary_policy_test.exs b/test/web/activity_pub/mrf/vocabulary_policy_test.exs
index 38309f9f1..69f22bb77 100644
--- a/test/web/activity_pub/mrf/vocabulary_policy_test.exs
+++ b/test/web/activity_pub/mrf/vocabulary_policy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicyTest do
@@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicyTest do
alias Pleroma.Web.ActivityPub.MRF.VocabularyPolicy
describe "accept" do
- clear_config([:mrf_vocabulary, :accept])
+ setup do: clear_config([:mrf_vocabulary, :accept])
test "it accepts based on parent activity type" do
Pleroma.Config.put([:mrf_vocabulary, :accept], ["Like"])
@@ -65,7 +65,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicyTest do
end
describe "reject" do
- clear_config([:mrf_vocabulary, :reject])
+ setup do: clear_config([:mrf_vocabulary, :reject])
test "it rejects based on parent activity type" do
Pleroma.Config.put([:mrf_vocabulary, :reject], ["Like"])
diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs
new file mode 100644
index 000000000..96eff1c30
--- /dev/null
+++ b/test/web/activity_pub/object_validator_test.exs
@@ -0,0 +1,283 @@
+defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.Object
+ alias Pleroma.Web.ActivityPub.Builder
+ alias Pleroma.Web.ActivityPub.ObjectValidator
+ alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
+ alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+
+ describe "EmojiReacts" do
+ setup do
+ user = insert(:user)
+ {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"})
+
+ object = Pleroma.Object.get_by_ap_id(post_activity.data["object"])
+
+ {:ok, valid_emoji_react, []} = Builder.emoji_react(user, object, "👌")
+
+ %{user: user, post_activity: post_activity, valid_emoji_react: valid_emoji_react}
+ end
+
+ test "it validates a valid EmojiReact", %{valid_emoji_react: valid_emoji_react} do
+ assert {:ok, _, _} = ObjectValidator.validate(valid_emoji_react, [])
+ end
+
+ test "it is not valid without a 'content' field", %{valid_emoji_react: valid_emoji_react} do
+ without_content =
+ valid_emoji_react
+ |> Map.delete("content")
+
+ {:error, cng} = ObjectValidator.validate(without_content, [])
+
+ refute cng.valid?
+ assert {:content, {"can't be blank", [validation: :required]}} in cng.errors
+ end
+
+ test "it is not valid with a non-emoji content field", %{valid_emoji_react: valid_emoji_react} do
+ without_emoji_content =
+ valid_emoji_react
+ |> Map.put("content", "x")
+
+ {:error, cng} = ObjectValidator.validate(without_emoji_content, [])
+
+ refute cng.valid?
+
+ assert {:content, {"must be a single character emoji", []}} in cng.errors
+ end
+ end
+
+ describe "Undos" do
+ setup do
+ user = insert(:user)
+ {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"})
+ {:ok, like} = CommonAPI.favorite(user, post_activity.id)
+ {:ok, valid_like_undo, []} = Builder.undo(user, like)
+
+ %{user: user, like: like, valid_like_undo: valid_like_undo}
+ end
+
+ test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do
+ assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, [])
+ end
+
+ test "it does not validate if the actor of the undo is not the actor of the object", %{
+ valid_like_undo: valid_like_undo
+ } do
+ other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
+
+ bad_actor =
+ valid_like_undo
+ |> Map.put("actor", other_user.ap_id)
+
+ {:error, cng} = ObjectValidator.validate(bad_actor, [])
+
+ assert {:actor, {"not the same as object actor", []}} in cng.errors
+ end
+
+ test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do
+ missing_object =
+ valid_like_undo
+ |> Map.put("object", "https://gensokyo.2hu/objects/1")
+
+ {:error, cng} = ObjectValidator.validate(missing_object, [])
+
+ assert {:object, {"can't find object", []}} in cng.errors
+ assert length(cng.errors) == 1
+ end
+ end
+
+ describe "deletes" do
+ setup do
+ user = insert(:user)
+ {:ok, post_activity} = CommonAPI.post(user, %{status: "cancel me daddy"})
+
+ {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"])
+ {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id)
+
+ %{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete}
+ end
+
+ test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
+ {:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, [])
+
+ assert valid_post_delete["deleted_activity_id"]
+ end
+
+ test "it is invalid if the object isn't in a list of certain types", %{
+ valid_post_delete: valid_post_delete
+ } do
+ object = Object.get_by_ap_id(valid_post_delete["object"])
+
+ data =
+ object.data
+ |> Map.put("type", "Like")
+
+ {:ok, _object} =
+ object
+ |> Ecto.Changeset.change(%{data: data})
+ |> Object.update_and_set_cache()
+
+ {:error, cng} = ObjectValidator.validate(valid_post_delete, [])
+ assert {:object, {"object not in allowed types", []}} in cng.errors
+ end
+
+ test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do
+ assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, []))
+ end
+
+ test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do
+ no_id =
+ valid_post_delete
+ |> Map.delete("id")
+
+ {:error, cng} = ObjectValidator.validate(no_id, [])
+
+ assert {:id, {"can't be blank", [validation: :required]}} in cng.errors
+ end
+
+ test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do
+ missing_object =
+ valid_post_delete
+ |> Map.put("object", "http://does.not/exist")
+
+ {:error, cng} = ObjectValidator.validate(missing_object, [])
+
+ assert {:object, {"can't find object", []}} in cng.errors
+ end
+
+ test "it's invalid if the actor of the object and the actor of delete are from different domains",
+ %{valid_post_delete: valid_post_delete} do
+ valid_user = insert(:user)
+
+ valid_other_actor =
+ valid_post_delete
+ |> Map.put("actor", valid_user.ap_id)
+
+ assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, []))
+
+ invalid_other_actor =
+ valid_post_delete
+ |> Map.put("actor", "https://gensokyo.2hu/users/raymoo")
+
+ {:error, cng} = ObjectValidator.validate(invalid_other_actor, [])
+
+ assert {:actor, {"is not allowed to delete object", []}} in cng.errors
+ end
+
+ test "it's valid if the actor of the object is a local superuser",
+ %{valid_post_delete: valid_post_delete} do
+ user =
+ insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo")
+
+ valid_other_actor =
+ valid_post_delete
+ |> Map.put("actor", user.ap_id)
+
+ {:ok, _, meta} = ObjectValidator.validate(valid_other_actor, [])
+ assert meta[:do_not_federate]
+ end
+ end
+
+ describe "likes" do
+ setup do
+ user = insert(:user)
+ {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"})
+
+ valid_like = %{
+ "to" => [user.ap_id],
+ "cc" => [],
+ "type" => "Like",
+ "id" => Utils.generate_activity_id(),
+ "object" => post_activity.data["object"],
+ "actor" => user.ap_id,
+ "context" => "a context"
+ }
+
+ %{valid_like: valid_like, user: user, post_activity: post_activity}
+ end
+
+ test "returns ok when called in the ObjectValidator", %{valid_like: valid_like} do
+ {:ok, object, _meta} = ObjectValidator.validate(valid_like, [])
+
+ assert "id" in Map.keys(object)
+ end
+
+ test "is valid for a valid object", %{valid_like: valid_like} do
+ assert LikeValidator.cast_and_validate(valid_like).valid?
+ end
+
+ test "sets the 'to' field to the object actor if no recipients are given", %{
+ valid_like: valid_like,
+ user: user
+ } do
+ without_recipients =
+ valid_like
+ |> Map.delete("to")
+
+ {:ok, object, _meta} = ObjectValidator.validate(without_recipients, [])
+
+ assert object["to"] == [user.ap_id]
+ end
+
+ test "sets the context field to the context of the object if no context is given", %{
+ valid_like: valid_like,
+ post_activity: post_activity
+ } do
+ without_context =
+ valid_like
+ |> Map.delete("context")
+
+ {:ok, object, _meta} = ObjectValidator.validate(without_context, [])
+
+ assert object["context"] == post_activity.data["context"]
+ end
+
+ test "it errors when the actor is missing or not known", %{valid_like: valid_like} do
+ without_actor = Map.delete(valid_like, "actor")
+
+ refute LikeValidator.cast_and_validate(without_actor).valid?
+
+ with_invalid_actor = Map.put(valid_like, "actor", "invalidactor")
+
+ refute LikeValidator.cast_and_validate(with_invalid_actor).valid?
+ end
+
+ test "it errors when the object is missing or not known", %{valid_like: valid_like} do
+ without_object = Map.delete(valid_like, "object")
+
+ refute LikeValidator.cast_and_validate(without_object).valid?
+
+ with_invalid_object = Map.put(valid_like, "object", "invalidobject")
+
+ refute LikeValidator.cast_and_validate(with_invalid_object).valid?
+ end
+
+ test "it errors when the actor has already like the object", %{
+ valid_like: valid_like,
+ user: user,
+ post_activity: post_activity
+ } do
+ _like = CommonAPI.favorite(user, post_activity.id)
+
+ refute LikeValidator.cast_and_validate(valid_like).valid?
+ end
+
+ test "it works when actor or object are wrapped in maps", %{valid_like: valid_like} do
+ wrapped_like =
+ valid_like
+ |> Map.put("actor", %{"id" => valid_like["actor"]})
+ |> Map.put("object", %{"id" => valid_like["object"]})
+
+ validated = LikeValidator.cast_and_validate(wrapped_like)
+
+ assert validated.valid?
+
+ assert {:actor, valid_like["actor"]} in validated.changes
+ assert {:object, valid_like["object"]} in validated.changes
+ end
+ end
+end
diff --git a/test/web/activity_pub/object_validators/note_validator_test.exs b/test/web/activity_pub/object_validators/note_validator_test.exs
new file mode 100644
index 000000000..30c481ffb
--- /dev/null
+++ b/test/web/activity_pub/object_validators/note_validator_test.exs
@@ -0,0 +1,35 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidatorTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator
+ alias Pleroma.Web.ActivityPub.Utils
+
+ import Pleroma.Factory
+
+ describe "Notes" do
+ setup do
+ user = insert(:user)
+
+ note = %{
+ "id" => Utils.generate_activity_id(),
+ "type" => "Note",
+ "actor" => user.ap_id,
+ "to" => [user.follower_address],
+ "cc" => [],
+ "content" => "Hellow this is content.",
+ "context" => "xxx",
+ "summary" => "a post"
+ }
+
+ %{user: user, note: note}
+ end
+
+ test "a basic note validates", %{note: note} do
+ %{valid?: true} = NoteValidator.cast_and_validate(note)
+ end
+ end
+end
diff --git a/test/web/activity_pub/object_validators/types/date_time_test.exs b/test/web/activity_pub/object_validators/types/date_time_test.exs
new file mode 100644
index 000000000..3e17a9497
--- /dev/null
+++ b/test/web/activity_pub/object_validators/types/date_time_test.exs
@@ -0,0 +1,32 @@
+defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTimeTest do
+ alias Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime
+ use Pleroma.DataCase
+
+ test "it validates an xsd:Datetime" do
+ valid_strings = [
+ "2004-04-12T13:20:00",
+ "2004-04-12T13:20:15.5",
+ "2004-04-12T13:20:00-05:00",
+ "2004-04-12T13:20:00Z"
+ ]
+
+ invalid_strings = [
+ "2004-04-12T13:00",
+ "2004-04-1213:20:00",
+ "99-04-12T13:00",
+ "2004-04-12"
+ ]
+
+ assert {:ok, "2004-04-01T12:00:00Z"} == DateTime.cast("2004-04-01T12:00:00Z")
+
+ Enum.each(valid_strings, fn date_time ->
+ result = DateTime.cast(date_time)
+ assert {:ok, _} = result
+ end)
+
+ Enum.each(invalid_strings, fn date_time ->
+ result = DateTime.cast(date_time)
+ assert :error == result
+ end)
+ end
+end
diff --git a/test/web/activity_pub/object_validators/types/object_id_test.exs b/test/web/activity_pub/object_validators/types/object_id_test.exs
new file mode 100644
index 000000000..834213182
--- /dev/null
+++ b/test/web/activity_pub/object_validators/types/object_id_test.exs
@@ -0,0 +1,37 @@
+defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do
+ alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID
+ use Pleroma.DataCase
+
+ @uris [
+ "http://lain.com/users/lain",
+ "http://lain.com",
+ "https://lain.com/object/1"
+ ]
+
+ @non_uris [
+ "https://",
+ "rin",
+ 1,
+ :x,
+ %{"1" => 2}
+ ]
+
+ test "it accepts http uris" do
+ Enum.each(@uris, fn uri ->
+ assert {:ok, uri} == ObjectID.cast(uri)
+ end)
+ end
+
+ test "it accepts an object with a nested uri id" do
+ Enum.each(@uris, fn uri ->
+ assert {:ok, uri} == ObjectID.cast(%{"id" => uri})
+ end)
+ end
+
+ test "it rejects non-uri strings" do
+ Enum.each(@non_uris, fn non_uri ->
+ assert :error == ObjectID.cast(non_uri)
+ assert :error == ObjectID.cast(%{"id" => non_uri})
+ end)
+ end
+end
diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs
new file mode 100644
index 000000000..f278f039b
--- /dev/null
+++ b/test/web/activity_pub/object_validators/types/recipients_test.exs
@@ -0,0 +1,27 @@
+defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do
+ alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients
+ use Pleroma.DataCase
+
+ test "it asserts that all elements of the list are object ids" do
+ list = ["https://lain.com/users/lain", "invalid"]
+
+ assert :error == Recipients.cast(list)
+ end
+
+ test "it works with a list" do
+ list = ["https://lain.com/users/lain"]
+ assert {:ok, list} == Recipients.cast(list)
+ end
+
+ test "it works with a list with whole objects" do
+ list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}]
+ resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"]
+ assert {:ok, resulting_list} == Recipients.cast(list)
+ end
+
+ test "it turns a single string into a list" do
+ recipient = "https://lain.com/users/lain"
+
+ assert {:ok, [recipient]} == Recipients.cast(recipient)
+ end
+end
diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs
new file mode 100644
index 000000000..f3c437498
--- /dev/null
+++ b/test/web/activity_pub/pipeline_test.exs
@@ -0,0 +1,87 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.PipelineTest do
+ use Pleroma.DataCase
+
+ import Mock
+ import Pleroma.Factory
+
+ describe "common_pipeline/2" do
+ test "it goes through validation, filtering, persisting, side effects and federation for local activities" do
+ activity = insert(:note_activity)
+ meta = [local: true]
+
+ with_mocks([
+ {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]},
+ {
+ Pleroma.Web.ActivityPub.MRF,
+ [],
+ [filter: fn o -> {:ok, o} end]
+ },
+ {
+ Pleroma.Web.ActivityPub.ActivityPub,
+ [],
+ [persist: fn o, m -> {:ok, o, m} end]
+ },
+ {
+ Pleroma.Web.ActivityPub.SideEffects,
+ [],
+ [handle: fn o, m -> {:ok, o, m} end]
+ },
+ {
+ Pleroma.Web.Federator,
+ [],
+ [publish: fn _o -> :ok end]
+ }
+ ]) do
+ assert {:ok, ^activity, ^meta} =
+ Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta)
+
+ assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta))
+ assert_called(Pleroma.Web.ActivityPub.MRF.filter(activity))
+ assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta))
+ assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta))
+ assert_called(Pleroma.Web.Federator.publish(activity))
+ end
+ end
+
+ test "it goes through validation, filtering, persisting, side effects without federation for remote activities" do
+ activity = insert(:note_activity)
+ meta = [local: false]
+
+ with_mocks([
+ {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]},
+ {
+ Pleroma.Web.ActivityPub.MRF,
+ [],
+ [filter: fn o -> {:ok, o} end]
+ },
+ {
+ Pleroma.Web.ActivityPub.ActivityPub,
+ [],
+ [persist: fn o, m -> {:ok, o, m} end]
+ },
+ {
+ Pleroma.Web.ActivityPub.SideEffects,
+ [],
+ [handle: fn o, m -> {:ok, o, m} end]
+ },
+ {
+ Pleroma.Web.Federator,
+ [],
+ []
+ }
+ ]) do
+ assert {:ok, ^activity, ^meta} =
+ Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta)
+
+ assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta))
+ assert_called(Pleroma.Web.ActivityPub.MRF.filter(activity))
+ assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta))
+ assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta))
+ end
+ end
+ end
+end
diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs
index df03b4008..c2bc38d52 100644
--- a/test/web/activity_pub/publisher_test.exs
+++ b/test/web/activity_pub/publisher_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.PublisherTest do
@@ -23,12 +23,32 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
:ok
end
+ setup_all do: clear_config([:instance, :federating], true)
+
+ describe "gather_webfinger_links/1" do
+ test "it returns links" do
+ user = insert(:user)
+
+ expected_links = [
+ %{"href" => user.ap_id, "rel" => "self", "type" => "application/activity+json"},
+ %{
+ "href" => user.ap_id,
+ "rel" => "self",
+ "type" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
+ },
+ %{
+ "rel" => "http://ostatus.org/schema/1.0/subscribe",
+ "template" => "#{Pleroma.Web.base_url()}/ostatus_subscribe?acct={uri}"
+ }
+ ]
+
+ assert expected_links == Publisher.gather_webfinger_links(user)
+ end
+ end
+
describe "determine_inbox/2" do
test "it returns sharedInbox for messages involving as:Public in to" do
- user =
- insert(:user, %{
- info: %{source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}}}
- })
+ user = insert(:user, %{shared_inbox: "http://example.com/inbox"})
activity = %Activity{
data: %{"to" => [@as_public], "cc" => [user.follower_address]}
@@ -38,10 +58,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
end
test "it returns sharedInbox for messages involving as:Public in cc" do
- user =
- insert(:user, %{
- info: %{source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}}}
- })
+ user = insert(:user, %{shared_inbox: "http://example.com/inbox"})
activity = %Activity{
data: %{"cc" => [@as_public], "to" => [user.follower_address]}
@@ -51,11 +68,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
end
test "it returns sharedInbox for messages involving multiple recipients in to" do
- user =
- insert(:user, %{
- info: %{source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}}}
- })
-
+ user = insert(:user, %{shared_inbox: "http://example.com/inbox"})
user_two = insert(:user)
user_three = insert(:user)
@@ -67,11 +80,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
end
test "it returns sharedInbox for messages involving multiple recipients in cc" do
- user =
- insert(:user, %{
- info: %{source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}}}
- })
-
+ user = insert(:user, %{shared_inbox: "http://example.com/inbox"})
user_two = insert(:user)
user_three = insert(:user)
@@ -85,12 +94,8 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
test "it returns sharedInbox for messages involving multiple recipients in total" do
user =
insert(:user, %{
- info: %{
- source_data: %{
- "inbox" => "http://example.com/personal-inbox",
- "endpoints" => %{"sharedInbox" => "http://example.com/inbox"}
- }
- }
+ shared_inbox: "http://example.com/inbox",
+ inbox: "http://example.com/personal-inbox"
})
user_two = insert(:user)
@@ -105,12 +110,8 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
test "it returns inbox for messages involving single recipients in total" do
user =
insert(:user, %{
- info: %{
- source_data: %{
- "inbox" => "http://example.com/personal-inbox",
- "endpoints" => %{"sharedInbox" => "http://example.com/inbox"}
- }
- }
+ shared_inbox: "http://example.com/inbox",
+ inbox: "http://example.com/personal-inbox"
})
activity = %Activity{
@@ -239,13 +240,11 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
[:passthrough],
[] do
follower =
- insert(:user,
+ insert(:user, %{
local: false,
- info: %{
- ap_enabled: true,
- source_data: %{"inbox" => "https://domain.com/users/nick1/inbox"}
- }
- )
+ inbox: "https://domain.com/users/nick1/inbox",
+ ap_enabled: true
+ })
actor = insert(:user, follower_address: follower.ap_id)
user = insert(:user)
@@ -278,19 +277,15 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
fetcher =
insert(:user,
local: false,
- info: %{
- ap_enabled: true,
- source_data: %{"inbox" => "https://domain.com/users/nick1/inbox"}
- }
+ inbox: "https://domain.com/users/nick1/inbox",
+ ap_enabled: true
)
another_fetcher =
insert(:user,
local: false,
- info: %{
- ap_enabled: true,
- source_data: %{"inbox" => "https://domain2.com/users/nick1/inbox"}
- }
+ inbox: "https://domain2.com/users/nick1/inbox",
+ ap_enabled: true
)
actor = insert(:user)
diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs
index ac2007b2c..9e16e39c4 100644
--- a/test/web/activity_pub/relay_test.exs
+++ b/test/web/activity_pub/relay_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.RelayTest do
@@ -56,19 +56,19 @@ defmodule Pleroma.Web.ActivityPub.RelayTest do
service_actor = Relay.get_actor()
ActivityPub.follow(service_actor, user)
Pleroma.User.follow(service_actor, user)
- assert "#{user.ap_id}/followers" in refresh_record(service_actor).following
+ assert "#{user.ap_id}/followers" in User.following(service_actor)
assert {:ok, %Activity{} = activity} = Relay.unfollow(user.ap_id)
assert activity.actor == "#{Pleroma.Web.Endpoint.url()}/relay"
assert user.ap_id in activity.recipients
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == service_actor.ap_id
assert activity.data["to"] == [user.ap_id]
- refute "#{user.ap_id}/followers" in refresh_record(service_actor).following
+ refute "#{user.ap_id}/followers" in User.following(service_actor)
end
end
describe "publish/1" do
- clear_config([:instance, :federating])
+ setup do: clear_config([:instance, :federating])
test "returns error when activity not `Create` type" do
activity = insert(:like_activity)
@@ -89,6 +89,11 @@ defmodule Pleroma.Web.ActivityPub.RelayTest do
}
)
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "http://mastodon.example.org/eee/99541947525187367"} ->
+ %Tesla.Env{status: 500, body: ""}
+ end)
+
assert capture_log(fn ->
assert Relay.publish(activity) == {:error, nil}
end) =~ "[error] error: nil"
diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs
new file mode 100644
index 000000000..797f00d08
--- /dev/null
+++ b/test/web/activity_pub/side_effects_test.exs
@@ -0,0 +1,267 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
+ use Oban.Testing, repo: Pleroma.Repo
+ use Pleroma.DataCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Notification
+ alias Pleroma.Object
+ alias Pleroma.Repo
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Builder
+ alias Pleroma.Web.ActivityPub.SideEffects
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+ import Mock
+
+ describe "delete objects" do
+ setup do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, op} = CommonAPI.post(other_user, %{status: "big oof"})
+ {:ok, post} = CommonAPI.post(user, %{status: "hey", in_reply_to_id: op})
+ {:ok, favorite} = CommonAPI.favorite(user, post.id)
+ object = Object.normalize(post)
+ {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
+ {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id)
+ {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
+ {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true)
+
+ %{
+ user: user,
+ delete: delete,
+ post: post,
+ object: object,
+ delete_user: delete_user,
+ op: op,
+ favorite: favorite
+ }
+ end
+
+ test "it handles object deletions", %{
+ delete: delete,
+ post: post,
+ object: object,
+ user: user,
+ op: op,
+ favorite: favorite
+ } do
+ with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough],
+ stream_out: fn _ -> nil end,
+ stream_out_participations: fn _, _ -> nil end do
+ {:ok, delete, _} = SideEffects.handle(delete)
+ user = User.get_cached_by_ap_id(object.data["actor"])
+
+ assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete))
+ assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user))
+ end
+
+ object = Object.get_by_id(object.id)
+ assert object.data["type"] == "Tombstone"
+ refute Activity.get_by_id(post.id)
+ refute Activity.get_by_id(favorite.id)
+
+ user = User.get_by_id(user.id)
+ assert user.note_count == 0
+
+ object = Object.normalize(op.data["object"], false)
+
+ assert object.data["repliesCount"] == 0
+ end
+
+ test "it handles object deletions when the object itself has been pruned", %{
+ delete: delete,
+ post: post,
+ object: object,
+ user: user,
+ op: op
+ } do
+ with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough],
+ stream_out: fn _ -> nil end,
+ stream_out_participations: fn _, _ -> nil end do
+ {:ok, delete, _} = SideEffects.handle(delete)
+ user = User.get_cached_by_ap_id(object.data["actor"])
+
+ assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete))
+ assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user))
+ end
+
+ object = Object.get_by_id(object.id)
+ assert object.data["type"] == "Tombstone"
+ refute Activity.get_by_id(post.id)
+
+ user = User.get_by_id(user.id)
+ assert user.note_count == 0
+
+ object = Object.normalize(op.data["object"], false)
+
+ assert object.data["repliesCount"] == 0
+ end
+
+ test "it handles user deletions", %{delete_user: delete, user: user} do
+ {:ok, _delete, _} = SideEffects.handle(delete)
+ ObanHelpers.perform_all()
+
+ assert User.get_cached_by_ap_id(user.ap_id).deactivated
+ end
+ end
+
+ describe "EmojiReact objects" do
+ setup do
+ poster = insert(:user)
+ user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+
+ {:ok, emoji_react_data, []} = Builder.emoji_react(user, post.object, "👌")
+ {:ok, emoji_react, _meta} = ActivityPub.persist(emoji_react_data, local: true)
+
+ %{emoji_react: emoji_react, user: user, poster: poster}
+ end
+
+ test "adds the reaction to the object", %{emoji_react: emoji_react, user: user} do
+ {:ok, emoji_react, _} = SideEffects.handle(emoji_react)
+ object = Object.get_by_ap_id(emoji_react.data["object"])
+
+ assert object.data["reaction_count"] == 1
+ assert ["👌", [user.ap_id]] in object.data["reactions"]
+ end
+
+ test "creates a notification", %{emoji_react: emoji_react, poster: poster} do
+ {:ok, emoji_react, _} = SideEffects.handle(emoji_react)
+ assert Repo.get_by(Notification, user_id: poster.id, activity_id: emoji_react.id)
+ end
+ end
+
+ describe "Undo objects" do
+ setup do
+ poster = insert(:user)
+ user = insert(:user)
+ {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+ {:ok, like} = CommonAPI.favorite(user, post.id)
+ {:ok, reaction} = CommonAPI.react_with_emoji(post.id, user, "👍")
+ {:ok, announce, _} = CommonAPI.repeat(post.id, user)
+ {:ok, block} = ActivityPub.block(user, poster)
+ User.block(user, poster)
+
+ {:ok, undo_data, _meta} = Builder.undo(user, like)
+ {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+ {:ok, undo_data, _meta} = Builder.undo(user, reaction)
+ {:ok, reaction_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+ {:ok, undo_data, _meta} = Builder.undo(user, announce)
+ {:ok, announce_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+ {:ok, undo_data, _meta} = Builder.undo(user, block)
+ {:ok, block_undo, _meta} = ActivityPub.persist(undo_data, local: true)
+
+ %{
+ like_undo: like_undo,
+ post: post,
+ like: like,
+ reaction_undo: reaction_undo,
+ reaction: reaction,
+ announce_undo: announce_undo,
+ announce: announce,
+ block_undo: block_undo,
+ block: block,
+ poster: poster,
+ user: user
+ }
+ end
+
+ test "deletes the original block", %{block_undo: block_undo, block: block} do
+ {:ok, _block_undo, _} = SideEffects.handle(block_undo)
+ refute Activity.get_by_id(block.id)
+ end
+
+ test "unblocks the blocked user", %{block_undo: block_undo, block: block} do
+ blocker = User.get_by_ap_id(block.data["actor"])
+ blocked = User.get_by_ap_id(block.data["object"])
+
+ {:ok, _block_undo, _} = SideEffects.handle(block_undo)
+ refute User.blocks?(blocker, blocked)
+ end
+
+ test "an announce undo removes the announce from the object", %{
+ announce_undo: announce_undo,
+ post: post
+ } do
+ {:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
+
+ object = Object.get_by_ap_id(post.data["object"])
+
+ assert object.data["announcement_count"] == 0
+ assert object.data["announcements"] == []
+ end
+
+ test "deletes the original announce", %{announce_undo: announce_undo, announce: announce} do
+ {:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
+ refute Activity.get_by_id(announce.id)
+ end
+
+ test "a reaction undo removes the reaction from the object", %{
+ reaction_undo: reaction_undo,
+ post: post
+ } do
+ {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
+
+ object = Object.get_by_ap_id(post.data["object"])
+
+ assert object.data["reaction_count"] == 0
+ assert object.data["reactions"] == []
+ end
+
+ test "deletes the original reaction", %{reaction_undo: reaction_undo, reaction: reaction} do
+ {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
+ refute Activity.get_by_id(reaction.id)
+ end
+
+ test "a like undo removes the like from the object", %{like_undo: like_undo, post: post} do
+ {:ok, _like_undo, _} = SideEffects.handle(like_undo)
+
+ object = Object.get_by_ap_id(post.data["object"])
+
+ assert object.data["like_count"] == 0
+ assert object.data["likes"] == []
+ end
+
+ test "deletes the original like", %{like_undo: like_undo, like: like} do
+ {:ok, _like_undo, _} = SideEffects.handle(like_undo)
+ refute Activity.get_by_id(like.id)
+ end
+ end
+
+ describe "like objects" do
+ setup do
+ poster = insert(:user)
+ user = insert(:user)
+ {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+
+ {:ok, like_data, _meta} = Builder.like(user, post.object)
+ {:ok, like, _meta} = ActivityPub.persist(like_data, local: true)
+
+ %{like: like, user: user, poster: poster}
+ end
+
+ test "add the like to the original object", %{like: like, user: user} do
+ {:ok, like, _} = SideEffects.handle(like)
+ object = Object.get_by_ap_id(like.data["object"])
+ assert object.data["like_count"] == 1
+ assert user.ap_id in object.data["likes"]
+ end
+
+ test "creates a notification", %{like: like, poster: poster} do
+ {:ok, like, _} = SideEffects.handle(like)
+ assert Repo.get_by(Notification, user_id: poster.id, activity_id: like.id)
+ end
+ end
+end
diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
new file mode 100644
index 000000000..c9a53918c
--- /dev/null
+++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -0,0 +1,114 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
+ use Oban.Testing, repo: Pleroma.Repo
+ use Pleroma.DataCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Object
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.Transmogrifier
+
+ import Pleroma.Factory
+
+ setup_all do
+ Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
+ test "it works for incoming deletes" do
+ activity = insert(:note_activity)
+ deleting_user = insert(:user)
+
+ data =
+ File.read!("test/fixtures/mastodon-delete.json")
+ |> Poison.decode!()
+ |> Map.put("actor", deleting_user.ap_id)
+ |> put_in(["object", "id"], activity.data["object"])
+
+ {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
+ Transmogrifier.handle_incoming(data)
+
+ assert id == data["id"]
+
+ # We delete the Create activity because we base our timelines on it.
+ # This should be changed after we unify objects and activities
+ refute Activity.get_by_id(activity.id)
+ assert actor == deleting_user.ap_id
+
+ # Objects are replaced by a tombstone object.
+ object = Object.normalize(activity.data["object"])
+ assert object.data["type"] == "Tombstone"
+ end
+
+ test "it works for incoming when the object has been pruned" do
+ activity = insert(:note_activity)
+
+ {:ok, object} =
+ Object.normalize(activity.data["object"])
+ |> Repo.delete()
+
+ Cachex.del(:object_cache, "object:#{object.data["id"]}")
+
+ deleting_user = insert(:user)
+
+ data =
+ File.read!("test/fixtures/mastodon-delete.json")
+ |> Poison.decode!()
+ |> Map.put("actor", deleting_user.ap_id)
+ |> put_in(["object", "id"], activity.data["object"])
+
+ {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
+ Transmogrifier.handle_incoming(data)
+
+ assert id == data["id"]
+
+ # We delete the Create activity because we base our timelines on it.
+ # This should be changed after we unify objects and activities
+ refute Activity.get_by_id(activity.id)
+ assert actor == deleting_user.ap_id
+ end
+
+ test "it fails for incoming deletes with spoofed origin" do
+ activity = insert(:note_activity)
+ %{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
+
+ data =
+ File.read!("test/fixtures/mastodon-delete.json")
+ |> Poison.decode!()
+ |> Map.put("actor", ap_id)
+ |> put_in(["object", "id"], activity.data["object"])
+
+ assert match?({:error, _}, Transmogrifier.handle_incoming(data))
+ end
+
+ @tag capture_log: true
+ test "it works for incoming user deletes" do
+ %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
+
+ data =
+ File.read!("test/fixtures/mastodon-delete-user.json")
+ |> Poison.decode!()
+
+ {:ok, _} = Transmogrifier.handle_incoming(data)
+ ObanHelpers.perform_all()
+
+ assert User.get_cached_by_ap_id(ap_id).deactivated
+ end
+
+ test "it fails for incoming user deletes with spoofed origin" do
+ %{ap_id: ap_id} = insert(:user)
+
+ data =
+ File.read!("test/fixtures/mastodon-delete-user.json")
+ |> Poison.decode!()
+ |> Map.put("actor", ap_id)
+
+ assert match?({:error, _}, Transmogrifier.handle_incoming(data))
+
+ assert User.get_cached_by_ap_id(ap_id)
+ end
+end
diff --git a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs
new file mode 100644
index 000000000..0fb056b50
--- /dev/null
+++ b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs
@@ -0,0 +1,61 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Object
+ alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+
+ test "it works for incoming emoji reactions" do
+ user = insert(:user)
+ other_user = insert(:user, local: false)
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+
+ data =
+ File.read!("test/fixtures/emoji-reaction.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+ |> Map.put("actor", other_user.ap_id)
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["actor"] == other_user.ap_id
+ assert data["type"] == "EmojiReact"
+ assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2"
+ assert data["object"] == activity.data["object"]
+ assert data["content"] == "👌"
+
+ object = Object.get_by_ap_id(data["object"])
+
+ assert object.data["reaction_count"] == 1
+ assert match?([["👌", _]], object.data["reactions"])
+ end
+
+ test "it reject invalid emoji reactions" do
+ user = insert(:user)
+ other_user = insert(:user, local: false)
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+
+ data =
+ File.read!("test/fixtures/emoji-reaction-too-long.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+ |> Map.put("actor", other_user.ap_id)
+
+ assert {:error, _} = Transmogrifier.handle_incoming(data)
+
+ data =
+ File.read!("test/fixtures/emoji-reaction-no-emoji.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+ |> Map.put("actor", other_user.ap_id)
+
+ assert {:error, _} = Transmogrifier.handle_incoming(data)
+ end
+end
diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs
index 99ab573c5..967389fae 100644
--- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs
+++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do
@@ -19,6 +19,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do
end
describe "handle_incoming" do
+ setup do: clear_config([:user, :deny_follow_blocked])
+
test "it works for osada follow request" do
user = insert(:user)
@@ -58,7 +60,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do
end
test "with locked accounts, it does not create a follow or an accept" do
- user = insert(:user, info: %{locked: true})
+ user = insert(:user, locked: true)
data =
File.read!("test/fixtures/mastodon-follow-activity.json")
@@ -78,7 +80,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do
)
|> Repo.all()
- assert length(accepts) == 0
+ assert Enum.empty?(accepts)
end
test "it works for follow requests when you are already followed, creating a new accept activity" do
@@ -128,7 +130,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do
user = insert(:user)
{:ok, target} = User.get_or_fetch("http://mastodon.example.org/users/admin")
- {:ok, user} = User.block(user, target)
+ {:ok, _user_relationship} = User.block(user, target)
data =
File.read!("test/fixtures/mastodon-follow-activity.json")
diff --git a/test/web/activity_pub/transmogrifier/like_handling_test.exs b/test/web/activity_pub/transmogrifier/like_handling_test.exs
new file mode 100644
index 000000000..53fe1d550
--- /dev/null
+++ b/test/web/activity_pub/transmogrifier/like_handling_test.exs
@@ -0,0 +1,78 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.LikeHandlingTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+
+ test "it works for incoming likes" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+
+ data =
+ File.read!("test/fixtures/mastodon-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ _actor = insert(:user, ap_id: data["actor"], local: false)
+
+ {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data)
+
+ refute Enum.empty?(activity.recipients)
+
+ assert data["actor"] == "http://mastodon.example.org/users/admin"
+ assert data["type"] == "Like"
+ assert data["id"] == "http://mastodon.example.org/users/admin#likes/2"
+ assert data["object"] == activity.data["object"]
+ end
+
+ test "it works for incoming misskey likes, turning them into EmojiReacts" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+
+ data =
+ File.read!("test/fixtures/misskey-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ _actor = insert(:user, ap_id: data["actor"], local: false)
+
+ {:ok, %Activity{data: activity_data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert activity_data["actor"] == data["actor"]
+ assert activity_data["type"] == "EmojiReact"
+ assert activity_data["id"] == data["id"]
+ assert activity_data["object"] == activity.data["object"]
+ assert activity_data["content"] == "🍮"
+ end
+
+ test "it works for incoming misskey likes that contain unicode emojis, turning them into EmojiReacts" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+
+ data =
+ File.read!("test/fixtures/misskey-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+ |> Map.put("_misskey_reaction", "⭐")
+
+ _actor = insert(:user, ap_id: data["actor"], local: false)
+
+ {:ok, %Activity{data: activity_data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert activity_data["actor"] == data["actor"]
+ assert activity_data["type"] == "EmojiReact"
+ assert activity_data["id"] == data["id"]
+ assert activity_data["object"] == activity.data["object"]
+ assert activity_data["content"] == "⭐"
+ end
+end
diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs
new file mode 100644
index 000000000..01dd6c370
--- /dev/null
+++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs
@@ -0,0 +1,185 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Object
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+
+ test "it works for incoming emoji reaction undos" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, user, "👌")
+
+ data =
+ File.read!("test/fixtures/mastodon-undo-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", reaction_activity.data["id"])
+ |> Map.put("actor", user.ap_id)
+
+ {:ok, activity} = Transmogrifier.handle_incoming(data)
+
+ assert activity.actor == user.ap_id
+ assert activity.data["id"] == data["id"]
+ assert activity.data["type"] == "Undo"
+ end
+
+ test "it returns an error for incoming unlikes wihout a like activity" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"})
+
+ data =
+ File.read!("test/fixtures/mastodon-undo-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ assert Transmogrifier.handle_incoming(data) == :error
+ end
+
+ test "it works for incoming unlikes with an existing like activity" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"})
+
+ like_data =
+ File.read!("test/fixtures/mastodon-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ _liker = insert(:user, ap_id: like_data["actor"], local: false)
+
+ {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
+
+ data =
+ File.read!("test/fixtures/mastodon-undo-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", like_data)
+ |> Map.put("actor", like_data["actor"])
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["actor"] == "http://mastodon.example.org/users/admin"
+ assert data["type"] == "Undo"
+ assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
+ assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
+
+ note = Object.get_by_ap_id(like_data["object"])
+ assert note.data["like_count"] == 0
+ assert note.data["likes"] == []
+ end
+
+ test "it works for incoming unlikes with an existing like activity and a compact object" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"})
+
+ like_data =
+ File.read!("test/fixtures/mastodon-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ _liker = insert(:user, ap_id: like_data["actor"], local: false)
+
+ {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
+
+ data =
+ File.read!("test/fixtures/mastodon-undo-like.json")
+ |> Poison.decode!()
+ |> Map.put("object", like_data["id"])
+ |> Map.put("actor", like_data["actor"])
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["actor"] == "http://mastodon.example.org/users/admin"
+ assert data["type"] == "Undo"
+ assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
+ assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
+ end
+
+ test "it works for incoming unannounces with an existing notice" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
+
+ announce_data =
+ File.read!("test/fixtures/mastodon-announce.json")
+ |> Poison.decode!()
+ |> Map.put("object", activity.data["object"])
+
+ _announcer = insert(:user, ap_id: announce_data["actor"], local: false)
+
+ {:ok, %Activity{data: announce_data, local: false}} =
+ Transmogrifier.handle_incoming(announce_data)
+
+ data =
+ File.read!("test/fixtures/mastodon-undo-announce.json")
+ |> Poison.decode!()
+ |> Map.put("object", announce_data)
+ |> Map.put("actor", announce_data["actor"])
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["type"] == "Undo"
+
+ assert data["object"] ==
+ "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
+ end
+
+ test "it works for incomming unfollows with an existing follow" do
+ user = insert(:user)
+
+ follow_data =
+ File.read!("test/fixtures/mastodon-follow-activity.json")
+ |> Poison.decode!()
+ |> Map.put("object", user.ap_id)
+
+ _follower = insert(:user, ap_id: follow_data["actor"], local: false)
+
+ {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data)
+
+ data =
+ File.read!("test/fixtures/mastodon-unfollow-activity.json")
+ |> Poison.decode!()
+ |> Map.put("object", follow_data)
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["type"] == "Undo"
+ assert data["object"]["type"] == "Follow"
+ assert data["object"]["object"] == user.ap_id
+ assert data["actor"] == "http://mastodon.example.org/users/admin"
+
+ refute User.following?(User.get_cached_by_ap_id(data["actor"]), user)
+ end
+
+ test "it works for incoming unblocks with an existing block" do
+ user = insert(:user)
+
+ block_data =
+ File.read!("test/fixtures/mastodon-block-activity.json")
+ |> Poison.decode!()
+ |> Map.put("object", user.ap_id)
+
+ _blocker = insert(:user, ap_id: block_data["actor"], local: false)
+
+ {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
+
+ data =
+ File.read!("test/fixtures/mastodon-unblock-activity.json")
+ |> Poison.decode!()
+ |> Map.put("object", block_data)
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+ assert data["type"] == "Undo"
+ assert data["object"] == block_data["id"]
+
+ blocker = User.get_cached_by_ap_id(data["actor"])
+
+ refute User.blocks?(blocker, user)
+ end
+end
diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs
index dbb6e59b0..0a54e3bb9 100644
--- a/test/web/activity_pub/transmogrifier_test.exs
+++ b/test/web/activity_pub/transmogrifier_test.exs
@@ -1,9 +1,11 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
+ use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
+
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Object.Fetcher
@@ -11,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import Mock
@@ -22,7 +25,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
:ok
end
- clear_config([:instance, :max_remote_account_fields])
+ setup do: clear_config([:instance, :max_remote_account_fields])
describe "handle_incoming" do
test "it ignores an incoming notice if we already have it" do
@@ -38,7 +41,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert activity == returned_activity
end
- test "it fetches replied-to activities if we don't have them" do
+ @tag capture_log: true
+ test "it fetches reply-to activities if we don't have them" do
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Poison.decode!()
@@ -59,7 +63,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert returned_object.data["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873"
end
- test "it does not fetch replied-to activities beyond max_replies_depth" do
+ test "it does not fetch reply-to activities beyond max replies depth limit" do
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Poison.decode!()
@@ -71,7 +75,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
data = Map.put(data, "object", object)
with_mock Pleroma.Web.Federator,
- allowed_incoming_reply_depth?: fn _ -> false end do
+ allowed_thread_distance?: fn _ -> false end do
{:ok, returned_activity} = Transmogrifier.handle_incoming(data)
returned_object = Object.normalize(returned_activity, false)
@@ -145,7 +149,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = User.get_cached_by_ap_id(object_data["actor"])
- assert user.info.note_count == 1
+ assert user.note_count == 1
end
test "it works for incoming notices with hashtags" do
@@ -208,8 +212,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "suya...",
- "poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10}
+ status: "suya...",
+ poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
})
object = Object.normalize(activity)
@@ -256,6 +260,24 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
"<p>henlo from my Psion netBook</p><p>message sent from my Psion netBook</p>"
end
+ test "it works for incoming honk announces" do
+ _user = insert(:user, ap_id: "https://honktest/u/test", local: false)
+ other_user = insert(:user)
+ {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"})
+
+ announce = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "actor" => "https://honktest/u/test",
+ "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx",
+ "object" => post.data["object"],
+ "published" => "2019-06-25T19:33:58Z",
+ "to" => "https://www.w3.org/ns/activitystreams#Public",
+ "type" => "Announce"
+ }
+
+ {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(announce)
+ end
+
test "it works for incoming announces with actor being inlined (kroeg)" do
data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Poison.decode!()
@@ -321,85 +343,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert object_data["cc"] == to
end
- test "it works for incoming likes" do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
-
- data =
- File.read!("test/fixtures/mastodon-like.json")
- |> Poison.decode!()
- |> Map.put("object", activity.data["object"])
-
- {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
- assert data["actor"] == "http://mastodon.example.org/users/admin"
- assert data["type"] == "Like"
- assert data["id"] == "http://mastodon.example.org/users/admin#likes/2"
- assert data["object"] == activity.data["object"]
- end
-
- test "it returns an error for incoming unlikes wihout a like activity" do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
- data =
- File.read!("test/fixtures/mastodon-undo-like.json")
- |> Poison.decode!()
- |> Map.put("object", activity.data["object"])
-
- assert Transmogrifier.handle_incoming(data) == :error
- end
-
- test "it works for incoming unlikes with an existing like activity" do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
- like_data =
- File.read!("test/fixtures/mastodon-like.json")
- |> Poison.decode!()
- |> Map.put("object", activity.data["object"])
-
- {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
-
- data =
- File.read!("test/fixtures/mastodon-undo-like.json")
- |> Poison.decode!()
- |> Map.put("object", like_data)
- |> Map.put("actor", like_data["actor"])
-
- {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
- assert data["actor"] == "http://mastodon.example.org/users/admin"
- assert data["type"] == "Undo"
- assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
- assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
- end
-
- test "it works for incoming unlikes with an existing like activity and a compact object" do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
-
- like_data =
- File.read!("test/fixtures/mastodon-like.json")
- |> Poison.decode!()
- |> Map.put("object", activity.data["object"])
-
- {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
-
- data =
- File.read!("test/fixtures/mastodon-undo-like.json")
- |> Poison.decode!()
- |> Map.put("object", like_data["id"])
- |> Map.put("actor", like_data["actor"])
-
- {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
- assert data["actor"] == "http://mastodon.example.org/users/admin"
- assert data["type"] == "Undo"
- assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
- assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
- end
-
test "it works for incoming announces" do
data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!()
@@ -419,7 +362,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it works for incoming announces with an existing activity" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
data =
File.read!("test/fixtures/mastodon-announce.json")
@@ -458,6 +401,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert object.data["content"] == "this is a private toot"
end
+ @tag capture_log: true
test "it rejects incoming announces with an inlined activity from another origin" do
data =
File.read!("test/fixtures/bogus-mastodon-announce.json")
@@ -468,7 +412,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it does not clobber the addressing on announce activities" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
data =
File.read!("test/fixtures/mastodon-announce.json")
@@ -552,6 +496,20 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
refute Map.has_key?(object.data, "likes")
end
+ test "it strips internal reactions" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "📢")
+
+ %{object: object} = Activity.get_by_id_with_object(activity.id)
+ assert Map.has_key?(object.data, "reactions")
+ assert Map.has_key?(object.data, "reaction_count")
+
+ object_data = Transmogrifier.strip_internal_fields(object.data)
+ refute Map.has_key?(object_data, "reactions")
+ refute Map.has_key?(object_data, "reaction_count")
+ end
+
test "it works for incoming update activities" do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!()
@@ -582,7 +540,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
}
]
- assert user.info.banner["url"] == [
+ assert user.banner["url"] == [
%{
"href" =>
"https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png"
@@ -592,6 +550,37 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert user.bio == "<p>Some bio</p>"
end
+ test "it works with alsoKnownAs" do
+ {:ok, %Activity{data: %{"actor" => actor}}} =
+ "test/fixtures/mastodon-post-activity.json"
+ |> File.read!()
+ |> Poison.decode!()
+ |> Transmogrifier.handle_incoming()
+
+ assert User.get_cached_by_ap_id(actor).also_known_as == ["http://example.org/users/foo"]
+
+ {:ok, _activity} =
+ "test/fixtures/mastodon-update.json"
+ |> File.read!()
+ |> Poison.decode!()
+ |> Map.put("actor", actor)
+ |> Map.update!("object", fn object ->
+ object
+ |> Map.put("actor", actor)
+ |> Map.put("id", actor)
+ |> Map.put("alsoKnownAs", [
+ "http://mastodon.example.org/users/foo",
+ "http://example.org/users/bar"
+ ])
+ end)
+ |> Transmogrifier.handle_incoming()
+
+ assert User.get_cached_by_ap_id(actor).also_known_as == [
+ "http://mastodon.example.org/users/foo",
+ "http://example.org/users/bar"
+ ]
+ end
+
test "it works with custom profile fields" do
{:ok, activity} =
"test/fixtures/mastodon-post-activity.json"
@@ -601,7 +590,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = User.get_cached_by_ap_id(activity.actor)
- assert User.Info.fields(user.info) == [
+ assert user.fields == [
%{"name" => "foo", "value" => "bar"},
%{"name" => "foo1", "value" => "bar1"}
]
@@ -622,7 +611,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = User.get_cached_by_ap_id(user.ap_id)
- assert User.Info.fields(user.info) == [
+ assert user.fields == [
%{"name" => "foo", "value" => "updated"},
%{"name" => "foo1", "value" => "updated"}
]
@@ -640,7 +629,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = User.get_cached_by_ap_id(user.ap_id)
- assert User.Info.fields(user.info) == [
+ assert user.fields == [
%{"name" => "foo", "value" => "updated"},
%{"name" => "foo1", "value" => "updated"}
]
@@ -651,7 +640,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = User.get_cached_by_ap_id(user.ap_id)
- assert User.Info.fields(user.info) == []
+ assert user.fields == []
end
test "it works for incoming update activities which lock the account" do
@@ -674,109 +663,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data)
user = User.get_cached_by_ap_id(data["actor"])
- assert user.info.locked == true
- end
-
- test "it works for incoming deletes" do
- activity = insert(:note_activity)
- deleting_user = insert(:user)
-
- data =
- File.read!("test/fixtures/mastodon-delete.json")
- |> Poison.decode!()
-
- object =
- data["object"]
- |> Map.put("id", activity.data["object"])
-
- data =
- data
- |> Map.put("object", object)
- |> Map.put("actor", deleting_user.ap_id)
-
- {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
- Transmogrifier.handle_incoming(data)
-
- assert id == data["id"]
- refute Activity.get_by_id(activity.id)
- assert actor == deleting_user.ap_id
- end
-
- test "it fails for incoming deletes with spoofed origin" do
- activity = insert(:note_activity)
-
- data =
- File.read!("test/fixtures/mastodon-delete.json")
- |> Poison.decode!()
-
- object =
- data["object"]
- |> Map.put("id", activity.data["object"])
-
- data =
- data
- |> Map.put("object", object)
-
- assert capture_log(fn ->
- :error = Transmogrifier.handle_incoming(data)
- end) =~
- "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, {:error, :nxdomain}}"
-
- assert Activity.get_by_id(activity.id)
- end
-
- test "it works for incoming user deletes" do
- %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
-
- data =
- File.read!("test/fixtures/mastodon-delete-user.json")
- |> Poison.decode!()
-
- {:ok, _} = Transmogrifier.handle_incoming(data)
- ObanHelpers.perform_all()
-
- refute User.get_cached_by_ap_id(ap_id)
- end
-
- test "it fails for incoming user deletes with spoofed origin" do
- %{ap_id: ap_id} = insert(:user)
-
- data =
- File.read!("test/fixtures/mastodon-delete-user.json")
- |> Poison.decode!()
- |> Map.put("actor", ap_id)
-
- assert :error == Transmogrifier.handle_incoming(data)
- assert User.get_cached_by_ap_id(ap_id)
- end
-
- test "it works for incoming unannounces with an existing notice" do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
-
- announce_data =
- File.read!("test/fixtures/mastodon-announce.json")
- |> Poison.decode!()
- |> Map.put("object", activity.data["object"])
-
- {:ok, %Activity{data: announce_data, local: false}} =
- Transmogrifier.handle_incoming(announce_data)
-
- data =
- File.read!("test/fixtures/mastodon-undo-announce.json")
- |> Poison.decode!()
- |> Map.put("object", announce_data)
- |> Map.put("actor", announce_data["actor"])
-
- {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
-
- assert data["type"] == "Undo"
- assert object_data = data["object"]
- assert object_data["type"] == "Announce"
- assert object_data["object"] == activity.data["object"]
-
- assert object_data["id"] ==
- "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
+ assert user.locked == true
end
test "it works for incomming unfollows with an existing follow" do
@@ -804,6 +691,25 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
refute User.following?(User.get_cached_by_ap_id(data["actor"]), user)
end
+ test "it works for incoming follows to locked account" do
+ pending_follower = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
+ user = insert(:user, locked: true)
+
+ data =
+ File.read!("test/fixtures/mastodon-follow-activity.json")
+ |> Poison.decode!()
+ |> Map.put("object", user.ap_id)
+
+ {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+ assert data["type"] == "Follow"
+ assert data["object"] == user.ap_id
+ assert data["state"] == "pending"
+ assert data["actor"] == "http://mastodon.example.org/users/admin"
+
+ assert [^pending_follower] = User.get_follow_requests(user)
+ end
+
test "it works for incoming blocks" do
user = insert(:user)
@@ -854,32 +760,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
refute User.following?(blocked, blocker)
end
- test "it works for incoming unblocks with an existing block" do
- user = insert(:user)
-
- block_data =
- File.read!("test/fixtures/mastodon-block-activity.json")
- |> Poison.decode!()
- |> Map.put("object", user.ap_id)
-
- {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
-
- data =
- File.read!("test/fixtures/mastodon-unblock-activity.json")
- |> Poison.decode!()
- |> Map.put("object", block_data)
-
- {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
- assert data["type"] == "Undo"
- assert data["object"]["type"] == "Block"
- assert data["object"]["object"] == user.ap_id
- assert data["actor"] == "http://mastodon.example.org/users/admin"
-
- blocker = User.get_cached_by_ap_id(data["actor"])
-
- refute User.blocks?(blocker, user)
- end
-
test "it works for incoming accepts which were pre-accepted" do
follower = insert(:user)
followed = insert(:user)
@@ -915,7 +795,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it works for incoming accepts which were orphaned" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
{:ok, follow_activity} = ActivityPub.follow(follower, followed)
@@ -937,7 +817,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it works for incoming accepts which are referenced by IRI only" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
{:ok, follow_activity} = ActivityPub.follow(follower, followed)
@@ -953,11 +833,17 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
follower = User.get_cached_by_id(follower.id)
assert User.following?(follower, followed) == true
+
+ follower = User.get_by_id(follower.id)
+ assert follower.following_count == 1
+
+ followed = User.get_by_id(followed.id)
+ assert followed.follower_count == 1
end
test "it fails for incoming accepts which cannot be correlated" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
accept_data =
File.read!("test/fixtures/mastodon-accept-activity.json")
@@ -976,7 +862,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it fails for incoming rejects which cannot be correlated" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
accept_data =
File.read!("test/fixtures/mastodon-reject-activity.json")
@@ -995,7 +881,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it works for incoming rejects which are orphaned" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
{:ok, follower} = User.follow(follower, followed)
{:ok, _follow_activity} = ActivityPub.follow(follower, followed)
@@ -1021,7 +907,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it works for incoming rejects which are referenced by IRI only" do
follower = insert(:user)
- followed = insert(:user, %{info: %User.Info{locked: true}})
+ followed = insert(:user, locked: true)
{:ok, follower} = User.follow(follower, followed)
{:ok, follow_activity} = ActivityPub.follow(follower, followed)
@@ -1053,6 +939,35 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
:error = Transmogrifier.handle_incoming(data)
end
+ test "skip converting the content when it is nil" do
+ object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe"
+
+ {:ok, object} = Fetcher.fetch_and_contain_remote_object_from_id(object_id)
+
+ result =
+ Pleroma.Web.ActivityPub.Transmogrifier.fix_object(Map.merge(object, %{"content" => nil}))
+
+ assert result["content"] == nil
+ end
+
+ test "it converts content of object to html" do
+ object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe"
+
+ {:ok, %{"content" => content_markdown}} =
+ Fetcher.fetch_and_contain_remote_object_from_id(object_id)
+
+ {:ok, %Pleroma.Object{data: %{"content" => content}} = object} =
+ Fetcher.fetch_object_from_id(object_id)
+
+ assert content_markdown ==
+ "Support this and our other Michigan!/usr/group videos and meetings. Learn more at http://mug.org/membership\n\nTwenty Years in Jail: FreeBSD's Jails, Then and Now\n\nJails started as a limited virtualization system, but over the last two years they've..."
+
+ assert content ==
+ "<p>Support this and our other Michigan!/usr/group videos and meetings. Learn more at <a href=\"http://mug.org/membership\">http://mug.org/membership</a></p><p>Twenty Years in Jail: FreeBSD’s Jails, Then and Now</p><p>Jails started as a limited virtualization system, but over the last two years they’ve…</p>"
+
+ assert object.data["mediaType"] == "text/html"
+ end
+
test "it remaps video URLs as attachments if necessary" do
{:ok, object} =
Fetcher.fetch_object_from_id(
@@ -1062,19 +977,13 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
attachment = %{
"type" => "Link",
"mediaType" => "video/mp4",
- "href" =>
- "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
- "mimeType" => "video/mp4",
- "size" => 5_015_880,
"url" => [
%{
"href" =>
"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
- "mediaType" => "video/mp4",
- "type" => "Link"
+ "mediaType" => "video/mp4"
}
- ],
- "width" => 480
+ ]
}
assert object.data["url"] ==
@@ -1087,13 +996,21 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test post"})
object = Object.normalize(activity)
+ note_obj = %{
+ "type" => "Note",
+ "id" => activity.data["id"],
+ "content" => "test post",
+ "published" => object.data["published"],
+ "actor" => AccountView.render("show.json", %{user: user})
+ }
+
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"cc" => [user.ap_id],
- "object" => [user.ap_id, object.data["id"]],
+ "object" => [user.ap_id, activity.data["id"]],
"type" => "Flag",
"content" => "blocked AND reported!!!",
"actor" => other_user.ap_id
@@ -1101,18 +1018,175 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
- assert activity.data["object"] == [user.ap_id, object.data["id"]]
+ assert activity.data["object"] == [user.ap_id, note_obj]
assert activity.data["content"] == "blocked AND reported!!!"
assert activity.data["actor"] == other_user.ap_id
assert activity.data["cc"] == [user.ap_id]
end
+
+ test "it correctly processes messages with non-array to field" do
+ user = insert(:user)
+
+ message = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "to" => "https://www.w3.org/ns/activitystreams#Public",
+ "type" => "Create",
+ "object" => %{
+ "content" => "blah blah blah",
+ "type" => "Note",
+ "attributedTo" => user.ap_id,
+ "inReplyTo" => nil
+ },
+ "actor" => user.ap_id
+ }
+
+ assert {:ok, activity} = Transmogrifier.handle_incoming(message)
+
+ assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"]
+ end
+
+ test "it correctly processes messages with non-array cc field" do
+ user = insert(:user)
+
+ message = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "to" => user.follower_address,
+ "cc" => "https://www.w3.org/ns/activitystreams#Public",
+ "type" => "Create",
+ "object" => %{
+ "content" => "blah blah blah",
+ "type" => "Note",
+ "attributedTo" => user.ap_id,
+ "inReplyTo" => nil
+ },
+ "actor" => user.ap_id
+ }
+
+ assert {:ok, activity} = Transmogrifier.handle_incoming(message)
+
+ assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"]
+ assert [user.follower_address] == activity.data["to"]
+ end
+
+ test "it accepts Move activities" do
+ old_user = insert(:user)
+ new_user = insert(:user)
+
+ message = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "type" => "Move",
+ "actor" => old_user.ap_id,
+ "object" => old_user.ap_id,
+ "target" => new_user.ap_id
+ }
+
+ assert :error = Transmogrifier.handle_incoming(message)
+
+ {:ok, _new_user} = User.update_and_set_cache(new_user, %{also_known_as: [old_user.ap_id]})
+
+ assert {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(message)
+ assert activity.actor == old_user.ap_id
+ assert activity.data["actor"] == old_user.ap_id
+ assert activity.data["object"] == old_user.ap_id
+ assert activity.data["target"] == new_user.ap_id
+ assert activity.data["type"] == "Move"
+ end
+ end
+
+ describe "`handle_incoming/2`, Mastodon format `replies` handling" do
+ setup do: clear_config([:activitypub, :note_replies_output_limit], 5)
+ setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
+
+ setup do
+ data =
+ "test/fixtures/mastodon-post-activity.json"
+ |> File.read!()
+ |> Poison.decode!()
+
+ items = get_in(data, ["object", "replies", "first", "items"])
+ assert length(items) > 0
+
+ %{data: data, items: items}
+ end
+
+ test "schedules background fetching of `replies` items if max thread depth limit allows", %{
+ data: data,
+ items: items
+ } do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10)
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(data)
+
+ for id <- items do
+ job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1}
+ assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+ end
+ end
+
+ test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows",
+ %{data: data} do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(data)
+
+ assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == []
+ end
+ end
+
+ describe "`handle_incoming/2`, Pleroma format `replies` handling" do
+ setup do: clear_config([:activitypub, :note_replies_output_limit], 5)
+ setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
+
+ setup do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "post1"})
+
+ {:ok, reply1} =
+ CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id})
+
+ {:ok, reply2} =
+ CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: activity.id})
+
+ replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end)
+
+ {:ok, federation_output} = Transmogrifier.prepare_outgoing(activity.data)
+
+ Repo.delete(activity.object)
+ Repo.delete(activity)
+
+ %{federation_output: federation_output, replies_uris: replies_uris}
+ end
+
+ test "schedules background fetching of `replies` items if max thread depth limit allows", %{
+ federation_output: federation_output,
+ replies_uris: replies_uris
+ } do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 1)
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(federation_output)
+
+ for id <- replies_uris do
+ job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1}
+ assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+ end
+ end
+
+ test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows",
+ %{federation_output: federation_output} do
+ Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(federation_output)
+
+ assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == []
+ end
end
describe "prepare outgoing" do
test "it inlines private announced objects" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey", "visibility" => "private"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey", visibility: "private"})
{:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user)
@@ -1127,7 +1201,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "hey, @#{other_user.nickname}, how are ya? #2hu"})
+ CommonAPI.post(user, %{status: "hey, @#{other_user.nickname}, how are ya? #2hu"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
object = modified["object"]
@@ -1151,7 +1225,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it adds the sensitive property" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "#nsfw hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["object"]["sensitive"]
@@ -1160,7 +1234,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it adds the json-ld context and the conversation property" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["@context"] ==
@@ -1172,7 +1246,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it sets the 'attributedTo' property to the actor of the object if it doesn't have one" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["object"]["actor"] == modified["object"]["attributedTo"]
@@ -1181,7 +1255,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it strips internal hashtag data" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "#2hu"})
expected_tag = %{
"href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu",
@@ -1197,7 +1271,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
test "it strips internal fields" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu :firefox:"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "#2hu :firefox:"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
@@ -1229,14 +1303,13 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu :moominmamma:"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "2hu :moominmamma:"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["directMessage"] == false
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "@#{other_user.nickname} :moominmamma:"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "@#{other_user.nickname} :moominmamma:"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
@@ -1244,8 +1317,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "@#{other_user.nickname} :moominmamma:",
- "visibility" => "direct"
+ status: "@#{other_user.nickname} :moominmamma:",
+ visibility: "direct"
})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
@@ -1257,8 +1330,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
@@ -1290,25 +1362,26 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
follower_address: User.ap_followers(%User{nickname: "rye@niu.moe"})
})
- user_two = insert(:user, %{following: [user.follower_address]})
+ user_two = insert(:user)
+ Pleroma.FollowingRelationship.follow(user_two, user, :follow_accept)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test"})
- {:ok, unrelated_activity} = CommonAPI.post(user_two, %{"status" => "test"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "test"})
+ {:ok, unrelated_activity} = CommonAPI.post(user_two, %{status: "test"})
assert "http://localhost:4001/users/rye@niu.moe/followers" in activity.recipients
user = User.get_cached_by_id(user.id)
- assert user.info.note_count == 1
+ assert user.note_count == 1
{:ok, user} = Transmogrifier.upgrade_user_from_ap_id("https://niu.moe/users/rye")
ObanHelpers.perform_all()
- assert user.info.ap_enabled
- assert user.info.note_count == 1
+ assert user.ap_enabled
+ assert user.note_count == 1
assert user.follower_address == "https://niu.moe/users/rye/followers"
assert user.following_address == "https://niu.moe/users/rye/following"
user = User.get_cached_by_id(user.id)
- assert user.info.note_count == 1
+ assert user.note_count == 1
activity = Activity.get_by_id(activity.id)
assert user.follower_address in activity.recipients
@@ -1329,7 +1402,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
"https://cdn.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png"
}
]
- } = user.info.banner
+ } = user.banner
refute "..." in activity.recipients
@@ -1337,8 +1410,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
refute user.follower_address in unrelated_activity.recipients
user_two = User.get_cached_by_id(user_two.id)
- assert user.follower_address in user_two.following
- refute "..." in user_two.following
+ assert User.following?(user_two, user)
+ refute "..." in User.following(user_two)
end
end
@@ -1364,7 +1437,9 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
"type" => "Announce"
}
- :error = Transmogrifier.handle_incoming(data)
+ assert capture_log(fn ->
+ :error = Transmogrifier.handle_incoming(data)
+ end) =~ "Object containment failed"
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 1)" do
@@ -1377,7 +1452,9 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
"type" => "Announce"
}
- :error = Transmogrifier.handle_incoming(data)
+ assert capture_log(fn ->
+ :error = Transmogrifier.handle_incoming(data)
+ end) =~ "Object containment failed"
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 2)" do
@@ -1390,7 +1467,9 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
"type" => "Announce"
}
- :error = Transmogrifier.handle_incoming(data)
+ assert capture_log(fn ->
+ :error = Transmogrifier.handle_incoming(data)
+ end) =~ "Object containment failed"
end
end
@@ -1453,8 +1532,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
{:ok, poll_activity} =
CommonAPI.post(user, %{
- "status" => "suya...",
- "poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10}
+ status: "suya...",
+ poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
})
poll_object = Object.normalize(poll_activity)
@@ -1538,7 +1617,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
end
describe "fix_in_reply_to/2" do
- clear_config([:instance, :federation_incoming_replies_max_depth])
+ setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
setup do
data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json"))
@@ -1579,6 +1658,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert modified_object["inReplyToAtomUri"] == ""
end
+ @tag capture_log: true
test "returns modified object when allowed incoming reply", %{data: data} do
object_with_reply =
Map.put(
@@ -1693,9 +1773,12 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
describe "get_obj_helper/2" do
test "returns nil when cannot normalize object" do
- refute Transmogrifier.get_obj_helper("test-obj-id")
+ assert capture_log(fn ->
+ refute Transmogrifier.get_obj_helper("test-obj-id")
+ end) =~ "Unsupported URI scheme"
end
+ @tag capture_log: true
test "returns {:ok, %Object{}} for success case" do
assert {:ok, %Object{}} =
Transmogrifier.get_obj_helper("https://shitposter.club/notice/2827873")
@@ -1719,11 +1802,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
%{
"mediaType" => "video/mp4",
"url" => [
- %{
- "href" => "https://peertube.moe/stat-480.mp4",
- "mediaType" => "video/mp4",
- "type" => "Link"
- }
+ %{"href" => "https://peertube.moe/stat-480.mp4", "mediaType" => "video/mp4"}
]
}
]
@@ -1741,23 +1820,13 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
%{
"mediaType" => "video/mp4",
"url" => [
- %{
- "href" => "https://pe.er/stat-480.mp4",
- "mediaType" => "video/mp4",
- "type" => "Link"
- }
+ %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"}
]
},
%{
- "href" => "https://pe.er/stat-480.mp4",
"mediaType" => "video/mp4",
- "mimeType" => "video/mp4",
"url" => [
- %{
- "href" => "https://pe.er/stat-480.mp4",
- "mediaType" => "video/mp4",
- "type" => "Link"
- }
+ %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"}
]
}
]
@@ -1795,4 +1864,60 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
}
end
end
+
+ describe "set_replies/1" do
+ setup do: clear_config([:activitypub, :note_replies_output_limit], 2)
+
+ test "returns unmodified object if activity doesn't have self-replies" do
+ data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json"))
+ assert Transmogrifier.set_replies(data) == data
+ end
+
+ test "sets `replies` collection with a limited number of self-replies" do
+ [user, another_user] = insert_list(2, :user)
+
+ {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"})
+
+ {:ok, %{id: id2} = self_reply1} =
+ CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: id1})
+
+ {:ok, self_reply2} =
+ CommonAPI.post(user, %{status: "self-reply 2", in_reply_to_status_id: id1})
+
+ # Assuming to _not_ be present in `replies` due to :note_replies_output_limit is set to 2
+ {:ok, _} = CommonAPI.post(user, %{status: "self-reply 3", in_reply_to_status_id: id1})
+
+ {:ok, _} =
+ CommonAPI.post(user, %{
+ status: "self-reply to self-reply",
+ in_reply_to_status_id: id2
+ })
+
+ {:ok, _} =
+ CommonAPI.post(another_user, %{
+ status: "another user's reply",
+ in_reply_to_status_id: id1
+ })
+
+ object = Object.normalize(activity)
+ replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.object.data["id"] end)
+
+ assert %{"type" => "Collection", "items" => ^replies_uris} =
+ Transmogrifier.set_replies(object.data)["replies"]
+ end
+ end
+
+ test "take_emoji_tags/1" do
+ user = insert(:user, %{emoji: %{"firefox" => "https://example.org/firefox.png"}})
+
+ assert Transmogrifier.take_emoji_tags(user) == [
+ %{
+ "icon" => %{"type" => "Image", "url" => "https://example.org/firefox.png"},
+ "id" => "https://example.org/firefox.png",
+ "name" => ":firefox:",
+ "type" => "Emoji",
+ "updated" => "1970-01-01T00:00:00Z"
+ }
+ ]
+ end
end
diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs
index c57ea7eb9..9e0a0f1c4 100644
--- a/test/web/activity_pub/utils_test.exs
+++ b/test/web/activity_pub/utils_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.UtilsTest do
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
@@ -101,34 +102,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
end
end
- describe "make_unlike_data/3" do
- test "returns data for unlike activity" do
- user = insert(:user)
- like_activity = insert(:like_activity, data_attrs: %{"context" => "test context"})
-
- object = Object.normalize(like_activity.data["object"])
-
- assert Utils.make_unlike_data(user, like_activity, nil) == %{
- "type" => "Undo",
- "actor" => user.ap_id,
- "object" => like_activity.data,
- "to" => [user.follower_address, object.data["actor"]],
- "cc" => [Pleroma.Constants.as_public()],
- "context" => like_activity.data["context"]
- }
-
- assert Utils.make_unlike_data(user, like_activity, "9mJEZK0tky1w2xD2vY") == %{
- "type" => "Undo",
- "actor" => user.ap_id,
- "object" => like_activity.data,
- "to" => [user.follower_address, object.data["actor"]],
- "cc" => [Pleroma.Constants.as_public()],
- "context" => like_activity.data["context"],
- "id" => "9mJEZK0tky1w2xD2vY"
- }
- end
- end
-
describe "make_like_data" do
setup do
user = insert(:user)
@@ -147,7 +120,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"hey @#{other_user.nickname}, @#{third_user.nickname} how about beering together this weekend?"
})
@@ -166,8 +139,8 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "@#{other_user.nickname} @#{third_user.nickname} bought a new swimsuit!",
- "visibility" => "private"
+ status: "@#{other_user.nickname} @#{third_user.nickname} bought a new swimsuit!",
+ visibility: "private"
})
%{"to" => to, "cc" => cc} = Utils.make_like_data(other_user, activity, nil)
@@ -176,71 +149,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
end
end
- describe "fetch_ordered_collection" do
- import Tesla.Mock
-
- test "fetches the first OrderedCollectionPage when an OrderedCollection is encountered" do
- mock(fn
- %{method: :get, url: "http://mastodon.com/outbox"} ->
- json(%{"type" => "OrderedCollection", "first" => "http://mastodon.com/outbox?page=true"})
-
- %{method: :get, url: "http://mastodon.com/outbox?page=true"} ->
- json(%{"type" => "OrderedCollectionPage", "orderedItems" => ["ok"]})
- end)
-
- assert Utils.fetch_ordered_collection("http://mastodon.com/outbox", 1) == ["ok"]
- end
-
- test "fetches several pages in the right order one after another, but only the specified amount" do
- mock(fn
- %{method: :get, url: "http://example.com/outbox"} ->
- json(%{
- "type" => "OrderedCollectionPage",
- "orderedItems" => [0],
- "next" => "http://example.com/outbox?page=1"
- })
-
- %{method: :get, url: "http://example.com/outbox?page=1"} ->
- json(%{
- "type" => "OrderedCollectionPage",
- "orderedItems" => [1],
- "next" => "http://example.com/outbox?page=2"
- })
-
- %{method: :get, url: "http://example.com/outbox?page=2"} ->
- json(%{"type" => "OrderedCollectionPage", "orderedItems" => [2]})
- end)
-
- assert Utils.fetch_ordered_collection("http://example.com/outbox", 0) == [0]
- assert Utils.fetch_ordered_collection("http://example.com/outbox", 1) == [0, 1]
- end
-
- test "returns an error if the url doesn't have an OrderedCollection/Page" do
- mock(fn
- %{method: :get, url: "http://example.com/not-an-outbox"} ->
- json(%{"type" => "NotAnOutbox"})
- end)
-
- assert {:error, _} = Utils.fetch_ordered_collection("http://example.com/not-an-outbox", 1)
- end
-
- test "returns the what was collected if there are less pages than specified" do
- mock(fn
- %{method: :get, url: "http://example.com/outbox"} ->
- json(%{
- "type" => "OrderedCollectionPage",
- "orderedItems" => [0],
- "next" => "http://example.com/outbox?page=1"
- })
-
- %{method: :get, url: "http://example.com/outbox?page=1"} ->
- json(%{"type" => "OrderedCollectionPage", "orderedItems" => [1]})
- end)
-
- assert Utils.fetch_ordered_collection("http://example.com/outbox", 5) == [0, 1]
- end
- end
-
test "make_json_ld_header/0" do
assert Utils.make_json_ld_header() == %{
"@context" => [
@@ -260,11 +168,11 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "How do I pronounce LaTeX?",
- "poll" => %{
- "options" => ["laytekh", "lahtekh", "latex"],
- "expires_in" => 20,
- "multiple" => true
+ status: "How do I pronounce LaTeX?",
+ poll: %{
+ options: ["laytekh", "lahtekh", "latex"],
+ expires_in: 20,
+ multiple: true
}
})
@@ -279,17 +187,16 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Are we living in a society?",
- "poll" => %{
- "options" => ["yes", "no"],
- "expires_in" => 20
+ status: "Are we living in a society?",
+ poll: %{
+ options: ["yes", "no"],
+ expires_in: 20
}
})
object = Object.normalize(activity)
{:ok, [vote], object} = CommonAPI.vote(other_user, object, [0])
- vote_object = Object.normalize(vote)
- {:ok, _activity, _object} = ActivityPub.like(user, vote_object)
+ {:ok, _activity} = CommonAPI.favorite(user, activity.id)
[fetched_vote] = Utils.get_existing_votes(other_user.ap_id, object)
assert fetched_vote.id == vote.id
end
@@ -297,7 +204,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
describe "update_follow_state_for_all/2" do
test "updates the state of all Follow activities with the same actor and object" do
- user = insert(:user, info: %{locked: true})
+ user = insert(:user, locked: true)
follower = insert(:user)
{:ok, follow_activity} = ActivityPub.follow(follower, user)
@@ -321,7 +228,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
describe "update_follow_state/2" do
test "updates the state of the given follow activity" do
- user = insert(:user, info: %{locked: true})
+ user = insert(:user, locked: true)
follower = insert(:user)
{:ok, follow_activity} = ActivityPub.follow(follower, user)
@@ -410,7 +317,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
user = insert(:user)
refute Utils.get_existing_like(user.ap_id, object)
- {:ok, like_activity, _object} = ActivityPub.like(user, object)
+ {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
assert ^like_activity = Utils.get_existing_like(user.ap_id, object)
end
@@ -562,7 +469,7 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
test "returns map with Flag object" do
reporter = insert(:user)
target_account = insert(:user)
- {:ok, activity} = CommonAPI.post(target_account, %{"status" => "foobar"})
+ {:ok, activity} = CommonAPI.post(target_account, %{status: "foobar"})
context = Utils.generate_context_id()
content = "foobar"
@@ -581,11 +488,19 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
%{}
)
+ note_obj = %{
+ "type" => "Note",
+ "id" => activity_ap_id,
+ "content" => content,
+ "published" => activity.object.data["published"],
+ "actor" => AccountView.render("show.json", %{user: target_account})
+ }
+
assert %{
"type" => "Flag",
"content" => ^content,
"context" => ^context,
- "object" => [^target_ap_id, ^activity_ap_id],
+ "object" => [^target_ap_id, ^note_obj],
"state" => "open"
} = res
end
@@ -627,4 +542,17 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
assert updated_object.data["announcement_count"] == 1
end
end
+
+ describe "get_cached_emoji_reactions/1" do
+ test "returns the data or an emtpy list" do
+ object = insert(:note)
+ assert Utils.get_cached_emoji_reactions(object) == []
+
+ object = insert(:note, data: %{"reactions" => [["x", ["lain"]]]})
+ assert Utils.get_cached_emoji_reactions(object) == [["x", ["lain"]]]
+
+ object = insert(:note, data: %{"reactions" => %{}})
+ assert Utils.get_cached_emoji_reactions(object) == []
+ end
+ end
end
diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs
index 13447dc29..43f0617f0 100644
--- a/test/web/activity_pub/views/object_view_test.exs
+++ b/test/web/activity_pub/views/object_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
@@ -36,12 +36,30 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
assert result["@context"]
end
+ describe "note activity's `replies` collection rendering" do
+ setup do: clear_config([:activitypub, :note_replies_output_limit], 5)
+
+ test "renders `replies` collection for a note activity" do
+ user = insert(:user)
+ activity = insert(:note_activity, user: user)
+
+ {:ok, self_reply1} =
+ CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: activity.id})
+
+ replies_uris = [self_reply1.object.data["id"]]
+ result = ObjectView.render("object.json", %{object: refresh_record(activity)})
+
+ assert %{"type" => "Collection", "items" => ^replies_uris} =
+ get_in(result, ["object", "replies"])
+ end
+ end
+
test "renders a like activity" do
note = insert(:note_activity)
object = Object.normalize(note)
user = insert(:user)
- {:ok, like_activity, _} = CommonAPI.favorite(note.id, user)
+ {:ok, like_activity} = CommonAPI.favorite(user, note.id)
result = ObjectView.render("object.json", %{object: like_activity})
diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs
index a31b4c92e..20b0f223c 100644
--- a/test/web/activity_pub/views/user_view_test.exs
+++ b/test/web/activity_pub/views/user_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.UserViewTest do
@@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
{:ok, user} =
insert(:user)
- |> User.upgrade_changeset(%{info: %{fields: fields}})
+ |> User.update_changeset(%{fields: fields})
|> User.update_and_set_cache()
assert %{
@@ -38,7 +38,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
end
test "Renders with emoji tags" do
- user = insert(:user, %{info: %{emoji: [%{"bib" => "/test"}]}})
+ user = insert(:user, emoji: %{"bib" => "/test"})
assert %{
"tag" => [
@@ -64,9 +64,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
user =
insert(:user,
avatar: %{"url" => [%{"href" => "https://someurl"}]},
- info: %{
- banner: %{"url" => [%{"href" => "https://somebanner"}]}
- }
+ banner: %{"url" => [%{"href" => "https://somebanner"}]}
)
{:ok, user} = User.ensure_keys_present(user)
@@ -77,7 +75,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
end
test "renders an invisible user with the invisible property set to true" do
- user = insert(:user, %{info: %{invisible: true}})
+ user = insert(:user, invisible: true)
assert %{"invisible" => true} = UserView.render("service.json", %{user: user})
end
@@ -127,9 +125,8 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
- info = Map.merge(user.info, %{hide_followers_count: true, hide_followers: true})
- user = Map.put(user, :info, info)
- assert %{"totalItems" => 0} = UserView.render("followers.json", %{user: user})
+ user = Map.merge(user, %{hide_followers_count: true, hide_followers: true})
+ refute UserView.render("followers.json", %{user: user}) |> Map.has_key?("totalItems")
end
test "sets correct totalItems when followers are hidden but the follower counter is not" do
@@ -137,8 +134,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
- info = Map.merge(user.info, %{hide_followers_count: false, hide_followers: true})
- user = Map.put(user, :info, info)
+ user = Map.merge(user, %{hide_followers_count: false, hide_followers: true})
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
end
end
@@ -149,8 +145,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
- info = Map.merge(user.info, %{hide_follows_count: true, hide_follows: true})
- user = Map.put(user, :info, info)
+ user = Map.merge(user, %{hide_follows_count: true, hide_follows: true})
assert %{"totalItems" => 0} = UserView.render("following.json", %{user: user})
end
@@ -159,8 +154,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
- info = Map.merge(user.info, %{hide_follows_count: false, hide_follows: true})
- user = Map.put(user, :info, info)
+ user = Map.merge(user, %{hide_follows_count: false, hide_follows: true})
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
end
end
@@ -170,7 +164,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
posts =
for i <- 0..25 do
- {:ok, activity} = CommonAPI.post(user, %{"status" => "post #{i}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"})
activity
end
diff --git a/test/web/activity_pub/visibilty_test.exs b/test/web/activity_pub/visibilty_test.exs
index b62a89e68..8e9354c65 100644
--- a/test/web/activity_pub/visibilty_test.exs
+++ b/test/web/activity_pub/visibilty_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.VisibilityTest do
@@ -21,21 +21,21 @@ defmodule Pleroma.Web.ActivityPub.VisibilityTest do
Pleroma.List.follow(list, unrelated)
{:ok, public} =
- CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "public"})
+ CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "public"})
{:ok, private} =
- CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "private"})
+ CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "private"})
{:ok, direct} =
- CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "direct"})
{:ok, unlisted} =
- CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "unlisted"})
+ CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "unlisted"})
{:ok, list} =
CommonAPI.post(user, %{
- "status" => "@#{mentioned.nickname}",
- "visibility" => "list:#{list.id}"
+ status: "@#{mentioned.nickname}",
+ visibility: "list:#{list.id}"
})
%{
@@ -212,7 +212,8 @@ defmodule Pleroma.Web.ActivityPub.VisibilityTest do
test "returns true if user following to author" do
author = insert(:user)
- user = insert(:user, following: [author.ap_id])
+ user = insert(:user)
+ Pleroma.User.follow(user, author)
activity =
insert(:note_activity,
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index 9da4940be..370d876d0 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -1,21 +1,30 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
use Pleroma.Web.ConnCase
use Oban.Testing, repo: Pleroma.Repo
+ import ExUnit.CaptureLog
+ import Mock
+ import Pleroma.Factory
+
alias Pleroma.Activity
+ alias Pleroma.Config
+ alias Pleroma.ConfigDB
alias Pleroma.HTML
+ alias Pleroma.MFA
alias Pleroma.ModerationLog
alias Pleroma.Repo
+ alias Pleroma.ReportNote
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.UserInviteToken
+ alias Pleroma.Web
+ alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MediaProxy
- import Pleroma.Factory
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -23,33 +32,151 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
:ok
end
+ setup do
+ admin = insert(:user, is_admin: true)
+ token = insert(:oauth_admin_token, user: admin)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, token)
+
+ {:ok, %{admin: admin, token: token, conn: conn}}
+ end
+
+ describe "with [:auth, :enforce_oauth_admin_scope_usage]," do
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true)
+
+ test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope",
+ %{admin: admin} do
+ user = insert(:user)
+ url = "/api/pleroma/admin/users/#{user.nickname}"
+
+ good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"])
+ good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"])
+ good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"])
+
+ bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts"])
+ bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"])
+ bad_token3 = nil
+
+ for good_token <- [good_token1, good_token2, good_token3] do
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, good_token)
+ |> get(url)
+
+ assert json_response(conn, 200)
+ end
+
+ for good_token <- [good_token1, good_token2, good_token3] do
+ conn =
+ build_conn()
+ |> assign(:user, nil)
+ |> assign(:token, good_token)
+ |> get(url)
+
+ assert json_response(conn, :forbidden)
+ end
+
+ for bad_token <- [bad_token1, bad_token2, bad_token3] do
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, bad_token)
+ |> get(url)
+
+ assert json_response(conn, :forbidden)
+ end
+ end
+ end
+
+ describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false)
+
+ test "GET /api/pleroma/admin/users/:nickname requires " <>
+ "read:accounts or admin:read:accounts or broader scope",
+ %{admin: admin} do
+ user = insert(:user)
+ url = "/api/pleroma/admin/users/#{user.nickname}"
+
+ good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"])
+ good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"])
+ good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"])
+ good_token4 = insert(:oauth_token, user: admin, scopes: ["read:accounts"])
+ good_token5 = insert(:oauth_token, user: admin, scopes: ["read"])
+
+ good_tokens = [good_token1, good_token2, good_token3, good_token4, good_token5]
+
+ bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts:partial"])
+ bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"])
+ bad_token3 = nil
+
+ for good_token <- good_tokens do
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, good_token)
+ |> get(url)
+
+ assert json_response(conn, 200)
+ end
+
+ for good_token <- good_tokens do
+ conn =
+ build_conn()
+ |> assign(:user, nil)
+ |> assign(:token, good_token)
+ |> get(url)
+
+ assert json_response(conn, :forbidden)
+ end
+
+ for bad_token <- [bad_token1, bad_token2, bad_token3] do
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, bad_token)
+ |> get(url)
+
+ assert json_response(conn, :forbidden)
+ end
+ end
+ end
+
describe "DELETE /api/pleroma/admin/users" do
- test "single user" do
- admin = insert(:user, info: %{is_admin: true})
+ test "single user", %{admin: admin, conn: conn} do
user = insert(:user)
- conn =
- build_conn()
- |> assign(:user, admin)
- |> put_req_header("accept", "application/json")
- |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ conn =
+ conn
+ |> put_req_header("accept", "application/json")
+ |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
- log_entry = Repo.one(ModerationLog)
+ ObanHelpers.perform_all()
- assert ModerationLog.get_log_entry_message(log_entry) ==
- "@#{admin.nickname} deleted users: @#{user.nickname}"
+ assert User.get_by_nickname(user.nickname).deactivated
+
+ log_entry = Repo.one(ModerationLog)
- assert json_response(conn, 200) == user.nickname
+ assert ModerationLog.get_log_entry_message(log_entry) ==
+ "@#{admin.nickname} deleted users: @#{user.nickname}"
+
+ assert json_response(conn, 200) == [user.nickname]
+
+ assert called(Pleroma.Web.Federator.publish(:_))
+ end
end
- test "multiple users" do
- admin = insert(:user, info: %{is_admin: true})
+ test "multiple users", %{admin: admin, conn: conn} do
user_one = insert(:user)
user_two = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/users", %{
nicknames: [user_one.nickname, user_two.nickname]
@@ -66,12 +193,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "/api/pleroma/admin/users" do
- test "Create" do
- admin = insert(:user, info: %{is_admin: true})
-
+ test "Create", %{conn: conn} do
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users", %{
"users" => [
@@ -96,13 +220,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == []
end
- test "Cannot create user with exisiting email" do
- admin = insert(:user, info: %{is_admin: true})
+ test "Cannot create user with existing email", %{conn: conn} do
user = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users", %{
"users" => [
@@ -127,13 +249,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
]
end
- test "Cannot create user with exisiting nickname" do
- admin = insert(:user, info: %{is_admin: true})
+ test "Cannot create user with existing nickname", %{conn: conn} do
user = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users", %{
"users" => [
@@ -158,13 +278,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
]
end
- test "Multiple user creation works in transaction" do
- admin = insert(:user, info: %{is_admin: true})
+ test "Multiple user creation works in transaction", %{conn: conn} do
user = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users", %{
"users" => [
@@ -208,13 +326,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
describe "/api/pleroma/admin/users/:nickname" do
test "Show", %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
user = insert(:user)
- conn =
- conn
- |> assign(:user, admin)
- |> get("/api/pleroma/admin/users/#{user.nickname}")
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}")
expected = %{
"deactivated" => false,
@@ -224,33 +338,28 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"roles" => %{"admin" => false, "moderator" => false},
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
assert expected == json_response(conn, 200)
end
test "when the user doesn't exist", %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
user = build(:user)
- conn =
- conn
- |> assign(:user, admin)
- |> get("/api/pleroma/admin/users/#{user.nickname}")
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}")
assert "Not found" == json_response(conn, 404)
end
end
describe "/api/pleroma/admin/users/follow" do
- test "allows to force-follow another user" do
- admin = insert(:user, info: %{is_admin: true})
+ test "allows to force-follow another user", %{admin: admin, conn: conn} do
user = insert(:user)
follower = insert(:user)
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users/follow", %{
"follower" => follower.nickname,
@@ -270,15 +379,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "/api/pleroma/admin/users/unfollow" do
- test "allows to force-unfollow another user" do
- admin = insert(:user, info: %{is_admin: true})
+ test "allows to force-unfollow another user", %{admin: admin, conn: conn} do
user = insert(:user)
follower = insert(:user)
User.follow(follower, user)
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users/unfollow", %{
"follower" => follower.nickname,
@@ -298,23 +405,20 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "PUT /api/pleroma/admin/users/tag" do
- setup do
- admin = insert(:user, info: %{is_admin: true})
+ setup %{conn: conn} do
user1 = insert(:user, %{tags: ["x"]})
user2 = insert(:user, %{tags: ["y"]})
user3 = insert(:user, %{tags: ["unchanged"]})
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> put(
- "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=#{
- user2.nickname
- }&tags[]=foo&tags[]=bar"
+ "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <>
+ "#{user2.nickname}&tags[]=foo&tags[]=bar"
)
- %{conn: conn, admin: admin, user1: user1, user2: user2, user3: user3}
+ %{conn: conn, user1: user1, user2: user2, user3: user3}
end
test "it appends specified tags to users with specified nicknames", %{
@@ -347,23 +451,20 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "DELETE /api/pleroma/admin/users/tag" do
- setup do
- admin = insert(:user, info: %{is_admin: true})
+ setup %{conn: conn} do
user1 = insert(:user, %{tags: ["x"]})
user2 = insert(:user, %{tags: ["y", "z"]})
user3 = insert(:user, %{tags: ["unchanged"]})
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> delete(
- "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=#{
- user2.nickname
- }&tags[]=x&tags[]=z"
+ "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <>
+ "#{user2.nickname}&tags[]=x&tags[]=z"
)
- %{conn: conn, admin: admin, user1: user1, user2: user2, user3: user3}
+ %{conn: conn, user1: user1, user2: user2, user3: user3}
end
test "it removes specified tags from users with specified nicknames", %{
@@ -396,12 +497,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "/api/pleroma/admin/users/:nickname/permission_group" do
- test "GET is giving user_info" do
- admin = insert(:user, info: %{is_admin: true})
-
+ test "GET is giving user_info", %{admin: admin, conn: conn} do
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> get("/api/pleroma/admin/users/#{admin.nickname}/permission_group/")
@@ -411,13 +509,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
}
end
- test "/:right POST, can add to a permission group" do
- admin = insert(:user, info: %{is_admin: true})
+ test "/:right POST, can add to a permission group", %{admin: admin, conn: conn} do
user = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin")
@@ -431,22 +527,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} made @#{user.nickname} admin"
end
- test "/:right POST, can add to a permission group (multiple)" do
- admin = insert(:user, info: %{is_admin: true})
+ test "/:right POST, can add to a permission group (multiple)", %{admin: admin, conn: conn} do
user_one = insert(:user)
user_two = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users/permission_group/admin", %{
nicknames: [user_one.nickname, user_two.nickname]
})
- assert json_response(conn, 200) == %{
- "is_admin" => true
- }
+ assert json_response(conn, 200) == %{"is_admin" => true}
log_entry = Repo.one(ModerationLog)
@@ -454,19 +546,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} made @#{user_one.nickname}, @#{user_two.nickname} admin"
end
- test "/:right DELETE, can remove from a permission group" do
- admin = insert(:user, info: %{is_admin: true})
- user = insert(:user, info: %{is_admin: true})
+ test "/:right DELETE, can remove from a permission group", %{admin: admin, conn: conn} do
+ user = insert(:user, is_admin: true)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin")
- assert json_response(conn, 200) == %{
- "is_admin" => false
- }
+ assert json_response(conn, 200) == %{"is_admin" => false}
log_entry = Repo.one(ModerationLog)
@@ -474,22 +562,21 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} revoked admin role from @#{user.nickname}"
end
- test "/:right DELETE, can remove from a permission group (multiple)" do
- admin = insert(:user, info: %{is_admin: true})
- user_one = insert(:user, info: %{is_admin: true})
- user_two = insert(:user, info: %{is_admin: true})
+ test "/:right DELETE, can remove from a permission group (multiple)", %{
+ admin: admin,
+ conn: conn
+ } do
+ user_one = insert(:user, is_admin: true)
+ user_two = insert(:user, is_admin: true)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/users/permission_group/admin", %{
nicknames: [user_one.nickname, user_two.nickname]
})
- assert json_response(conn, 200) == %{
- "is_admin" => false
- }
+ assert json_response(conn, 200) == %{"is_admin" => false}
log_entry = Repo.one(ModerationLog)
@@ -501,41 +588,31 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "POST /api/pleroma/admin/email_invite, with valid config" do
- setup do
- [user: insert(:user, info: %{is_admin: true})]
- end
-
- clear_config([:instance, :registrations_open]) do
- Pleroma.Config.put([:instance, :registrations_open], false)
- end
+ setup do: clear_config([:instance, :registrations_open], false)
+ setup do: clear_config([:instance, :invites_enabled], true)
- clear_config([:instance, :invites_enabled]) do
- Pleroma.Config.put([:instance, :invites_enabled], true)
- end
-
- test "sends invitation and returns 204", %{conn: conn, user: user} do
+ test "sends invitation and returns 204", %{admin: admin, conn: conn} do
recipient_email = "foo@bar.com"
recipient_name = "J. D."
conn =
- conn
- |> assign(:user, user)
- |> post(
+ post(
+ conn,
"/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}"
)
assert json_response(conn, :no_content)
- token_record = List.last(Pleroma.Repo.all(Pleroma.UserInviteToken))
+ token_record = List.last(Repo.all(Pleroma.UserInviteToken))
assert token_record
refute token_record.used
- notify_email = Pleroma.Config.get([:instance, :notify_email])
- instance_name = Pleroma.Config.get([:instance, :name])
+ notify_email = Config.get([:instance, :notify_email])
+ instance_name = Config.get([:instance, :name])
email =
Pleroma.Emails.UserEmail.user_invitation_email(
- user,
+ admin,
token_record,
recipient_email,
recipient_name
@@ -548,58 +625,83 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
)
end
- test "it returns 403 if requested by a non-admin", %{conn: conn} do
+ test "it returns 403 if requested by a non-admin" do
non_admin_user = insert(:user)
+ token = insert(:oauth_token, user: non_admin_user)
conn =
- conn
+ build_conn()
|> assign(:user, non_admin_user)
+ |> assign(:token, token)
|> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD")
assert json_response(conn, :forbidden)
end
- end
- describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do
- setup do
- [user: insert(:user, info: %{is_admin: true})]
+ test "email with +", %{conn: conn, admin: admin} do
+ recipient_email = "foo+bar@baz.com"
+
+ conn
+ |> put_req_header("content-type", "application/json;charset=utf-8")
+ |> post("/api/pleroma/admin/users/email_invite", %{email: recipient_email})
+ |> json_response(:no_content)
+
+ token_record =
+ Pleroma.UserInviteToken
+ |> Repo.all()
+ |> List.last()
+
+ assert token_record
+ refute token_record.used
+
+ notify_email = Config.get([:instance, :notify_email])
+ instance_name = Config.get([:instance, :name])
+
+ email =
+ Pleroma.Emails.UserEmail.user_invitation_email(
+ admin,
+ token_record,
+ recipient_email
+ )
+
+ Swoosh.TestAssertions.assert_email_sent(
+ from: {instance_name, notify_email},
+ to: recipient_email,
+ html_body: email.html_body
+ )
end
+ end
- clear_config([:instance, :registrations_open])
- clear_config([:instance, :invites_enabled])
+ describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do
+ setup do: clear_config([:instance, :registrations_open])
+ setup do: clear_config([:instance, :invites_enabled])
- test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn, user: user} do
- Pleroma.Config.put([:instance, :registrations_open], false)
- Pleroma.Config.put([:instance, :invites_enabled], false)
+ test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do
+ Config.put([:instance, :registrations_open], false)
+ Config.put([:instance, :invites_enabled], false)
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD")
+ conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD")
- assert json_response(conn, :internal_server_error)
+ assert json_response(conn, :bad_request) ==
+ "To send invites you need to set the `invites_enabled` option to true."
end
- test "it returns 500 if `registrations_open` is enabled", %{conn: conn, user: user} do
- Pleroma.Config.put([:instance, :registrations_open], true)
- Pleroma.Config.put([:instance, :invites_enabled], true)
+ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do
+ Config.put([:instance, :registrations_open], true)
+ Config.put([:instance, :invites_enabled], true)
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD")
+ conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD")
- assert json_response(conn, :internal_server_error)
+ assert json_response(conn, :bad_request) ==
+ "To send invites you need to set the `registrations_open` option to false."
end
end
- test "/api/pleroma/admin/users/:nickname/password_reset" do
- admin = insert(:user, info: %{is_admin: true})
+ test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do
user = insert(:user)
conn =
- build_conn()
- |> assign(:user, admin)
+ conn
|> put_req_header("accept", "application/json")
|> get("/api/pleroma/admin/users/#{user.nickname}/password_reset")
@@ -609,16 +711,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "GET /api/pleroma/admin/users" do
- setup do
- admin = insert(:user, info: %{is_admin: true})
-
- conn =
- build_conn()
- |> assign(:user, admin)
-
- {:ok, conn: conn, admin: admin}
- end
-
test "renders users array for the first page", %{conn: conn, admin: admin} do
user = insert(:user, local: false, tags: ["foo", "bar"])
conn = get(conn, "/api/pleroma/admin/users?page=1")
@@ -626,24 +718,26 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
users =
[
%{
- "deactivated" => admin.info.deactivated,
+ "deactivated" => admin.deactivated,
"id" => admin.id,
"nickname" => admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(admin) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(admin.name || admin.nickname)
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname),
+ "confirmation_pending" => false
},
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => false,
"tags" => ["foo", "bar"],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
|> Enum.sort_by(& &1["nickname"])
@@ -655,6 +749,39 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
}
end
+ test "pagination works correctly with service users", %{conn: conn} do
+ service1 = insert(:user, ap_id: Web.base_url() <> "/relay")
+ service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch")
+ insert_list(25, :user)
+
+ assert %{"count" => 26, "page_size" => 10, "users" => users1} =
+ conn
+ |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"})
+ |> json_response(200)
+
+ assert Enum.count(users1) == 10
+ assert service1 not in [users1]
+ assert service2 not in [users1]
+
+ assert %{"count" => 26, "page_size" => 10, "users" => users2} =
+ conn
+ |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"})
+ |> json_response(200)
+
+ assert Enum.count(users2) == 10
+ assert service1 not in [users2]
+ assert service2 not in [users2]
+
+ assert %{"count" => 26, "page_size" => 10, "users" => users3} =
+ conn
+ |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"})
+ |> json_response(200)
+
+ assert Enum.count(users3) == 6
+ assert service1 not in [users3]
+ assert service2 not in [users3]
+ end
+
test "renders empty array for the second page", %{conn: conn} do
insert(:user)
@@ -677,14 +804,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -701,14 +829,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -725,14 +854,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -749,14 +879,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -773,14 +904,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -797,14 +929,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 1,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -816,21 +949,23 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 1,
"users" => [
%{
- "deactivated" => user2.info.deactivated,
+ "deactivated" => user2.deactivated,
"id" => user2.id,
"nickname" => user2.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user2) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user2.name || user2.nickname)
+ "display_name" => HTML.strip_tags(user2.name || user2.nickname),
+ "confirmation_pending" => false
}
]
}
end
test "only local users" do
- admin = insert(:user, info: %{is_admin: true}, nickname: "john")
+ admin = insert(:user, is_admin: true, nickname: "john")
+ token = insert(:oauth_admin_token, user: admin)
user = insert(:user, nickname: "bob")
insert(:user, nickname: "bobb", local: false)
@@ -838,6 +973,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
conn =
build_conn()
|> assign(:user, admin)
+ |> assign(:token, token)
|> get("/api/pleroma/admin/users?query=bo&filters=local")
assert json_response(conn, 200) == %{
@@ -845,51 +981,51 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
]
}
end
- test "only local users with no query", %{admin: old_admin} do
- admin = insert(:user, info: %{is_admin: true}, nickname: "john")
+ test "only local users with no query", %{conn: conn, admin: old_admin} do
+ admin = insert(:user, is_admin: true, nickname: "john")
user = insert(:user, nickname: "bob")
insert(:user, nickname: "bobb", local: false)
- conn =
- build_conn()
- |> assign(:user, admin)
- |> get("/api/pleroma/admin/users?filters=local")
+ conn = get(conn, "/api/pleroma/admin/users?filters=local")
users =
[
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
},
%{
- "deactivated" => admin.info.deactivated,
+ "deactivated" => admin.deactivated,
"id" => admin.id,
"nickname" => admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(admin) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(admin.name || admin.nickname)
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname),
+ "confirmation_pending" => false
},
%{
"deactivated" => false,
@@ -899,7 +1035,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"roles" => %{"admin" => true, "moderator" => false},
"tags" => [],
"avatar" => User.avatar_url(old_admin) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname)
+ "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname),
+ "confirmation_pending" => false
}
]
|> Enum.sort_by(& &1["nickname"])
@@ -912,7 +1049,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "load only admins", %{conn: conn, admin: admin} do
- second_admin = insert(:user, info: %{is_admin: true})
+ second_admin = insert(:user, is_admin: true)
insert(:user)
insert(:user)
@@ -928,7 +1065,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"local" => admin.local,
"tags" => [],
"avatar" => User.avatar_url(admin) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(admin.name || admin.nickname)
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname),
+ "confirmation_pending" => false
},
%{
"deactivated" => false,
@@ -938,7 +1076,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"local" => second_admin.local,
"tags" => [],
"avatar" => User.avatar_url(second_admin) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname)
+ "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname),
+ "confirmation_pending" => false
}
]
|> Enum.sort_by(& &1["nickname"])
@@ -951,7 +1090,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "load only moderators", %{conn: conn} do
- moderator = insert(:user, info: %{is_moderator: true})
+ moderator = insert(:user, is_moderator: true)
insert(:user)
insert(:user)
@@ -969,7 +1108,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"local" => moderator.local,
"tags" => [],
"avatar" => User.avatar_url(moderator) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(moderator.name || moderator.nickname)
+ "display_name" => HTML.strip_tags(moderator.name || moderator.nickname),
+ "confirmation_pending" => false
}
]
}
@@ -993,7 +1133,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"local" => user1.local,
"tags" => ["first"],
"avatar" => User.avatar_url(user1) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user1.name || user1.nickname)
+ "display_name" => HTML.strip_tags(user1.name || user1.nickname),
+ "confirmation_pending" => false
},
%{
"deactivated" => false,
@@ -1003,7 +1144,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"local" => user2.local,
"tags" => ["second"],
"avatar" => User.avatar_url(user2) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user2.name || user2.nickname)
+ "display_name" => HTML.strip_tags(user2.name || user2.nickname),
+ "confirmation_pending" => false
}
]
|> Enum.sort_by(& &1["nickname"])
@@ -1016,15 +1158,17 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "it works with multiple filters" do
- admin = insert(:user, nickname: "john", info: %{is_admin: true})
- user = insert(:user, nickname: "bob", local: false, info: %{deactivated: true})
+ admin = insert(:user, nickname: "john", is_admin: true)
+ token = insert(:oauth_admin_token, user: admin)
+ user = insert(:user, nickname: "bob", local: false, deactivated: true)
- insert(:user, nickname: "ken", local: true, info: %{deactivated: true})
- insert(:user, nickname: "bobb", local: false, info: %{deactivated: false})
+ insert(:user, nickname: "ken", local: true, deactivated: true)
+ insert(:user, nickname: "bobb", local: false, deactivated: false)
conn =
build_conn()
|> assign(:user, admin)
+ |> assign(:token, token)
|> get("/api/pleroma/admin/users?filters=deactivated,external")
assert json_response(conn, 200) == %{
@@ -1032,29 +1176,52 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"page_size" => 50,
"users" => [
%{
- "deactivated" => user.info.deactivated,
+ "deactivated" => user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => user.local,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
+ }
+ ]
+ }
+ end
+
+ test "it omits relay user", %{admin: admin, conn: conn} do
+ assert %User{} = Relay.get_actor()
+
+ conn = get(conn, "/api/pleroma/admin/users")
+
+ assert json_response(conn, 200) == %{
+ "count" => 1,
+ "page_size" => 50,
+ "users" => [
+ %{
+ "deactivated" => admin.deactivated,
+ "id" => admin.id,
+ "nickname" => admin.nickname,
+ "roles" => %{"admin" => true, "moderator" => false},
+ "local" => true,
+ "tags" => [],
+ "avatar" => User.avatar_url(admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname),
+ "confirmation_pending" => false
}
]
}
end
end
- test "PATCH /api/pleroma/admin/users/activate" do
- admin = insert(:user, info: %{is_admin: true})
- user_one = insert(:user, info: %{deactivated: true})
- user_two = insert(:user, info: %{deactivated: true})
+ test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do
+ user_one = insert(:user, deactivated: true)
+ user_two = insert(:user, deactivated: true)
conn =
- build_conn()
- |> assign(:user, admin)
- |> patch(
+ patch(
+ conn,
"/api/pleroma/admin/users/activate",
%{nicknames: [user_one.nickname, user_two.nickname]}
)
@@ -1068,15 +1235,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}"
end
- test "PATCH /api/pleroma/admin/users/deactivate" do
- admin = insert(:user, info: %{is_admin: true})
- user_one = insert(:user, info: %{deactivated: false})
- user_two = insert(:user, info: %{deactivated: false})
+ test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do
+ user_one = insert(:user, deactivated: false)
+ user_two = insert(:user, deactivated: false)
conn =
- build_conn()
- |> assign(:user, admin)
- |> patch(
+ patch(
+ conn,
"/api/pleroma/admin/users/deactivate",
%{nicknames: [user_one.nickname, user_two.nickname]}
)
@@ -1090,25 +1255,22 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}"
end
- test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation" do
- admin = insert(:user, info: %{is_admin: true})
+ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do
user = insert(:user)
- conn =
- build_conn()
- |> assign(:user, admin)
- |> patch("/api/pleroma/admin/users/#{user.nickname}/toggle_activation")
+ conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation")
assert json_response(conn, 200) ==
%{
- "deactivated" => !user.info.deactivated,
+ "deactivated" => !user.deactivated,
"id" => user.id,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
"tags" => [],
"avatar" => User.avatar_url(user) |> MediaProxy.url(),
- "display_name" => HTML.strip_tags(user.name || user.nickname)
+ "display_name" => HTML.strip_tags(user.name || user.nickname),
+ "confirmation_pending" => false
}
log_entry = Repo.one(ModerationLog)
@@ -1117,17 +1279,39 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} deactivated users: @#{user.nickname}"
end
- describe "POST /api/pleroma/admin/users/invite_token" do
- setup do
- admin = insert(:user, info: %{is_admin: true})
+ describe "PUT disable_mfa" do
+ test "returns 200 and disable 2fa", %{conn: conn} do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+ }
+ )
- conn =
- build_conn()
- |> assign(:user, admin)
+ response =
+ conn
+ |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname})
+ |> json_response(200)
- {:ok, conn: conn}
+ assert response == user.nickname
+ mfa_settings = refresh_record(user).multi_factor_authentication_settings
+
+ refute mfa_settings.enabled
+ refute mfa_settings.totp.confirmed
end
+ test "returns 404 if user not found", %{conn: conn} do
+ response =
+ conn
+ |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"})
+ |> json_response(404)
+
+ assert response == "Not found"
+ end
+ end
+
+ describe "POST /api/pleroma/admin/users/invite_token" do
test "without options", %{conn: conn} do
conn = post(conn, "/api/pleroma/admin/users/invite_token")
@@ -1182,16 +1366,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "GET /api/pleroma/admin/users/invites" do
- setup do
- admin = insert(:user, info: %{is_admin: true})
-
- conn =
- build_conn()
- |> assign(:user, admin)
-
- {:ok, conn: conn}
- end
-
test "no invites", %{conn: conn} do
conn = get(conn, "/api/pleroma/admin/users/invites")
@@ -1220,14 +1394,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
describe "POST /api/pleroma/admin/users/revoke_invite" do
- test "with token" do
- admin = insert(:user, info: %{is_admin: true})
+ test "with token", %{conn: conn} do
{:ok, invite} = UserInviteToken.create_invite()
- conn =
- build_conn()
- |> assign(:user, admin)
- |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token})
+ conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token})
assert json_response(conn, 200) == %{
"expires_at" => nil,
@@ -1240,34 +1410,23 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
}
end
- test "with invalid token" do
- admin = insert(:user, info: %{is_admin: true})
-
- conn =
- build_conn()
- |> assign(:user, admin)
- |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"})
+ test "with invalid token", %{conn: conn} do
+ conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"})
assert json_response(conn, :not_found) == "Not found"
end
end
describe "GET /api/pleroma/admin/reports/:id" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
-
- %{conn: assign(conn, :user, admin)}
- end
-
test "returns report by its id", %{conn: conn} do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
response =
@@ -1285,29 +1444,66 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
end
- describe "PUT /api/pleroma/admin/reports/:id" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ describe "PATCH /api/pleroma/admin/reports" do
+ setup do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
- %{conn: assign(conn, :user, admin), id: report_id, admin: admin}
+ {:ok, %{id: second_report_id}} =
+ CommonAPI.report(reporter, %{
+ account_id: target_user.id,
+ comment: "I feel very offended",
+ status_ids: [activity.id]
+ })
+
+ %{
+ id: report_id,
+ second_report_id: second_report_id
+ }
end
- test "mark report as resolved", %{conn: conn, id: id, admin: admin} do
+ test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do
+ read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"])
+ write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"])
+
response =
conn
- |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "resolved"})
- |> json_response(:ok)
+ |> assign(:token, read_token)
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [%{"state" => "resolved", "id" => id}]
+ })
+ |> json_response(403)
+
+ assert response == %{
+ "error" => "Insufficient permissions: admin:write:reports."
+ }
+
+ conn
+ |> assign(:token, write_token)
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [%{"state" => "resolved", "id" => id}]
+ })
+ |> json_response(:no_content)
+ end
- assert response["state"] == "resolved"
+ test "mark report as resolved", %{conn: conn, id: id, admin: admin} do
+ conn
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [
+ %{"state" => "resolved", "id" => id}
+ ]
+ })
+ |> json_response(:no_content)
+
+ activity = Activity.get_by_id(id)
+ assert activity.data["state"] == "resolved"
log_entry = Repo.one(ModerationLog)
@@ -1316,12 +1512,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "closes report", %{conn: conn, id: id, admin: admin} do
- response =
- conn
- |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "closed"})
- |> json_response(:ok)
+ conn
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [
+ %{"state" => "closed", "id" => id}
+ ]
+ })
+ |> json_response(:no_content)
- assert response["state"] == "closed"
+ activity = Activity.get_by_id(id)
+ assert activity.data["state"] == "closed"
log_entry = Repo.one(ModerationLog)
@@ -1332,27 +1532,58 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
test "returns 400 when state is unknown", %{conn: conn, id: id} do
conn =
conn
- |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "test"})
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [
+ %{"state" => "test", "id" => id}
+ ]
+ })
- assert json_response(conn, :bad_request) == "Unsupported state"
+ assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state"
end
test "returns 404 when report is not exist", %{conn: conn} do
conn =
conn
- |> put("/api/pleroma/admin/reports/test", %{"state" => "closed"})
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [
+ %{"state" => "closed", "id" => "test"}
+ ]
+ })
- assert json_response(conn, :not_found) == "Not found"
+ assert hd(json_response(conn, :bad_request))["error"] == "not_found"
end
- end
- describe "GET /api/pleroma/admin/reports" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ test "updates state of multiple reports", %{
+ conn: conn,
+ id: id,
+ admin: admin,
+ second_report_id: second_report_id
+ } do
+ conn
+ |> patch("/api/pleroma/admin/reports", %{
+ "reports" => [
+ %{"state" => "resolved", "id" => id},
+ %{"state" => "closed", "id" => second_report_id}
+ ]
+ })
+ |> json_response(:no_content)
+
+ activity = Activity.get_by_id(id)
+ second_activity = Activity.get_by_id(second_report_id)
+ assert activity.data["state"] == "resolved"
+ assert second_activity.data["state"] == "closed"
+
+ [first_log_entry, second_log_entry] = Repo.all(ModerationLog)
+
+ assert ModerationLog.get_log_entry_message(first_log_entry) ==
+ "@#{admin.nickname} updated report ##{id} with 'resolved' state"
- %{conn: assign(conn, :user, admin)}
+ assert ModerationLog.get_log_entry_message(second_log_entry) ==
+ "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state"
end
+ end
+ describe "GET /api/pleroma/admin/reports" do
test "returns empty response when no reports created", %{conn: conn} do
response =
conn
@@ -1369,9 +1600,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
response =
@@ -1393,15 +1624,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
{:ok, %{id: first_report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
{:ok, %{id: second_report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I don't like this user"
+ account_id: target_user.id,
+ comment: "I don't like this user"
})
CommonAPI.update_report_state(second_report_id, "closed")
@@ -1447,86 +1678,49 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
test "returns 403 when requested by a non-admin" do
user = insert(:user)
+ token = insert(:oauth_token, user: user)
conn =
build_conn()
|> assign(:user, user)
+ |> assign(:token, token)
|> get("/api/pleroma/admin/reports")
- assert json_response(conn, :forbidden) == %{"error" => "User is not admin."}
+ assert json_response(conn, :forbidden) ==
+ %{"error" => "User is not an admin or OAuth admin scope is not granted."}
end
test "returns 403 when requested by anonymous" do
- conn =
- build_conn()
- |> get("/api/pleroma/admin/reports")
+ conn = get(build_conn(), "/api/pleroma/admin/reports")
assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."}
end
end
- #
- describe "POST /api/pleroma/admin/reports/:id/respond" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
-
- %{conn: assign(conn, :user, admin), admin: admin}
+ describe "GET /api/pleroma/admin/statuses/:id" do
+ test "not found", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/admin/statuses/not_found")
+ |> json_response(:not_found)
end
- test "returns created dm", %{conn: conn, admin: admin} do
- [reporter, target_user] = insert_pair(:user)
- activity = insert(:note_activity, user: target_user)
-
- {:ok, %{id: report_id}} =
- CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
- })
+ test "shows activity", %{conn: conn} do
+ activity = insert(:note_activity)
response =
conn
- |> post("/api/pleroma/admin/reports/#{report_id}/respond", %{
- "status" => "I will check it out"
- })
- |> json_response(:ok)
-
- recipients = Enum.map(response["mentions"], & &1["username"])
+ |> get("/api/pleroma/admin/statuses/#{activity.id}")
+ |> json_response(200)
- assert reporter.nickname in recipients
- assert response["content"] == "I will check it out"
- assert response["visibility"] == "direct"
-
- log_entry = Repo.one(ModerationLog)
-
- assert ModerationLog.get_log_entry_message(log_entry) ==
- "@#{admin.nickname} responded with 'I will check it out' to report ##{
- response["id"]
- }"
- end
-
- test "returns 400 when status is missing", %{conn: conn} do
- conn = post(conn, "/api/pleroma/admin/reports/test/respond")
-
- assert json_response(conn, :bad_request) == "Invalid parameters"
- end
-
- test "returns 404 when report id is invalid", %{conn: conn} do
- conn =
- post(conn, "/api/pleroma/admin/reports/test/respond", %{
- "status" => "foo"
- })
-
- assert json_response(conn, :not_found) == "Not found"
+ assert response["id"] == activity.id
end
end
describe "PUT /api/pleroma/admin/statuses/:id" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ setup do
activity = insert(:note_activity)
- %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
+ %{id: activity.id}
end
test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do
@@ -1553,7 +1747,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
test "change visibility flag", %{conn: conn, id: id, admin: admin} do
response =
conn
- |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "public"})
+ |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "public"})
|> json_response(:ok)
assert response["visibility"] == "public"
@@ -1565,34 +1759,31 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
response =
conn
- |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "private"})
+ |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "private"})
|> json_response(:ok)
assert response["visibility"] == "private"
response =
conn
- |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "unlisted"})
+ |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "unlisted"})
|> json_response(:ok)
assert response["visibility"] == "unlisted"
end
test "returns 400 when visibility is unknown", %{conn: conn, id: id} do
- conn =
- conn
- |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "test"})
+ conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{visibility: "test"})
assert json_response(conn, :bad_request) == "Unsupported visibility"
end
end
describe "DELETE /api/pleroma/admin/statuses/:id" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ setup do
activity = insert(:note_activity)
- %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
+ %{id: activity.id}
end
test "deletes status", %{conn: conn, id: id, admin: admin} do
@@ -1608,41 +1799,39 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} deleted status ##{id}"
end
- test "returns error when status is not exist", %{conn: conn} do
- conn =
- conn
- |> delete("/api/pleroma/admin/statuses/test")
+ test "returns 404 when the status does not exist", %{conn: conn} do
+ conn = delete(conn, "/api/pleroma/admin/statuses/test")
- assert json_response(conn, :bad_request) == "Could not delete"
+ assert json_response(conn, :not_found) == "Not found"
end
end
describe "GET /api/pleroma/admin/config" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
-
- %{conn: assign(conn, :user, admin)}
- end
+ setup do: clear_config(:configurable_from_database, true)
- test "without any settings in db", %{conn: conn} do
+ test "when configuration from database is off", %{conn: conn} do
+ Config.put(:configurable_from_database, false)
conn = get(conn, "/api/pleroma/admin/config")
- assert json_response(conn, 200) == %{"configs" => []}
+ assert json_response(conn, 400) ==
+ "To use this endpoint you need to enable configuration from database."
end
- test "with settings in db", %{conn: conn} do
+ test "with settings only in db", %{conn: conn} do
config1 = insert(:config)
config2 = insert(:config)
- conn = get(conn, "/api/pleroma/admin/config")
+ conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true})
%{
"configs" => [
%{
+ "group" => ":pleroma",
"key" => key1,
"value" => _
},
%{
+ "group" => ":pleroma",
"key" => key2,
"value" => _
}
@@ -1652,13 +1841,107 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert key1 == config1.key
assert key2 == config2.key
end
+
+ test "db is added to settings that are in db", %{conn: conn} do
+ _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name"))
+
+ %{"configs" => configs} =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ [instance_config] =
+ Enum.filter(configs, fn %{"group" => group, "key" => key} ->
+ group == ":pleroma" and key == ":instance"
+ end)
+
+ assert instance_config["db"] == [":name"]
+ end
+
+ test "merged default setting with db settings", %{conn: conn} do
+ config1 = insert(:config)
+ config2 = insert(:config)
+
+ config3 =
+ insert(:config,
+ value: ConfigDB.to_binary(k1: :v1, k2: :v2)
+ )
+
+ %{"configs" => configs} =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ assert length(configs) > 3
+
+ received_configs =
+ Enum.filter(configs, fn %{"group" => group, "key" => key} ->
+ group == ":pleroma" and key in [config1.key, config2.key, config3.key]
+ end)
+
+ assert length(received_configs) == 3
+
+ db_keys =
+ config3.value
+ |> ConfigDB.from_binary()
+ |> Keyword.keys()
+ |> ConfigDB.convert()
+
+ Enum.each(received_configs, fn %{"value" => value, "db" => db} ->
+ assert db in [[config1.key], [config2.key], db_keys]
+
+ assert value in [
+ ConfigDB.from_binary_with_convert(config1.value),
+ ConfigDB.from_binary_with_convert(config2.value),
+ ConfigDB.from_binary_with_convert(config3.value)
+ ]
+ end)
+ end
+
+ test "subkeys with full update right merge", %{conn: conn} do
+ config1 =
+ insert(:config,
+ key: ":emoji",
+ value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1])
+ )
+
+ config2 =
+ insert(:config,
+ key: ":assets",
+ value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1])
+ )
+
+ %{"configs" => configs} =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ vals =
+ Enum.filter(configs, fn %{"group" => group, "key" => key} ->
+ group == ":pleroma" and key in [config1.key, config2.key]
+ end)
+
+ emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end)
+ assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end)
+
+ emoji_val = ConfigDB.transform_with_out_binary(emoji["value"])
+ assets_val = ConfigDB.transform_with_out_binary(assets["value"])
+
+ assert emoji_val[:groups] == [a: 1, b: 2]
+ assert assets_val[:mascots] == [a: 1, b: 2]
+ end
end
- describe "POST /api/pleroma/admin/config" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ test "POST /api/pleroma/admin/config error", %{conn: conn} do
+ conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []})
- temp_file = "config/test.exported_from_db.secret.exs"
+ assert json_response(conn, 400) ==
+ "To use this endpoint you need to enable configuration from database."
+ end
+
+ describe "POST /api/pleroma/admin/config" do
+ setup do
+ http = Application.get_env(:pleroma, :http)
on_exit(fn ->
Application.delete_env(:pleroma, :key1)
@@ -1669,29 +1952,31 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
Application.delete_env(:pleroma, :keyaa2)
Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal)
Application.delete_env(:pleroma, Pleroma.Captcha.NotReal)
- :ok = File.rm(temp_file)
+ Application.put_env(:pleroma, :http, http)
+ Application.put_env(:tesla, :adapter, Tesla.Mock)
+ Restarter.Pleroma.refresh()
end)
-
- %{conn: assign(conn, :user, admin)}
end
- clear_config([:instance, :dynamic_configuration]) do
- Pleroma.Config.put([:instance, :dynamic_configuration], true)
- end
+ setup do: clear_config(:configurable_from_database, true)
+ @tag capture_log: true
test "create new config setting in db", %{conn: conn} do
+ ueberauth = Application.get_env(:ueberauth, Ueberauth)
+ on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end)
+
conn =
post(conn, "/api/pleroma/admin/config", %{
configs: [
- %{group: "pleroma", key: "key1", value: "value1"},
+ %{group: ":pleroma", key: ":key1", value: "value1"},
%{
- group: "ueberauth",
- key: "Ueberauth.Strategy.Twitter.OAuth",
+ group: ":ueberauth",
+ key: "Ueberauth",
value: [%{"tuple" => [":consumer_secret", "aaaa"]}]
},
%{
- group: "pleroma",
- key: "key2",
+ group: ":pleroma",
+ key: ":key2",
value: %{
":nested_1" => "nested_value1",
":nested_2" => [
@@ -1701,21 +1986,21 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
}
},
%{
- group: "pleroma",
- key: "key3",
+ group: ":pleroma",
+ key: ":key3",
value: [
%{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
%{"nested_4" => true}
]
},
%{
- group: "pleroma",
- key: "key4",
+ group: ":pleroma",
+ key: ":key4",
value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"}
},
%{
- group: "idna",
- key: "key5",
+ group: ":idna",
+ key: ":key5",
value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}
}
]
@@ -1724,43 +2009,49 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert json_response(conn, 200) == %{
"configs" => [
%{
- "group" => "pleroma",
- "key" => "key1",
- "value" => "value1"
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => "value1",
+ "db" => [":key1"]
},
%{
- "group" => "ueberauth",
- "key" => "Ueberauth.Strategy.Twitter.OAuth",
- "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}]
+ "group" => ":ueberauth",
+ "key" => "Ueberauth",
+ "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}],
+ "db" => [":consumer_secret"]
},
%{
- "group" => "pleroma",
- "key" => "key2",
+ "group" => ":pleroma",
+ "key" => ":key2",
"value" => %{
":nested_1" => "nested_value1",
":nested_2" => [
%{":nested_22" => "nested_value222"},
%{":nested_33" => %{":nested_44" => "nested_444"}}
]
- }
+ },
+ "db" => [":key2"]
},
%{
- "group" => "pleroma",
- "key" => "key3",
+ "group" => ":pleroma",
+ "key" => ":key3",
"value" => [
%{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
%{"nested_4" => true}
- ]
+ ],
+ "db" => [":key3"]
},
%{
- "group" => "pleroma",
- "key" => "key4",
- "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}
+ "group" => ":pleroma",
+ "key" => ":key4",
+ "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"},
+ "db" => [":key4"]
},
%{
- "group" => "idna",
- "key" => "key5",
- "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}
+ "group" => ":idna",
+ "key" => ":key5",
+ "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]},
+ "db" => [":key5"]
}
]
}
@@ -1788,25 +2079,307 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []}
end
- test "update config setting & delete", %{conn: conn} do
- config1 = insert(:config, key: "keyaa1")
- config2 = insert(:config, key: "keyaa2")
+ test "save configs setting without explicit key", %{conn: conn} do
+ level = Application.get_env(:quack, :level)
+ meta = Application.get_env(:quack, :meta)
+ webhook_url = Application.get_env(:quack, :webhook_url)
- insert(:config,
- group: "ueberauth",
- key: "Ueberauth.Strategy.Microsoft.OAuth",
- value: :erlang.term_to_binary([])
- )
+ on_exit(fn ->
+ Application.put_env(:quack, :level, level)
+ Application.put_env(:quack, :meta, meta)
+ Application.put_env(:quack, :webhook_url, webhook_url)
+ end)
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: ":quack",
+ key: ":level",
+ value: ":info"
+ },
+ %{
+ group: ":quack",
+ key: ":meta",
+ value: [":none"]
+ },
+ %{
+ group: ":quack",
+ key: ":webhook_url",
+ value: "https://hooks.slack.com/services/KEY"
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":quack",
+ "key" => ":level",
+ "value" => ":info",
+ "db" => [":level"]
+ },
+ %{
+ "group" => ":quack",
+ "key" => ":meta",
+ "value" => [":none"],
+ "db" => [":meta"]
+ },
+ %{
+ "group" => ":quack",
+ "key" => ":webhook_url",
+ "value" => "https://hooks.slack.com/services/KEY",
+ "db" => [":webhook_url"]
+ }
+ ]
+ }
+
+ assert Application.get_env(:quack, :level) == :info
+ assert Application.get_env(:quack, :meta) == [:none]
+ assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY"
+ end
+
+ test "saving config with partial update", %{conn: conn} do
+ config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2))
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]}
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{"tuple" => [":key1", 1]},
+ %{"tuple" => [":key2", 2]},
+ %{"tuple" => [":key3", 3]}
+ ],
+ "db" => [":key1", ":key2", ":key3"]
+ }
+ ]
+ }
+ end
+
+ test "saving config which need pleroma reboot", %{conn: conn} do
+ chat = Config.get(:chat)
+ on_exit(fn -> Config.put(:chat, chat) end)
+
+ assert post(
+ conn,
+ "/api/pleroma/admin/config",
+ %{
+ configs: [
+ %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]}
+ ]
+ }
+ )
+ |> json_response(200) == %{
+ "configs" => [
+ %{
+ "db" => [":enabled"],
+ "group" => ":pleroma",
+ "key" => ":chat",
+ "value" => [%{"tuple" => [":enabled", true]}]
+ }
+ ],
+ "need_reboot" => true
+ }
+
+ configs =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ assert configs["need_reboot"]
+
+ capture_log(fn ->
+ assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{}
+ end) =~ "pleroma restarted"
+
+ configs =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ assert configs["need_reboot"] == false
+ end
+
+ test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do
+ chat = Config.get(:chat)
+ on_exit(fn -> Config.put(:chat, chat) end)
+
+ assert post(
+ conn,
+ "/api/pleroma/admin/config",
+ %{
+ configs: [
+ %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]}
+ ]
+ }
+ )
+ |> json_response(200) == %{
+ "configs" => [
+ %{
+ "db" => [":enabled"],
+ "group" => ":pleroma",
+ "key" => ":chat",
+ "value" => [%{"tuple" => [":enabled", true]}]
+ }
+ ],
+ "need_reboot" => true
+ }
+
+ assert post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]}
+ ]
+ })
+ |> json_response(200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{"tuple" => [":key3", 3]}
+ ],
+ "db" => [":key3"]
+ }
+ ],
+ "need_reboot" => true
+ }
+
+ capture_log(fn ->
+ assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{}
+ end) =~ "pleroma restarted"
+
+ configs =
+ conn
+ |> get("/api/pleroma/admin/config")
+ |> json_response(200)
+
+ assert configs["need_reboot"] == false
+ end
+
+ test "saving config with nested merge", %{conn: conn} do
+ config =
+ insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2]))
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: config.group,
+ key: config.key,
+ value: [
+ %{"tuple" => [":key3", 3]},
+ %{
+ "tuple" => [
+ ":key2",
+ [
+ %{"tuple" => [":k2", 1]},
+ %{"tuple" => [":k3", 3]}
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{"tuple" => [":key1", 1]},
+ %{"tuple" => [":key3", 3]},
+ %{
+ "tuple" => [
+ ":key2",
+ [
+ %{"tuple" => [":k1", 1]},
+ %{"tuple" => [":k2", 1]},
+ %{"tuple" => [":k3", 3]}
+ ]
+ ]
+ }
+ ],
+ "db" => [":key1", ":key3", ":key2"]
+ }
+ ]
+ }
+ end
+
+ test "saving special atoms", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{
+ "tuple" => [
+ ":ssl_options",
+ [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}]
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{
+ "tuple" => [
+ ":ssl_options",
+ [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}]
+ ]
+ }
+ ],
+ "db" => [":ssl_options"]
+ }
+ ]
+ }
+
+ assert Application.get_env(:pleroma, :key1) == [
+ ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]]
+ ]
+ end
+
+ test "saving full setting if value is in full_key_update list", %{conn: conn} do
+ backends = Application.get_env(:logger, :backends)
+ on_exit(fn -> Application.put_env(:logger, :backends, backends) end)
+
+ config =
+ insert(:config,
+ group: ":logger",
+ key: ":backends",
+ value: :erlang.term_to_binary([])
+ )
+
+ Pleroma.Config.TransferTask.load_and_update_env([], false)
+
+ assert Application.get_env(:logger, :backends) == []
conn =
post(conn, "/api/pleroma/admin/config", %{
configs: [
- %{group: config1.group, key: config1.key, value: "another_value"},
- %{group: config2.group, key: config2.key, delete: "true"},
%{
- group: "ueberauth",
- key: "Ueberauth.Strategy.Microsoft.OAuth",
- delete: "true"
+ group: config.group,
+ key: config.key,
+ value: [":console"]
}
]
})
@@ -1814,15 +2387,113 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert json_response(conn, 200) == %{
"configs" => [
%{
- "group" => "pleroma",
+ "group" => ":logger",
+ "key" => ":backends",
+ "value" => [
+ ":console"
+ ],
+ "db" => [":backends"]
+ }
+ ]
+ }
+
+ assert Application.get_env(:logger, :backends) == [
+ :console
+ ]
+ end
+
+ test "saving full setting if value is not keyword", %{conn: conn} do
+ config =
+ insert(:config,
+ group: ":tesla",
+ key: ":adapter",
+ value: :erlang.term_to_binary(Tesla.Adapter.Hackey)
+ )
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"}
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":tesla",
+ "key" => ":adapter",
+ "value" => "Tesla.Adapter.Httpc",
+ "db" => [":adapter"]
+ }
+ ]
+ }
+ end
+
+ test "update config setting & delete with fallback to default value", %{
+ conn: conn,
+ admin: admin,
+ token: token
+ } do
+ ueberauth = Application.get_env(:ueberauth, Ueberauth)
+ config1 = insert(:config, key: ":keyaa1")
+ config2 = insert(:config, key: ":keyaa2")
+
+ config3 =
+ insert(:config,
+ group: ":ueberauth",
+ key: "Ueberauth"
+ )
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: config1.group, key: config1.key, value: "another_value"},
+ %{group: config2.group, key: config2.key, value: "another_value"}
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
"key" => config1.key,
- "value" => "another_value"
+ "value" => "another_value",
+ "db" => [":keyaa1"]
+ },
+ %{
+ "group" => ":pleroma",
+ "key" => config2.key,
+ "value" => "another_value",
+ "db" => [":keyaa2"]
}
]
}
assert Application.get_env(:pleroma, :keyaa1) == "another_value"
- refute Application.get_env(:pleroma, :keyaa2)
+ assert Application.get_env(:pleroma, :keyaa2) == "another_value"
+ assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, token)
+ |> post("/api/pleroma/admin/config", %{
+ configs: [
+ %{group: config2.group, key: config2.key, delete: true},
+ %{
+ group: ":ueberauth",
+ key: "Ueberauth",
+ delete: true
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => []
+ }
+
+ assert Application.get_env(:ueberauth, Ueberauth) == ueberauth
+ refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2)
end
test "common config example", %{conn: conn} do
@@ -1830,7 +2501,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
post(conn, "/api/pleroma/admin/config", %{
configs: [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => "Pleroma.Captcha.NotReal",
"value" => [
%{"tuple" => [":enabled", false]},
@@ -1842,16 +2513,19 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
%{"tuple" => [":regex1", "~r/https:\/\/example.com/"]},
%{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]},
%{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]},
- %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}
+ %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]},
+ %{"tuple" => [":name", "Pleroma"]}
]
}
]
})
+ assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma"
+
assert json_response(conn, 200) == %{
"configs" => [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => "Pleroma.Captcha.NotReal",
"value" => [
%{"tuple" => [":enabled", false]},
@@ -1863,7 +2537,21 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
%{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]},
%{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]},
%{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]},
- %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}
+ %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]},
+ %{"tuple" => [":name", "Pleroma"]}
+ ],
+ "db" => [
+ ":enabled",
+ ":method",
+ ":seconds_valid",
+ ":path",
+ ":key1",
+ ":partial_chain",
+ ":regex1",
+ ":regex2",
+ ":regex3",
+ ":regex4",
+ ":name"
]
}
]
@@ -1875,7 +2563,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
post(conn, "/api/pleroma/admin/config", %{
configs: [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => "Pleroma.Web.Endpoint.NotReal",
"value" => [
%{
@@ -1939,7 +2627,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert json_response(conn, 200) == %{
"configs" => [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => "Pleroma.Web.Endpoint.NotReal",
"value" => [
%{
@@ -1995,7 +2683,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
]
]
}
- ]
+ ],
+ "db" => [":http"]
}
]
}
@@ -2006,7 +2695,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
post(conn, "/api/pleroma/admin/config", %{
configs: [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key2", "some_val"]},
@@ -2036,7 +2725,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
%{
"configs" => [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key2", "some_val"]},
@@ -2057,7 +2746,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
}
]
}
- ]
+ ],
+ "db" => [":key2", ":key3"]
}
]
}
@@ -2068,7 +2758,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
post(conn, "/api/pleroma/admin/config", %{
configs: [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => ":key1",
"value" => %{"key" => "some_val"}
}
@@ -2079,83 +2769,21 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
%{
"configs" => [
%{
- "group" => "pleroma",
+ "group" => ":pleroma",
"key" => ":key1",
- "value" => %{"key" => "some_val"}
+ "value" => %{"key" => "some_val"},
+ "db" => [":key1"]
}
]
}
end
- test "dispatch setting", %{conn: conn} do
- conn =
- post(conn, "/api/pleroma/admin/config", %{
- configs: [
- %{
- "group" => "pleroma",
- "key" => "Pleroma.Web.Endpoint.NotReal",
- "value" => [
- %{
- "tuple" => [
- ":http",
- [
- %{"tuple" => [":ip", %{"tuple" => [127, 0, 0, 1]}]},
- %{"tuple" => [":dispatch", ["{:_,
- [
- {\"/api/v1/streaming\", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {\"/websocket\", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: \"/websocket\"]}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]}"]]}
- ]
- ]
- }
- ]
- }
- ]
- })
-
- dispatch_string =
- "{:_, [{\"/api/v1/streaming\", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, " <>
- "{\"/websocket\", Phoenix.Endpoint.CowboyWebSocket, {Phoenix.Transports.WebSocket, " <>
- "{Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: \"/websocket\"]}}}, " <>
- "{:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}]}"
-
- assert json_response(conn, 200) == %{
- "configs" => [
- %{
- "group" => "pleroma",
- "key" => "Pleroma.Web.Endpoint.NotReal",
- "value" => [
- %{
- "tuple" => [
- ":http",
- [
- %{"tuple" => [":ip", %{"tuple" => [127, 0, 0, 1]}]},
- %{
- "tuple" => [
- ":dispatch",
- [
- dispatch_string
- ]
- ]
- }
- ]
- ]
- }
- ]
- }
- ]
- }
- end
-
test "queues key as atom", %{conn: conn} do
conn =
post(conn, "/api/pleroma/admin/config", %{
configs: [
%{
- "group" => "oban",
+ "group" => ":oban",
"key" => ":queues",
"value" => [
%{"tuple" => [":federator_incoming", 50]},
@@ -2173,7 +2801,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
assert json_response(conn, 200) == %{
"configs" => [
%{
- "group" => "oban",
+ "group" => ":oban",
"key" => ":queues",
"value" => [
%{"tuple" => [":federator_incoming", 50]},
@@ -2183,6 +2811,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
%{"tuple" => [":transmogrifier", 20]},
%{"tuple" => [":scheduled_activities", 10]},
%{"tuple" => [":background", 5]}
+ ],
+ "db" => [
+ ":federator_incoming",
+ ":federator_outgoing",
+ ":web_push",
+ ":mailer",
+ ":transmogrifier",
+ ":scheduled_activities",
+ ":background"
]
}
]
@@ -2192,7 +2829,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
test "delete part of settings by atom subkeys", %{conn: conn} do
config =
insert(:config,
- key: "keyaa1",
+ key: ":keyaa1",
value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3")
)
@@ -2203,64 +2840,214 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
group: config.group,
key: config.key,
subkeys: [":subkey1", ":subkey3"],
- delete: "true"
+ delete: true
}
]
})
- assert(
- json_response(conn, 200) == %{
- "configs" => [
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":keyaa1",
+ "value" => [%{"tuple" => [":subkey2", "val2"]}],
+ "db" => [":subkey2"]
+ }
+ ]
+ }
+ end
+
+ test "proxy tuple localhost", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
%{
- "group" => "pleroma",
- "key" => "keyaa1",
- "value" => [%{"tuple" => [":subkey2", "val2"]}]
+ group: ":pleroma",
+ key: ":http",
+ value: [
+ %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}
+ ]
}
]
- }
- )
+ })
+
+ assert %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":http",
+ "value" => value,
+ "db" => db
+ }
+ ]
+ } = json_response(conn, 200)
+
+ assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value
+ assert ":proxy_url" in db
+ end
+
+ test "proxy tuple domain", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: ":pleroma",
+ key: ":http",
+ value: [
+ %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}
+ ]
+ }
+ ]
+ })
+
+ assert %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":http",
+ "value" => value,
+ "db" => db
+ }
+ ]
+ } = json_response(conn, 200)
+
+ assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value
+ assert ":proxy_url" in db
+ end
+
+ test "proxy tuple ip", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: ":pleroma",
+ key: ":http",
+ value: [
+ %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}
+ ]
+ }
+ ]
+ })
+
+ assert %{
+ "configs" => [
+ %{
+ "group" => ":pleroma",
+ "key" => ":http",
+ "value" => value,
+ "db" => db
+ }
+ ]
+ } = json_response(conn, 200)
+
+ assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value
+ assert ":proxy_url" in db
+ end
+
+ test "doesn't set keys not in the whitelist", %{conn: conn} do
+ clear_config(:database_config_whitelist, [
+ {:pleroma, :key1},
+ {:pleroma, :key2},
+ {:pleroma, Pleroma.Captcha.NotReal},
+ {:not_real}
+ ])
+
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: ":pleroma", key: ":key1", value: "value1"},
+ %{group: ":pleroma", key: ":key2", value: "value2"},
+ %{group: ":pleroma", key: ":key3", value: "value3"},
+ %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"},
+ %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"},
+ %{group: ":not_real", key: ":anything", value: "value6"}
+ ]
+ })
+
+ assert Application.get_env(:pleroma, :key1) == "value1"
+ assert Application.get_env(:pleroma, :key2) == "value2"
+ assert Application.get_env(:pleroma, :key3) == nil
+ assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil
+ assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5"
+ assert Application.get_env(:not_real, :anything) == "value6"
end
end
- describe "config mix tasks run" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ describe "GET /api/pleroma/admin/restart" do
+ setup do: clear_config(:configurable_from_database, true)
- temp_file = "config/test.exported_from_db.secret.exs"
+ test "pleroma restarts", %{conn: conn} do
+ capture_log(fn ->
+ assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{}
+ end) =~ "pleroma restarted"
- Mix.shell(Mix.Shell.Quiet)
+ refute Restarter.Pleroma.need_reboot?()
+ end
+ end
- on_exit(fn ->
- Mix.shell(Mix.Shell.IO)
- :ok = File.rm(temp_file)
- end)
+ test "need_reboot flag", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/admin/need_reboot")
+ |> json_response(200) == %{"need_reboot" => false}
+
+ Restarter.Pleroma.need_reboot()
- %{conn: assign(conn, :user, admin), admin: admin}
+ assert conn
+ |> get("/api/pleroma/admin/need_reboot")
+ |> json_response(200) == %{"need_reboot" => true}
+
+ on_exit(fn -> Restarter.Pleroma.refresh() end)
+ end
+
+ describe "GET /api/pleroma/admin/statuses" do
+ test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do
+ blocked = insert(:user)
+ user = insert(:user)
+ User.block(admin, blocked)
+
+ {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"})
+
+ {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
+ {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"})
+ {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"})
+ {:ok, _} = CommonAPI.post(blocked, %{status: ".", visibility: "public"})
+
+ response =
+ conn
+ |> get("/api/pleroma/admin/statuses")
+ |> json_response(200)
+
+ refute "private" in Enum.map(response, & &1["visibility"])
+ assert length(response) == 3
end
- clear_config([:instance, :dynamic_configuration]) do
- Pleroma.Config.put([:instance, :dynamic_configuration], true)
+ test "returns only local statuses with local_only on", %{conn: conn} do
+ user = insert(:user)
+ remote_user = insert(:user, local: false, nickname: "archaeme@archae.me")
+ insert(:note_activity, user: user, local: true)
+ insert(:note_activity, user: remote_user, local: false)
+
+ response =
+ conn
+ |> get("/api/pleroma/admin/statuses?local_only=true")
+ |> json_response(200)
+
+ assert length(response) == 1
end
- test "transfer settings to DB and to file", %{conn: conn, admin: admin} do
- assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == []
- conn = get(conn, "/api/pleroma/admin/config/migrate_to_db")
- assert json_response(conn, 200) == %{}
- assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) > 0
+ test "returns private and direct statuses with godmode on", %{conn: conn, admin: admin} do
+ user = insert(:user)
- conn =
- build_conn()
- |> assign(:user, admin)
- |> get("/api/pleroma/admin/config/migrate_from_db")
+ {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"})
- assert json_response(conn, 200) == %{}
- assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == []
+ {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"})
+ {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"})
+ conn = get(conn, "/api/pleroma/admin/statuses?godmode=true")
+ assert json_response(conn, 200) |> length() == 3
end
end
describe "GET /api/pleroma/admin/users/:nickname/statuses" do
setup do
- admin = insert(:user, info: %{is_admin: true})
user = insert(:user)
date1 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!()
@@ -2271,11 +3058,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
insert(:note_activity, user: user, published: date2)
insert(:note_activity, user: user, published: date3)
- conn =
- build_conn()
- |> assign(:user, admin)
-
- {:ok, conn: conn, user: user}
+ %{user: user}
end
test "renders user's statuses", %{conn: conn, user: user} do
@@ -2291,11 +3074,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "doesn't return private statuses by default", %{conn: conn, user: user} do
- {:ok, _private_status} =
- CommonAPI.post(user, %{"status" => "private", "visibility" => "private"})
+ {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"})
- {:ok, _public_status} =
- CommonAPI.post(user, %{"status" => "public", "visibility" => "public"})
+ {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"})
conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses")
@@ -2303,24 +3084,35 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
test "returns private statuses with godmode on", %{conn: conn, user: user} do
- {:ok, _private_status} =
- CommonAPI.post(user, %{"status" => "private", "visibility" => "private"})
+ {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"})
- {:ok, _public_status} =
- CommonAPI.post(user, %{"status" => "public", "visibility" => "public"})
+ {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"})
conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?godmode=true")
assert json_response(conn, 200) |> length() == 5
end
+
+ test "excludes reblogs by default", %{conn: conn, user: user} do
+ other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "."})
+ {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, other_user)
+
+ conn_res = get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses")
+ assert json_response(conn_res, 200) |> length() == 0
+
+ conn_res =
+ get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses?with_reblogs=true")
+
+ assert json_response(conn_res, 200) |> length() == 1
+ end
end
describe "GET /api/pleroma/admin/moderation_log" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
- moderator = insert(:user, info: %{is_moderator: true})
+ setup do
+ moderator = insert(:user, is_moderator: true)
- %{conn: assign(conn, :user, admin), admin: admin, moderator: moderator}
+ %{moderator: moderator}
end
test "returns the log", %{conn: conn, admin: admin} do
@@ -2524,42 +3316,95 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
end
end
- describe "PATCH /users/:nickname/force_password_reset" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ describe "GET /users/:nickname/credentials" do
+ test "gets the user credentials", %{conn: conn} do
user = insert(:user)
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials")
- %{conn: assign(conn, :user, admin), admin: admin, user: user}
+ response = assert json_response(conn, 200)
+ assert response["email"] == user.email
end
- test "sets password_reset_pending to true", %{admin: admin, user: user} do
- assert user.info.password_reset_pending == false
+ test "returns 403 if requested by a non-admin" do
+ user = insert(:user)
conn =
build_conn()
- |> assign(:user, admin)
- |> patch("/api/pleroma/admin/users/#{user.nickname}/force_password_reset")
+ |> assign(:user, user)
+ |> get("/api/pleroma/admin/users/#{user.nickname}/credentials")
- assert json_response(conn, 204) == ""
+ assert json_response(conn, :forbidden)
+ end
+ end
+
+ describe "PATCH /users/:nickname/credentials" do
+ test "changes password and email", %{conn: conn, admin: admin} do
+ user = insert(:user)
+ assert user.password_reset_pending == false
+
+ conn =
+ patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{
+ "password" => "new_password",
+ "email" => "new_email@example.com",
+ "name" => "new_name"
+ })
+
+ assert json_response(conn, 200) == %{"status" => "success"}
ObanHelpers.perform_all()
- assert User.get_by_id(user.id).info.password_reset_pending == true
+ updated_user = User.get_by_id(user.id)
+
+ assert updated_user.email == "new_email@example.com"
+ assert updated_user.name == "new_name"
+ assert updated_user.password_hash != user.password_hash
+ assert updated_user.password_reset_pending == true
+
+ [log_entry2, log_entry1] = ModerationLog |> Repo.all() |> Enum.sort()
+
+ assert ModerationLog.get_log_entry_message(log_entry1) ==
+ "@#{admin.nickname} updated users: @#{user.nickname}"
+
+ assert ModerationLog.get_log_entry_message(log_entry2) ==
+ "@#{admin.nickname} forced password reset for users: @#{user.nickname}"
+ end
+
+ test "returns 403 if requested by a non-admin" do
+ user = insert(:user)
+
+ conn =
+ build_conn()
+ |> assign(:user, user)
+ |> patch("/api/pleroma/admin/users/#{user.nickname}/credentials", %{
+ "password" => "new_password",
+ "email" => "new_email@example.com",
+ "name" => "new_name"
+ })
+
+ assert json_response(conn, :forbidden)
end
end
- describe "relays" do
- setup %{conn: conn} do
- admin = insert(:user, info: %{is_admin: true})
+ describe "PATCH /users/:nickname/force_password_reset" do
+ test "sets password_reset_pending to true", %{conn: conn} do
+ user = insert(:user)
+ assert user.password_reset_pending == false
+
+ conn =
+ patch(conn, "/api/pleroma/admin/users/force_password_reset", %{nicknames: [user.nickname]})
- %{conn: assign(conn, :user, admin), admin: admin}
+ assert json_response(conn, 204) == ""
+
+ ObanHelpers.perform_all()
+
+ assert User.get_by_id(user.id).password_reset_pending == true
end
+ end
- test "POST /relay", %{admin: admin} do
+ describe "relays" do
+ test "POST /relay", %{conn: conn, admin: admin} do
conn =
- build_conn()
- |> assign(:user, admin)
- |> post("/api/pleroma/admin/relay", %{
+ post(conn, "/api/pleroma/admin/relay", %{
relay_url: "http://mastodon.example.org/users/admin"
})
@@ -2571,36 +3416,27 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin"
end
- test "GET /relay", %{admin: admin} do
- Pleroma.Web.ActivityPub.Relay.get_actor()
- |> Ecto.Changeset.change(
- following: [
- "http://test-app.com/user/test1",
- "http://test-app.com/user/test1",
- "http://test-app-42.com/user/test1"
- ]
- )
- |> Pleroma.User.update_and_set_cache()
+ test "GET /relay", %{conn: conn} do
+ relay_user = Pleroma.Web.ActivityPub.Relay.get_actor()
- conn =
- build_conn()
- |> assign(:user, admin)
- |> get("/api/pleroma/admin/relay")
+ ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"]
+ |> Enum.each(fn ap_id ->
+ {:ok, user} = User.get_or_fetch_by_ap_id(ap_id)
+ User.follow(relay_user, user)
+ end)
+
+ conn = get(conn, "/api/pleroma/admin/relay")
- assert json_response(conn, 200)["relays"] -- ["test-app.com", "test-app-42.com"] == []
+ assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == []
end
- test "DELETE /relay", %{admin: admin} do
- build_conn()
- |> assign(:user, admin)
- |> post("/api/pleroma/admin/relay", %{
+ test "DELETE /relay", %{conn: conn, admin: admin} do
+ post(conn, "/api/pleroma/admin/relay", %{
relay_url: "http://mastodon.example.org/users/admin"
})
conn =
- build_conn()
- |> assign(:user, admin)
- |> delete("/api/pleroma/admin/relay", %{
+ delete(conn, "/api/pleroma/admin/relay", %{
relay_url: "http://mastodon.example.org/users/admin"
})
@@ -2615,6 +3451,409 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
"@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin"
end
end
+
+ describe "instances" do
+ test "GET /instances/:instance/statuses", %{conn: conn} do
+ user = insert(:user, local: false, nickname: "archaeme@archae.me")
+ user2 = insert(:user, local: false, nickname: "test@test.com")
+ insert_pair(:note_activity, user: user)
+ activity = insert(:note_activity, user: user2)
+
+ ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses")
+
+ response = json_response(ret_conn, 200)
+
+ assert length(response) == 2
+
+ ret_conn = get(conn, "/api/pleroma/admin/instances/test.com/statuses")
+
+ response = json_response(ret_conn, 200)
+
+ assert length(response) == 1
+
+ ret_conn = get(conn, "/api/pleroma/admin/instances/nonexistent.com/statuses")
+
+ response = json_response(ret_conn, 200)
+
+ assert Enum.empty?(response)
+
+ CommonAPI.repeat(activity.id, user)
+
+ ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses")
+ response = json_response(ret_conn, 200)
+ assert length(response) == 2
+
+ ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses?with_reblogs=true")
+ response = json_response(ret_conn, 200)
+ assert length(response) == 3
+ end
+ end
+
+ describe "PATCH /confirm_email" do
+ test "it confirms emails of two users", %{conn: conn, admin: admin} do
+ [first_user, second_user] = insert_pair(:user, confirmation_pending: true)
+
+ assert first_user.confirmation_pending == true
+ assert second_user.confirmation_pending == true
+
+ ret_conn =
+ patch(conn, "/api/pleroma/admin/users/confirm_email", %{
+ nicknames: [
+ first_user.nickname,
+ second_user.nickname
+ ]
+ })
+
+ assert ret_conn.status == 200
+
+ assert first_user.confirmation_pending == true
+ assert second_user.confirmation_pending == true
+
+ log_entry = Repo.one(ModerationLog)
+
+ assert ModerationLog.get_log_entry_message(log_entry) ==
+ "@#{admin.nickname} confirmed email for users: @#{first_user.nickname}, @#{
+ second_user.nickname
+ }"
+ end
+ end
+
+ describe "PATCH /resend_confirmation_email" do
+ test "it resend emails for two users", %{conn: conn, admin: admin} do
+ [first_user, second_user] = insert_pair(:user, confirmation_pending: true)
+
+ ret_conn =
+ patch(conn, "/api/pleroma/admin/users/resend_confirmation_email", %{
+ nicknames: [
+ first_user.nickname,
+ second_user.nickname
+ ]
+ })
+
+ assert ret_conn.status == 200
+
+ log_entry = Repo.one(ModerationLog)
+
+ assert ModerationLog.get_log_entry_message(log_entry) ==
+ "@#{admin.nickname} re-sent confirmation email for users: @#{first_user.nickname}, @#{
+ second_user.nickname
+ }"
+ end
+ end
+
+ describe "POST /reports/:id/notes" do
+ setup %{conn: conn, admin: admin} do
+ [reporter, target_user] = insert_pair(:user)
+ activity = insert(:note_activity, user: target_user)
+
+ {:ok, %{id: report_id}} =
+ CommonAPI.report(reporter, %{
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
+ })
+
+ post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{
+ content: "this is disgusting!"
+ })
+
+ post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{
+ content: "this is disgusting2!"
+ })
+
+ %{
+ admin_id: admin.id,
+ report_id: report_id
+ }
+ end
+
+ test "it creates report note", %{admin_id: admin_id, report_id: report_id} do
+ [note, _] = Repo.all(ReportNote)
+
+ assert %{
+ activity_id: ^report_id,
+ content: "this is disgusting!",
+ user_id: ^admin_id
+ } = note
+ end
+
+ test "it returns reports with notes", %{conn: conn, admin: admin} do
+ conn = get(conn, "/api/pleroma/admin/reports")
+
+ response = json_response(conn, 200)
+ notes = hd(response["reports"])["notes"]
+ [note, _] = notes
+
+ assert note["user"]["nickname"] == admin.nickname
+ assert note["content"] == "this is disgusting!"
+ assert note["created_at"]
+ assert response["total"] == 1
+ end
+
+ test "it deletes the note", %{conn: conn, report_id: report_id} do
+ assert ReportNote |> Repo.all() |> length() == 2
+
+ [note, _] = Repo.all(ReportNote)
+
+ delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}")
+
+ assert ReportNote |> Repo.all() |> length() == 1
+ end
+ end
+
+ describe "GET /api/pleroma/admin/config/descriptions" do
+ test "structure", %{conn: conn} do
+ admin = insert(:user, is_admin: true)
+
+ conn =
+ assign(conn, :user, admin)
+ |> get("/api/pleroma/admin/config/descriptions")
+
+ assert [child | _others] = json_response(conn, 200)
+
+ assert child["children"]
+ assert child["key"]
+ assert String.starts_with?(child["group"], ":")
+ assert child["description"]
+ end
+
+ test "filters by database configuration whitelist", %{conn: conn} do
+ clear_config(:database_config_whitelist, [
+ {:pleroma, :instance},
+ {:pleroma, :activitypub},
+ {:pleroma, Pleroma.Upload},
+ {:esshd}
+ ])
+
+ admin = insert(:user, is_admin: true)
+
+ conn =
+ assign(conn, :user, admin)
+ |> get("/api/pleroma/admin/config/descriptions")
+
+ children = json_response(conn, 200)
+
+ assert length(children) == 4
+
+ assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3
+
+ instance = Enum.find(children, fn c -> c["key"] == ":instance" end)
+ assert instance["children"]
+
+ activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end)
+ assert activitypub["children"]
+
+ web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end)
+ assert web_endpoint["children"]
+
+ esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end)
+ assert esshd["children"]
+ end
+ end
+
+ describe "/api/pleroma/admin/stats" do
+ test "status visibility count", %{conn: conn} do
+ admin = insert(:user, is_admin: true)
+ user = insert(:user)
+ CommonAPI.post(user, %{visibility: "public", status: "hey"})
+ CommonAPI.post(user, %{visibility: "unlisted", status: "hey"})
+ CommonAPI.post(user, %{visibility: "unlisted", status: "hey"})
+
+ response =
+ conn
+ |> assign(:user, admin)
+ |> get("/api/pleroma/admin/stats")
+ |> json_response(200)
+
+ assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 2} =
+ response["status_visibility"]
+ end
+ end
+
+ describe "POST /api/pleroma/admin/oauth_app" do
+ test "errors", %{conn: conn} do
+ response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200)
+
+ assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"}
+ end
+
+ test "success", %{conn: conn} do
+ base_url = Web.base_url()
+ app_name = "Trusted app"
+
+ response =
+ conn
+ |> post("/api/pleroma/admin/oauth_app", %{
+ name: app_name,
+ redirect_uris: base_url
+ })
+ |> json_response(200)
+
+ assert %{
+ "client_id" => _,
+ "client_secret" => _,
+ "name" => ^app_name,
+ "redirect_uri" => ^base_url,
+ "trusted" => false
+ } = response
+ end
+
+ test "with trusted", %{conn: conn} do
+ base_url = Web.base_url()
+ app_name = "Trusted app"
+
+ response =
+ conn
+ |> post("/api/pleroma/admin/oauth_app", %{
+ name: app_name,
+ redirect_uris: base_url,
+ trusted: true
+ })
+ |> json_response(200)
+
+ assert %{
+ "client_id" => _,
+ "client_secret" => _,
+ "name" => ^app_name,
+ "redirect_uri" => ^base_url,
+ "trusted" => true
+ } = response
+ end
+ end
+
+ describe "GET /api/pleroma/admin/oauth_app" do
+ setup do
+ app = insert(:oauth_app)
+ {:ok, app: app}
+ end
+
+ test "list", %{conn: conn} do
+ response =
+ conn
+ |> get("/api/pleroma/admin/oauth_app")
+ |> json_response(200)
+
+ assert %{"apps" => apps, "count" => count, "page_size" => _} = response
+
+ assert length(apps) == count
+ end
+
+ test "with page size", %{conn: conn} do
+ insert(:oauth_app)
+ page_size = 1
+
+ response =
+ conn
+ |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)})
+ |> json_response(200)
+
+ assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response
+
+ assert length(apps) == page_size
+ end
+
+ test "search by client name", %{conn: conn, app: app} do
+ response =
+ conn
+ |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name})
+ |> json_response(200)
+
+ assert %{"apps" => [returned], "count" => _, "page_size" => _} = response
+
+ assert returned["client_id"] == app.client_id
+ assert returned["name"] == app.client_name
+ end
+
+ test "search by client id", %{conn: conn, app: app} do
+ response =
+ conn
+ |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id})
+ |> json_response(200)
+
+ assert %{"apps" => [returned], "count" => _, "page_size" => _} = response
+
+ assert returned["client_id"] == app.client_id
+ assert returned["name"] == app.client_name
+ end
+
+ test "only trusted", %{conn: conn} do
+ app = insert(:oauth_app, trusted: true)
+
+ response =
+ conn
+ |> get("/api/pleroma/admin/oauth_app", %{trusted: true})
+ |> json_response(200)
+
+ assert %{"apps" => [returned], "count" => _, "page_size" => _} = response
+
+ assert returned["client_id"] == app.client_id
+ assert returned["name"] == app.client_name
+ end
+ end
+
+ describe "DELETE /api/pleroma/admin/oauth_app/:id" do
+ test "with id", %{conn: conn} do
+ app = insert(:oauth_app)
+
+ response =
+ conn
+ |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id))
+ |> json_response(:no_content)
+
+ assert response == ""
+ end
+
+ test "with non existance id", %{conn: conn} do
+ response =
+ conn
+ |> delete("/api/pleroma/admin/oauth_app/0")
+ |> json_response(:bad_request)
+
+ assert response == ""
+ end
+ end
+
+ describe "PATCH /api/pleroma/admin/oauth_app/:id" do
+ test "with id", %{conn: conn} do
+ app = insert(:oauth_app)
+
+ name = "another name"
+ url = "https://example.com"
+ scopes = ["admin"]
+ id = app.id
+ website = "http://website.com"
+
+ response =
+ conn
+ |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{
+ name: name,
+ trusted: true,
+ redirect_uris: url,
+ scopes: scopes,
+ website: website
+ })
+ |> json_response(200)
+
+ assert %{
+ "client_id" => _,
+ "client_secret" => _,
+ "id" => ^id,
+ "name" => ^name,
+ "redirect_uri" => ^url,
+ "trusted" => true,
+ "website" => ^website
+ } = response
+ end
+
+ test "without id", %{conn: conn} do
+ response =
+ conn
+ |> patch("/api/pleroma/admin/oauth_app/0")
+ |> json_response(:bad_request)
+
+ assert response == ""
+ end
+ end
end
# Needed for testing
diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs
deleted file mode 100644
index 204446b79..000000000
--- a/test/web/admin_api/config_test.exs
+++ /dev/null
@@ -1,497 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.AdminAPI.ConfigTest do
- use Pleroma.DataCase, async: true
- import Pleroma.Factory
- alias Pleroma.Web.AdminAPI.Config
-
- test "get_by_key/1" do
- config = insert(:config)
- insert(:config)
-
- assert config == Config.get_by_params(%{group: config.group, key: config.key})
- end
-
- test "create/1" do
- {:ok, config} = Config.create(%{group: "pleroma", key: "some_key", value: "some_value"})
- assert config == Config.get_by_params(%{group: "pleroma", key: "some_key"})
- end
-
- test "update/1" do
- config = insert(:config)
- {:ok, updated} = Config.update(config, %{value: "some_value"})
- loaded = Config.get_by_params(%{group: config.group, key: config.key})
- assert loaded == updated
- end
-
- test "update_or_create/1" do
- config = insert(:config)
- key2 = "another_key"
-
- params = [
- %{group: "pleroma", key: key2, value: "another_value"},
- %{group: config.group, key: config.key, value: "new_value"}
- ]
-
- assert Repo.all(Config) |> length() == 1
-
- Enum.each(params, &Config.update_or_create(&1))
-
- assert Repo.all(Config) |> length() == 2
-
- config1 = Config.get_by_params(%{group: config.group, key: config.key})
- config2 = Config.get_by_params(%{group: "pleroma", key: key2})
-
- assert config1.value == Config.transform("new_value")
- assert config2.value == Config.transform("another_value")
- end
-
- test "delete/1" do
- config = insert(:config)
- {:ok, _} = Config.delete(%{key: config.key, group: config.group})
- refute Config.get_by_params(%{key: config.key, group: config.group})
- end
-
- describe "transform/1" do
- test "string" do
- binary = Config.transform("value as string")
- assert binary == :erlang.term_to_binary("value as string")
- assert Config.from_binary(binary) == "value as string"
- end
-
- test "boolean" do
- binary = Config.transform(false)
- assert binary == :erlang.term_to_binary(false)
- assert Config.from_binary(binary) == false
- end
-
- test "nil" do
- binary = Config.transform(nil)
- assert binary == :erlang.term_to_binary(nil)
- assert Config.from_binary(binary) == nil
- end
-
- test "integer" do
- binary = Config.transform(150)
- assert binary == :erlang.term_to_binary(150)
- assert Config.from_binary(binary) == 150
- end
-
- test "atom" do
- binary = Config.transform(":atom")
- assert binary == :erlang.term_to_binary(:atom)
- assert Config.from_binary(binary) == :atom
- end
-
- test "pleroma module" do
- binary = Config.transform("Pleroma.Bookmark")
- assert binary == :erlang.term_to_binary(Pleroma.Bookmark)
- assert Config.from_binary(binary) == Pleroma.Bookmark
- end
-
- test "phoenix module" do
- binary = Config.transform("Phoenix.Socket.V1.JSONSerializer")
- assert binary == :erlang.term_to_binary(Phoenix.Socket.V1.JSONSerializer)
- assert Config.from_binary(binary) == Phoenix.Socket.V1.JSONSerializer
- end
-
- test "sigil" do
- binary = Config.transform("~r/comp[lL][aA][iI][nN]er/")
- assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/)
- assert Config.from_binary(binary) == ~r/comp[lL][aA][iI][nN]er/
- end
-
- test "link sigil" do
- binary = Config.transform("~r/https:\/\/example.com/")
- assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/)
- assert Config.from_binary(binary) == ~r/https:\/\/example.com/
- end
-
- test "link sigil with u modifier" do
- binary = Config.transform("~r/https:\/\/example.com/u")
- assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/u)
- assert Config.from_binary(binary) == ~r/https:\/\/example.com/u
- end
-
- test "link sigil with i modifier" do
- binary = Config.transform("~r/https:\/\/example.com/i")
- assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/i)
- assert Config.from_binary(binary) == ~r/https:\/\/example.com/i
- end
-
- test "link sigil with s modifier" do
- binary = Config.transform("~r/https:\/\/example.com/s")
- assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/s)
- assert Config.from_binary(binary) == ~r/https:\/\/example.com/s
- end
-
- test "2 child tuple" do
- binary = Config.transform(%{"tuple" => ["v1", ":v2"]})
- assert binary == :erlang.term_to_binary({"v1", :v2})
- assert Config.from_binary(binary) == {"v1", :v2}
- end
-
- test "tuple with n childs" do
- binary =
- Config.transform(%{
- "tuple" => [
- "v1",
- ":v2",
- "Pleroma.Bookmark",
- 150,
- false,
- "Phoenix.Socket.V1.JSONSerializer"
- ]
- })
-
- assert binary ==
- :erlang.term_to_binary(
- {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer}
- )
-
- assert Config.from_binary(binary) ==
- {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer}
- end
-
- test "tuple with dispatch key" do
- binary = Config.transform(%{"tuple" => [":dispatch", ["{:_,
- [
- {\"/api/v1/streaming\", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {\"/websocket\", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: \"/websocket\"]}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]}"]]})
-
- assert binary ==
- :erlang.term_to_binary(
- {:dispatch,
- [
- {:_,
- [
- {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: "/websocket"]}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]}
- ]}
- )
-
- assert Config.from_binary(binary) ==
- {:dispatch,
- [
- {:_,
- [
- {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: "/websocket"]}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]}
- ]}
- end
-
- test "map with string key" do
- binary = Config.transform(%{"key" => "value"})
- assert binary == :erlang.term_to_binary(%{"key" => "value"})
- assert Config.from_binary(binary) == %{"key" => "value"}
- end
-
- test "map with atom key" do
- binary = Config.transform(%{":key" => "value"})
- assert binary == :erlang.term_to_binary(%{key: "value"})
- assert Config.from_binary(binary) == %{key: "value"}
- end
-
- test "list of strings" do
- binary = Config.transform(["v1", "v2", "v3"])
- assert binary == :erlang.term_to_binary(["v1", "v2", "v3"])
- assert Config.from_binary(binary) == ["v1", "v2", "v3"]
- end
-
- test "list of modules" do
- binary = Config.transform(["Pleroma.Repo", "Pleroma.Activity"])
- assert binary == :erlang.term_to_binary([Pleroma.Repo, Pleroma.Activity])
- assert Config.from_binary(binary) == [Pleroma.Repo, Pleroma.Activity]
- end
-
- test "list of atoms" do
- binary = Config.transform([":v1", ":v2", ":v3"])
- assert binary == :erlang.term_to_binary([:v1, :v2, :v3])
- assert Config.from_binary(binary) == [:v1, :v2, :v3]
- end
-
- test "list of mixed values" do
- binary =
- Config.transform([
- "v1",
- ":v2",
- "Pleroma.Repo",
- "Phoenix.Socket.V1.JSONSerializer",
- 15,
- false
- ])
-
- assert binary ==
- :erlang.term_to_binary([
- "v1",
- :v2,
- Pleroma.Repo,
- Phoenix.Socket.V1.JSONSerializer,
- 15,
- false
- ])
-
- assert Config.from_binary(binary) == [
- "v1",
- :v2,
- Pleroma.Repo,
- Phoenix.Socket.V1.JSONSerializer,
- 15,
- false
- ]
- end
-
- test "simple keyword" do
- binary = Config.transform([%{"tuple" => [":key", "value"]}])
- assert binary == :erlang.term_to_binary([{:key, "value"}])
- assert Config.from_binary(binary) == [{:key, "value"}]
- assert Config.from_binary(binary) == [key: "value"]
- end
-
- test "keyword with partial_chain key" do
- binary =
- Config.transform([%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}])
-
- assert binary == :erlang.term_to_binary(partial_chain: &:hackney_connect.partial_chain/1)
- assert Config.from_binary(binary) == [partial_chain: &:hackney_connect.partial_chain/1]
- end
-
- test "keyword" do
- binary =
- Config.transform([
- %{"tuple" => [":types", "Pleroma.PostgresTypes"]},
- %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]},
- %{"tuple" => [":migration_lock", nil]},
- %{"tuple" => [":key1", 150]},
- %{"tuple" => [":key2", "string"]}
- ])
-
- assert binary ==
- :erlang.term_to_binary(
- types: Pleroma.PostgresTypes,
- telemetry_event: [Pleroma.Repo.Instrumenter],
- migration_lock: nil,
- key1: 150,
- key2: "string"
- )
-
- assert Config.from_binary(binary) == [
- types: Pleroma.PostgresTypes,
- telemetry_event: [Pleroma.Repo.Instrumenter],
- migration_lock: nil,
- key1: 150,
- key2: "string"
- ]
- end
-
- test "complex keyword with nested mixed childs" do
- binary =
- Config.transform([
- %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]},
- %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]},
- %{"tuple" => [":link_name", true]},
- %{"tuple" => [":proxy_remote", false]},
- %{"tuple" => [":common_map", %{":key" => "value"}]},
- %{
- "tuple" => [
- ":proxy_opts",
- [
- %{"tuple" => [":redirect_on_failure", false]},
- %{"tuple" => [":max_body_length", 1_048_576]},
- %{
- "tuple" => [
- ":http",
- [%{"tuple" => [":follow_redirect", true]}, %{"tuple" => [":pool", ":upload"]}]
- ]
- }
- ]
- ]
- }
- ])
-
- assert binary ==
- :erlang.term_to_binary(
- uploader: Pleroma.Uploaders.Local,
- filters: [Pleroma.Upload.Filter.Dedupe],
- link_name: true,
- proxy_remote: false,
- common_map: %{key: "value"},
- proxy_opts: [
- redirect_on_failure: false,
- max_body_length: 1_048_576,
- http: [
- follow_redirect: true,
- pool: :upload
- ]
- ]
- )
-
- assert Config.from_binary(binary) ==
- [
- uploader: Pleroma.Uploaders.Local,
- filters: [Pleroma.Upload.Filter.Dedupe],
- link_name: true,
- proxy_remote: false,
- common_map: %{key: "value"},
- proxy_opts: [
- redirect_on_failure: false,
- max_body_length: 1_048_576,
- http: [
- follow_redirect: true,
- pool: :upload
- ]
- ]
- ]
- end
-
- test "common keyword" do
- binary =
- Config.transform([
- %{"tuple" => [":level", ":warn"]},
- %{"tuple" => [":meta", [":all"]]},
- %{"tuple" => [":path", ""]},
- %{"tuple" => [":val", nil]},
- %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]}
- ])
-
- assert binary ==
- :erlang.term_to_binary(
- level: :warn,
- meta: [:all],
- path: "",
- val: nil,
- webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE"
- )
-
- assert Config.from_binary(binary) == [
- level: :warn,
- meta: [:all],
- path: "",
- val: nil,
- webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE"
- ]
- end
-
- test "complex keyword with sigil" do
- binary =
- Config.transform([
- %{"tuple" => [":federated_timeline_removal", []]},
- %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]},
- %{"tuple" => [":replace", []]}
- ])
-
- assert binary ==
- :erlang.term_to_binary(
- federated_timeline_removal: [],
- reject: [~r/comp[lL][aA][iI][nN]er/],
- replace: []
- )
-
- assert Config.from_binary(binary) ==
- [federated_timeline_removal: [], reject: [~r/comp[lL][aA][iI][nN]er/], replace: []]
- end
-
- test "complex keyword with tuples with more than 2 values" do
- binary =
- Config.transform([
- %{
- "tuple" => [
- ":http",
- [
- %{
- "tuple" => [
- ":key1",
- [
- %{
- "tuple" => [
- ":_",
- [
- %{
- "tuple" => [
- "/api/v1/streaming",
- "Pleroma.Web.MastodonAPI.WebsocketHandler",
- []
- ]
- },
- %{
- "tuple" => [
- "/websocket",
- "Phoenix.Endpoint.CowboyWebSocket",
- %{
- "tuple" => [
- "Phoenix.Transports.WebSocket",
- %{
- "tuple" => [
- "Pleroma.Web.Endpoint",
- "Pleroma.Web.UserSocket",
- []
- ]
- }
- ]
- }
- ]
- },
- %{
- "tuple" => [
- ":_",
- "Phoenix.Endpoint.Cowboy2Handler",
- %{"tuple" => ["Pleroma.Web.Endpoint", []]}
- ]
- }
- ]
- ]
- }
- ]
- ]
- }
- ]
- ]
- }
- ])
-
- assert binary ==
- :erlang.term_to_binary(
- http: [
- key1: [
- _: [
- {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]
- ]
- ]
- )
-
- assert Config.from_binary(binary) == [
- http: [
- key1: [
- {:_,
- [
- {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
- {"/websocket", Phoenix.Endpoint.CowboyWebSocket,
- {Phoenix.Transports.WebSocket,
- {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}},
- {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
- ]}
- ]
- ]
- ]
- end
- end
-end
diff --git a/test/web/admin_api/search_test.exs b/test/web/admin_api/search_test.exs
index 9df4cd539..e0e3d4153 100644
--- a/test/web/admin_api/search_test.exs
+++ b/test/web/admin_api/search_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.SearchTest do
@@ -47,9 +47,9 @@ defmodule Pleroma.Web.AdminAPI.SearchTest do
end
test "it returns active/deactivated users" do
- insert(:user, info: %{deactivated: true})
- insert(:user, info: %{deactivated: true})
- insert(:user, info: %{deactivated: false})
+ insert(:user, deactivated: true)
+ insert(:user, deactivated: true)
+ insert(:user, deactivated: false)
{:ok, _results, active_count} =
Search.user(%{
@@ -70,7 +70,7 @@ defmodule Pleroma.Web.AdminAPI.SearchTest do
test "it returns specific user" do
insert(:user)
insert(:user)
- user = insert(:user, nickname: "bob", local: true, info: %{deactivated: false})
+ user = insert(:user, nickname: "bob", local: true, deactivated: false)
{:ok, _results, total_count} = Search.user(%{query: ""})
@@ -108,7 +108,7 @@ defmodule Pleroma.Web.AdminAPI.SearchTest do
end
test "it returns admin user" do
- admin = insert(:user, info: %{is_admin: true})
+ admin = insert(:user, is_admin: true)
insert(:user)
insert(:user)
@@ -119,7 +119,7 @@ defmodule Pleroma.Web.AdminAPI.SearchTest do
end
test "it returns moderator user" do
- moderator = insert(:user, info: %{is_moderator: true})
+ moderator = insert(:user, is_moderator: true)
insert(:user)
insert(:user)
diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs
index 475705857..f00b0afb2 100644
--- a/test/web/admin_api/views/report_view_test.exs
+++ b/test/web/admin_api/views/report_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.ReportViewTest do
@@ -15,7 +15,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.report(user, %{"account_id" => other_user.id})
+ {:ok, activity} = CommonAPI.report(user, %{account_id: other_user.id})
expected = %{
content: nil,
@@ -30,6 +30,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user})
),
statuses: [],
+ notes: [],
state: "open",
id: activity.id
}
@@ -44,10 +45,12 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
test "includes reported statuses" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "toot"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "toot"})
{:ok, report_activity} =
- CommonAPI.report(user, %{"account_id" => other_user.id, "status_ids" => [activity.id]})
+ CommonAPI.report(user, %{account_id: other_user.id, status_ids: [activity.id]})
+
+ other_user = Pleroma.User.get_by_id(other_user.id)
expected = %{
content: nil,
@@ -63,6 +66,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
),
statuses: [StatusView.render("show.json", %{activity: activity})],
state: "open",
+ notes: [],
id: report_activity.id
}
@@ -77,7 +81,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.report(user, %{"account_id" => other_user.id})
+ {:ok, activity} = CommonAPI.report(user, %{account_id: other_user.id})
{:ok, activity} = CommonAPI.update_report_state(activity.id, "closed")
assert %{state: "closed"} =
@@ -90,8 +94,8 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
{:ok, activity} =
CommonAPI.report(user, %{
- "account_id" => other_user.id,
- "comment" => "posts are too good for this instance"
+ account_id: other_user.id,
+ comment: "posts are too good for this instance"
})
assert %{content: "posts are too good for this instance"} =
@@ -104,8 +108,8 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
{:ok, activity} =
CommonAPI.report(user, %{
- "account_id" => other_user.id,
- "comment" => ""
+ account_id: other_user.id,
+ comment: ""
})
data = Map.put(activity.data, "content", "<script> alert('hecked :D:D:D:D:D:D:D') </script>")
@@ -121,8 +125,8 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do
{:ok, activity} =
CommonAPI.report(user, %{
- "account_id" => other_user.id,
- "comment" => ""
+ account_id: other_user.id,
+ comment: ""
})
Pleroma.User.delete(other_user)
diff --git a/test/web/api_spec/schema_examples_test.exs b/test/web/api_spec/schema_examples_test.exs
new file mode 100644
index 000000000..88b6f07cb
--- /dev/null
+++ b/test/web/api_spec/schema_examples_test.exs
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.SchemaExamplesTest do
+ use ExUnit.Case, async: true
+ import Pleroma.Tests.ApiSpecHelpers
+
+ @content_type "application/json"
+
+ for operation <- api_operations() do
+ describe operation.operationId <> " Request Body" do
+ if operation.requestBody do
+ @media_type operation.requestBody.content[@content_type]
+ @schema resolve_schema(@media_type.schema)
+
+ if @media_type.example do
+ test "request body media type example matches schema" do
+ assert_schema(@media_type.example, @schema)
+ end
+ end
+
+ if @schema.example do
+ test "request body schema example matches schema" do
+ assert_schema(@schema.example, @schema)
+ end
+ end
+ end
+ end
+
+ for {status, response} <- operation.responses do
+ describe "#{operation.operationId} - #{status} Response" do
+ @schema resolve_schema(response.content[@content_type].schema)
+
+ if @schema.example do
+ test "example matches schema" do
+ assert_schema(@schema.example, @schema)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/test/web/auth/auth_test_controller_test.exs b/test/web/auth/auth_test_controller_test.exs
new file mode 100644
index 000000000..fed52b7f3
--- /dev/null
+++ b/test/web/auth/auth_test_controller_test.exs
@@ -0,0 +1,242 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Tests.AuthTestControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+
+ describe "do_oauth_check" do
+ test "serves with proper OAuth token (fulfilling requested scopes)" do
+ %{conn: good_token_conn, user: user} = oauth_access(["read"])
+
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/authenticated_api/do_oauth_check")
+ |> json_response(200)
+
+ # Unintended usage (:api) — use with :authenticated_api instead
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/api/do_oauth_check")
+ |> json_response(200)
+ end
+
+ test "fails on no token / missing scope(s)" do
+ %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"])
+
+ bad_token_conn
+ |> get("/test/authenticated_api/do_oauth_check")
+ |> json_response(403)
+
+ bad_token_conn
+ |> assign(:token, nil)
+ |> get("/test/api/do_oauth_check")
+ |> json_response(403)
+ end
+ end
+
+ describe "fallback_oauth_check" do
+ test "serves with proper OAuth token (fulfilling requested scopes)" do
+ %{conn: good_token_conn, user: user} = oauth_access(["read"])
+
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/api/fallback_oauth_check")
+ |> json_response(200)
+
+ # Unintended usage (:authenticated_api) — use with :api instead
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/authenticated_api/fallback_oauth_check")
+ |> json_response(200)
+ end
+
+ test "for :api on public instance, drops :user and renders on no token / missing scope(s)" do
+ clear_config([:instance, :public], true)
+
+ %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"])
+
+ assert %{"user_id" => nil} ==
+ bad_token_conn
+ |> get("/test/api/fallback_oauth_check")
+ |> json_response(200)
+
+ assert %{"user_id" => nil} ==
+ bad_token_conn
+ |> assign(:token, nil)
+ |> get("/test/api/fallback_oauth_check")
+ |> json_response(200)
+ end
+
+ test "for :api on private instance, fails on no token / missing scope(s)" do
+ clear_config([:instance, :public], false)
+
+ %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"])
+
+ bad_token_conn
+ |> get("/test/api/fallback_oauth_check")
+ |> json_response(403)
+
+ bad_token_conn
+ |> assign(:token, nil)
+ |> get("/test/api/fallback_oauth_check")
+ |> json_response(403)
+ end
+ end
+
+ describe "skip_oauth_check" do
+ test "for :authenticated_api, serves if :user is set (regardless of token / token scopes)" do
+ user = insert(:user)
+
+ assert %{"user_id" => user.id} ==
+ build_conn()
+ |> assign(:user, user)
+ |> get("/test/authenticated_api/skip_oauth_check")
+ |> json_response(200)
+
+ %{conn: bad_token_conn, user: user} = oauth_access(["irrelevant_scope"])
+
+ assert %{"user_id" => user.id} ==
+ bad_token_conn
+ |> get("/test/authenticated_api/skip_oauth_check")
+ |> json_response(200)
+ end
+
+ test "serves via :api on public instance if :user is not set" do
+ clear_config([:instance, :public], true)
+
+ assert %{"user_id" => nil} ==
+ build_conn()
+ |> get("/test/api/skip_oauth_check")
+ |> json_response(200)
+
+ build_conn()
+ |> get("/test/authenticated_api/skip_oauth_check")
+ |> json_response(403)
+ end
+
+ test "fails on private instance if :user is not set" do
+ clear_config([:instance, :public], false)
+
+ build_conn()
+ |> get("/test/api/skip_oauth_check")
+ |> json_response(403)
+
+ build_conn()
+ |> get("/test/authenticated_api/skip_oauth_check")
+ |> json_response(403)
+ end
+ end
+
+ describe "fallback_oauth_skip_publicity_check" do
+ test "serves with proper OAuth token (fulfilling requested scopes)" do
+ %{conn: good_token_conn, user: user} = oauth_access(["read"])
+
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/api/fallback_oauth_skip_publicity_check")
+ |> json_response(200)
+
+ # Unintended usage (:authenticated_api)
+ assert %{"user_id" => user.id} ==
+ good_token_conn
+ |> get("/test/authenticated_api/fallback_oauth_skip_publicity_check")
+ |> json_response(200)
+ end
+
+ test "for :api on private / public instance, drops :user and renders on token issue" do
+ %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"])
+
+ for is_public <- [true, false] do
+ clear_config([:instance, :public], is_public)
+
+ assert %{"user_id" => nil} ==
+ bad_token_conn
+ |> get("/test/api/fallback_oauth_skip_publicity_check")
+ |> json_response(200)
+
+ assert %{"user_id" => nil} ==
+ bad_token_conn
+ |> assign(:token, nil)
+ |> get("/test/api/fallback_oauth_skip_publicity_check")
+ |> json_response(200)
+ end
+ end
+ end
+
+ describe "skip_oauth_skip_publicity_check" do
+ test "for :authenticated_api, serves if :user is set (regardless of token / token scopes)" do
+ user = insert(:user)
+
+ assert %{"user_id" => user.id} ==
+ build_conn()
+ |> assign(:user, user)
+ |> get("/test/authenticated_api/skip_oauth_skip_publicity_check")
+ |> json_response(200)
+
+ %{conn: bad_token_conn, user: user} = oauth_access(["irrelevant_scope"])
+
+ assert %{"user_id" => user.id} ==
+ bad_token_conn
+ |> get("/test/authenticated_api/skip_oauth_skip_publicity_check")
+ |> json_response(200)
+ end
+
+ test "for :api, serves on private and public instances regardless of whether :user is set" do
+ user = insert(:user)
+
+ for is_public <- [true, false] do
+ clear_config([:instance, :public], is_public)
+
+ assert %{"user_id" => nil} ==
+ build_conn()
+ |> get("/test/api/skip_oauth_skip_publicity_check")
+ |> json_response(200)
+
+ assert %{"user_id" => user.id} ==
+ build_conn()
+ |> assign(:user, user)
+ |> get("/test/api/skip_oauth_skip_publicity_check")
+ |> json_response(200)
+ end
+ end
+ end
+
+ describe "missing_oauth_check_definition" do
+ def test_missing_oauth_check_definition_failure(endpoint, expected_error) do
+ %{conn: conn} = oauth_access(["read", "write", "follow", "push", "admin"])
+
+ assert %{"error" => expected_error} ==
+ conn
+ |> get(endpoint)
+ |> json_response(403)
+ end
+
+ test "fails if served via :authenticated_api" do
+ test_missing_oauth_check_definition_failure(
+ "/test/authenticated_api/missing_oauth_check_definition",
+ "Security violation: OAuth scopes check was neither handled nor explicitly skipped."
+ )
+ end
+
+ test "fails if served via :api and the instance is private" do
+ clear_config([:instance, :public], false)
+
+ test_missing_oauth_check_definition_failure(
+ "/test/api/missing_oauth_check_definition",
+ "This resource requires authentication."
+ )
+ end
+
+ test "succeeds with dropped :user if served via :api on public instance" do
+ %{conn: conn} = oauth_access(["read", "write", "follow", "push", "admin"])
+
+ assert %{"user_id" => nil} ==
+ conn
+ |> get("/test/api/missing_oauth_check_definition")
+ |> json_response(200)
+ end
+ end
+end
diff --git a/test/web/auth/authenticator_test.exs b/test/web/auth/authenticator_test.exs
index fea5c8209..d54253343 100644
--- a/test/web/auth/authenticator_test.exs
+++ b/test/web/auth/authenticator_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.AuthenticatorTest do
diff --git a/test/web/auth/basic_auth_test.exs b/test/web/auth/basic_auth_test.exs
new file mode 100644
index 000000000..bf6e3d2fc
--- /dev/null
+++ b/test/web/auth/basic_auth_test.exs
@@ -0,0 +1,46 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.BasicAuthTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+
+ test "with HTTP Basic Auth used, grants access to OAuth scope-restricted endpoints", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ assert Pbkdf2.verify_pass("test", user.password_hash)
+
+ basic_auth_contents =
+ (URI.encode_www_form(user.nickname) <> ":" <> URI.encode_www_form("test"))
+ |> Base.encode64()
+
+ # Succeeds with HTTP Basic Auth
+ response =
+ conn
+ |> put_req_header("authorization", "Basic " <> basic_auth_contents)
+ |> get("/api/v1/accounts/verify_credentials")
+ |> json_response(200)
+
+ user_nickname = user.nickname
+ assert %{"username" => ^user_nickname} = response
+
+ # Succeeds with a properly scoped OAuth token
+ valid_token = insert(:oauth_token, scopes: ["read:accounts"])
+
+ conn
+ |> put_req_header("authorization", "Bearer #{valid_token.token}")
+ |> get("/api/v1/accounts/verify_credentials")
+ |> json_response(200)
+
+ # Fails with a wrong-scoped OAuth token (proof of restriction)
+ invalid_token = insert(:oauth_token, scopes: ["read:something"])
+
+ conn
+ |> put_req_header("authorization", "Bearer #{invalid_token.token}")
+ |> get("/api/v1/accounts/verify_credentials")
+ |> json_response(403)
+ end
+end
diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs
new file mode 100644
index 000000000..731bd5932
--- /dev/null
+++ b/test/web/auth/pleroma_authenticator_test.exs
@@ -0,0 +1,48 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Web.Auth.PleromaAuthenticator
+ import Pleroma.Factory
+
+ setup do
+ password = "testpassword"
+ name = "AgentSmith"
+ user = insert(:user, nickname: name, password_hash: Pbkdf2.hash_pwd_salt(password))
+ {:ok, [user: user, name: name, password: password]}
+ end
+
+ test "get_user/authorization", %{name: name, password: password} do
+ name = name <> "1"
+ user = insert(:user, nickname: name, password_hash: Bcrypt.hash_pwd_salt(password))
+
+ params = %{"authorization" => %{"name" => name, "password" => password}}
+ res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+ assert {:ok, returned_user} = res
+ assert returned_user.id == user.id
+ assert "$pbkdf2" <> _ = returned_user.password_hash
+ end
+
+ test "get_user/authorization with invalid password", %{name: name} do
+ params = %{"authorization" => %{"name" => name, "password" => "password"}}
+ res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+ assert {:error, {:checkpw, false}} == res
+ end
+
+ test "get_user/grant_type_password", %{user: user, name: name, password: password} do
+ params = %{"grant_type" => "password", "username" => name, "password" => password}
+ res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
+
+ assert {:ok, user} == res
+ end
+
+ test "error credintails" do
+ res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}})
+ assert {:error, :invalid_credentials} == res
+ end
+end
diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs
new file mode 100644
index 000000000..e502e0ae8
--- /dev/null
+++ b/test/web/auth/totp_authenticator_test.exs
@@ -0,0 +1,51 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.MFA
+ alias Pleroma.MFA.BackupCodes
+ alias Pleroma.MFA.TOTP
+ alias Pleroma.Web.Auth.TOTPAuthenticator
+
+ import Pleroma.Factory
+
+ test "verify token" do
+ otp_secret = TOTP.generate_secret()
+ otp_token = TOTP.generate_token(otp_secret)
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass}
+ assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token}
+ assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token}
+ end
+
+ test "checks backup codes" do
+ [code | _] = backup_codes = BackupCodes.generate()
+
+ hashed_codes =
+ backup_codes
+ |> Enum.map(&Pbkdf2.hash_pwd_salt(&1))
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ backup_codes: hashed_codes,
+ totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
+ }
+ )
+
+ assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass}
+ refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass}
+ end
+end
diff --git a/test/web/chat_channel_test.exs b/test/web/chat_channel_test.exs
new file mode 100644
index 000000000..f18f3a212
--- /dev/null
+++ b/test/web/chat_channel_test.exs
@@ -0,0 +1,37 @@
+defmodule Pleroma.Web.ChatChannelTest do
+ use Pleroma.Web.ChannelCase
+ alias Pleroma.Web.ChatChannel
+ alias Pleroma.Web.UserSocket
+
+ import Pleroma.Factory
+
+ setup do
+ user = insert(:user)
+
+ {:ok, _, socket} =
+ socket(UserSocket, "", %{user_name: user.nickname})
+ |> subscribe_and_join(ChatChannel, "chat:public")
+
+ {:ok, socket: socket}
+ end
+
+ test "it broadcasts a message", %{socket: socket} do
+ push(socket, "new_msg", %{"text" => "why is tenshi eating a corndog so cute?"})
+ assert_broadcast("new_msg", %{text: "why is tenshi eating a corndog so cute?"})
+ end
+
+ describe "message lengths" do
+ setup do: clear_config([:instance, :chat_limit])
+
+ test "it ignores messages of length zero", %{socket: socket} do
+ push(socket, "new_msg", %{"text" => ""})
+ refute_broadcast("new_msg", %{text: ""})
+ end
+
+ test "it ignores messages above a certain length", %{socket: socket} do
+ Pleroma.Config.put([:instance, :chat_limit], 2)
+ push(socket, "new_msg", %{"text" => "123"})
+ refute_broadcast("new_msg", %{text: "123"})
+ end
+ end
+end
diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs
index 83df44c36..fd8299013 100644
--- a/test/web/common_api/common_api_test.exs
+++ b/test/web/common_api/common_api_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPITest do
@@ -9,25 +9,190 @@ defmodule Pleroma.Web.CommonAPITest do
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Visibility
+ alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
+ import Mock
require Pleroma.Constants
- clear_config([:instance, :safe_dm_mentions])
- clear_config([:instance, :limit])
- clear_config([:instance, :max_pinned_statuses])
+ setup do: clear_config([:instance, :safe_dm_mentions])
+ setup do: clear_config([:instance, :limit])
+ setup do: clear_config([:instance, :max_pinned_statuses])
+
+ describe "unblocking" do
+ test "it works even without an existing block activity" do
+ blocked = insert(:user)
+ blocker = insert(:user)
+ User.block(blocker, blocked)
+
+ assert User.blocks?(blocker, blocked)
+ assert {:ok, :no_activity} == CommonAPI.unblock(blocker, blocked)
+ refute User.blocks?(blocker, blocked)
+ end
+ end
+
+ describe "deletion" do
+ test "it works with pruned objects" do
+ user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
+
+ Object.normalize(post, false)
+ |> Object.prune()
+
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ assert {:ok, delete} = CommonAPI.delete(post.id, user)
+ assert delete.local
+ assert called(Pleroma.Web.Federator.publish(delete))
+ end
+
+ refute Activity.get_by_id(post.id)
+ end
+
+ test "it allows users to delete their posts" do
+ user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
+
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ assert {:ok, delete} = CommonAPI.delete(post.id, user)
+ assert delete.local
+ assert called(Pleroma.Web.Federator.publish(delete))
+ end
+
+ refute Activity.get_by_id(post.id)
+ end
+
+ test "it does not allow a user to delete their posts" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
+
+ assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user)
+ assert Activity.get_by_id(post.id)
+ end
+
+ test "it allows moderators to delete other user's posts" do
+ user = insert(:user)
+ moderator = insert(:user, is_moderator: true)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
+
+ assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+ assert delete.local
+
+ refute Activity.get_by_id(post.id)
+ end
+
+ test "it allows admins to delete other user's posts" do
+ user = insert(:user)
+ moderator = insert(:user, is_admin: true)
+
+ {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
+
+ assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+ assert delete.local
+
+ refute Activity.get_by_id(post.id)
+ end
+
+ test "superusers deleting non-local posts won't federate the delete" do
+ # This is the user of the ingested activity
+ _user =
+ insert(:user,
+ local: false,
+ ap_id: "http://mastodon.example.org/users/admin",
+ last_refreshed_at: NaiveDateTime.utc_now()
+ )
+
+ moderator = insert(:user, is_admin: true)
+
+ data =
+ File.read!("test/fixtures/mastodon-post-activity.json")
+ |> Jason.decode!()
+
+ {:ok, post} = Transmogrifier.handle_incoming(data)
+
+ with_mock Pleroma.Web.Federator,
+ publish: fn _ -> nil end do
+ assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
+ assert delete.local
+ refute called(Pleroma.Web.Federator.publish(:_))
+ end
+
+ refute Activity.get_by_id(post.id)
+ end
+ end
+
+ test "favoriting race condition" do
+ user = insert(:user)
+ users_serial = insert_list(10, :user)
+ users = insert_list(10, :user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "."})
+
+ users_serial
+ |> Enum.map(fn user ->
+ CommonAPI.favorite(user, activity.id)
+ end)
+
+ object = Object.get_by_ap_id(activity.data["object"])
+ assert object.data["like_count"] == 10
+
+ users
+ |> Enum.map(fn user ->
+ Task.async(fn ->
+ CommonAPI.favorite(user, activity.id)
+ end)
+ end)
+ |> Enum.map(&Task.await/1)
+
+ object = Object.get_by_ap_id(activity.data["object"])
+ assert object.data["like_count"] == 20
+ end
+
+ test "repeating race condition" do
+ user = insert(:user)
+ users_serial = insert_list(10, :user)
+ users = insert_list(10, :user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "."})
+
+ users_serial
+ |> Enum.map(fn user ->
+ CommonAPI.repeat(activity.id, user)
+ end)
+
+ object = Object.get_by_ap_id(activity.data["object"])
+ assert object.data["announcement_count"] == 10
+
+ users
+ |> Enum.map(fn user ->
+ Task.async(fn ->
+ CommonAPI.repeat(activity.id, user)
+ end)
+ end)
+ |> Enum.map(&Task.await/1)
+
+ object = Object.get_by_ap_id(activity.data["object"])
+ assert object.data["announcement_count"] == 20
+ end
test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
[participation] = Participation.for_user(user)
{:ok, convo_reply} =
- CommonAPI.post(user, %{"status" => ".", "in_reply_to_conversation_id" => participation.id})
+ CommonAPI.post(user, %{status: ".", in_reply_to_conversation_id: participation.id})
assert Visibility.is_direct?(convo_reply)
@@ -41,8 +206,8 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(har, %{
- "status" => "@#{jafnhar.nickname} hey",
- "visibility" => "direct"
+ status: "@#{jafnhar.nickname} hey",
+ visibility: "direct"
})
assert har.ap_id in activity.recipients
@@ -52,10 +217,10 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(har, %{
- "status" => "I don't really like @#{tridi.nickname}",
- "visibility" => "direct",
- "in_reply_to_status_id" => activity.id,
- "in_reply_to_conversation_id" => participation.id
+ status: "I don't really like @#{tridi.nickname}",
+ visibility: "direct",
+ in_reply_to_status_id: activity.id,
+ in_reply_to_conversation_id: participation.id
})
assert har.ap_id in activity.recipients
@@ -67,12 +232,13 @@ defmodule Pleroma.Web.CommonAPITest do
har = insert(:user)
jafnhar = insert(:user)
tridi = insert(:user)
+
Pleroma.Config.put([:instance, :safe_dm_mentions], true)
{:ok, activity} =
CommonAPI.post(har, %{
- "status" => "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again",
- "visibility" => "direct"
+ status: "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again",
+ visibility: "direct"
})
refute tridi.ap_id in activity.recipients
@@ -81,7 +247,7 @@ defmodule Pleroma.Web.CommonAPITest do
test "it de-duplicates tags" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu #2HU"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU"})
object = Object.normalize(activity)
@@ -90,23 +256,11 @@ defmodule Pleroma.Web.CommonAPITest do
test "it adds emoji in the object" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => ":firefox:"})
+ {:ok, activity} = CommonAPI.post(user, %{status: ":firefox:"})
assert Object.normalize(activity).data["emoji"]["firefox"]
end
- test "it adds emoji when updating profiles" do
- user = insert(:user, %{name: ":firefox:"})
-
- {:ok, activity} = CommonAPI.update(user)
- user = User.get_cached_by_ap_id(user.ap_id)
- [firefox] = user.info.source_data["tag"]
-
- assert firefox["name"] == ":firefox:"
-
- assert Pleroma.Constants.as_public() in activity.recipients
- end
-
describe "posting" do
test "it supports explicit addressing" do
user = insert(:user)
@@ -116,9 +270,9 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"Hey, I think @#{user_three.nickname} is ugly. @#{user_four.nickname} is alright though.",
- "to" => [user_two.nickname, user_four.nickname, "nonexistent"]
+ to: [user_two.nickname, user_four.nickname, "nonexistent"]
})
assert user.ap_id in activity.recipients
@@ -134,13 +288,13 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => post,
- "content_type" => "text/html"
+ status: post,
+ content_type: "text/html"
})
object = Object.normalize(activity)
- assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')"
+ assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
end
test "it filters out obviously bad tags when accepting a post as Markdown" do
@@ -150,33 +304,33 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => post,
- "content_type" => "text/markdown"
+ status: post,
+ content_type: "text/markdown"
})
object = Object.normalize(activity)
- assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')"
+ assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
end
test "it does not allow replies to direct messages that are not direct messages themselves" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "suya..", visibility: "direct"})
assert {:ok, _} =
CommonAPI.post(user, %{
- "status" => "suya..",
- "visibility" => "direct",
- "in_reply_to_status_id" => activity.id
+ status: "suya..",
+ visibility: "direct",
+ in_reply_to_status_id: activity.id
})
Enum.each(["public", "private", "unlisted"], fn visibility ->
assert {:error, "The message visibility must be direct"} =
CommonAPI.post(user, %{
- "status" => "suya..",
- "visibility" => visibility,
- "in_reply_to_status_id" => activity.id
+ status: "suya..",
+ visibility: visibility,
+ in_reply_to_status_id: activity.id
})
end)
end
@@ -185,8 +339,7 @@ defmodule Pleroma.Web.CommonAPITest do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
assert activity.data["bcc"] == [list.ap_id]
assert activity.recipients == [list.ap_id, user.ap_id]
@@ -197,16 +350,18 @@ defmodule Pleroma.Web.CommonAPITest do
user = insert(:user)
assert {:error, "Cannot post an empty status without attachments"} =
- CommonAPI.post(user, %{"status" => ""})
+ CommonAPI.post(user, %{status: ""})
end
- test "it returns error when character limit is exceeded" do
+ test "it validates character limits are correctly enforced" do
Pleroma.Config.put([:instance, :limit], 5)
user = insert(:user)
assert {:error, "The status is over the character limit"} =
- CommonAPI.post(user, %{"status" => "foobar"})
+ CommonAPI.post(user, %{status: "foobar"})
+
+ assert {:ok, activity} = CommonAPI.post(user, %{status: "12345"})
end
test "it can handle activities that expire" do
@@ -217,8 +372,7 @@ defmodule Pleroma.Web.CommonAPITest do
|> NaiveDateTime.truncate(:second)
|> NaiveDateTime.add(1_000_000, :second)
- assert {:ok, activity} =
- CommonAPI.post(user, %{"status" => "chai", "expires_in" => 1_000_000})
+ assert {:ok, activity} = CommonAPI.post(user, %{status: "chai", expires_in: 1_000_000})
assert expiration = Pleroma.ActivityExpiration.get_by_activity_id(activity.id)
assert expiration.scheduled_at == expires_at
@@ -226,23 +380,63 @@ defmodule Pleroma.Web.CommonAPITest do
end
describe "reactions" do
+ test "reacting to a status with an emoji" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+
+ {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍")
+
+ assert reaction.data["actor"] == user.ap_id
+ assert reaction.data["content"] == "👍"
+
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+
+ {:error, _} = CommonAPI.react_with_emoji(activity.id, user, ".")
+ end
+
+ test "unreacting to a status with an emoji" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+ {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍")
+
+ {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
+
+ assert unreaction.data["type"] == "Undo"
+ assert unreaction.data["object"] == reaction.data["id"]
+ assert unreaction.local
+ end
+
test "repeating a status" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user)
end
+ test "can't repeat a repeat" do
+ user = insert(:user)
+ other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+
+ {:ok, %Activity{} = announce, _} = CommonAPI.repeat(activity.id, other_user)
+
+ refute match?({:ok, %Activity{}, _}, CommonAPI.repeat(announce.id, user))
+ end
+
test "repeating a status privately" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{} = announce_activity, _} =
- CommonAPI.repeat(activity.id, user, %{"visibility" => "private"})
+ CommonAPI.repeat(activity.id, user, %{visibility: "private"})
assert Visibility.is_private?(announce_activity)
end
@@ -251,27 +445,30 @@ defmodule Pleroma.Web.CommonAPITest do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
+ {:ok, post_activity} = CommonAPI.post(other_user, %{status: "cofe"})
- {:ok, %Activity{}, _} = CommonAPI.favorite(activity.id, user)
+ {:ok, %Activity{data: data}} = CommonAPI.favorite(user, post_activity.id)
+ assert data["type"] == "Like"
+ assert data["actor"] == user.ap_id
+ assert data["object"] == post_activity.data["object"]
end
- test "retweeting a status twice returns an error" do
+ test "retweeting a status twice returns the status" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
- {:ok, %Activity{}, _object} = CommonAPI.repeat(activity.id, user)
- {:error, _} = CommonAPI.repeat(activity.id, user)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+ {:ok, %Activity{} = announce, object} = CommonAPI.repeat(activity.id, user)
+ {:ok, ^announce, ^object} = CommonAPI.repeat(activity.id, user)
end
- test "favoriting a status twice returns an error" do
+ test "favoriting a status twice returns ok, but without the like activity" do
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
- {:ok, %Activity{}, _object} = CommonAPI.favorite(activity.id, user)
- {:error, _} = CommonAPI.favorite(activity.id, user)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
+ {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id)
+ assert {:ok, :already_liked} = CommonAPI.favorite(user, activity.id)
end
end
@@ -280,7 +477,7 @@ defmodule Pleroma.Web.CommonAPITest do
Pleroma.Config.put([:instance, :max_pinned_statuses], 1)
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"})
[user: user, activity: activity]
end
@@ -291,11 +488,26 @@ defmodule Pleroma.Web.CommonAPITest do
id = activity.id
user = refresh_record(user)
- assert %User{info: %{pinned_activities: [^id]}} = user
+ assert %User{pinned_activities: [^id]} = user
+ end
+
+ test "pin poll", %{user: user} do
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ status: "How is fediverse today?",
+ poll: %{options: ["Absolutely outstanding", "Not good"], expires_in: 20}
+ })
+
+ assert {:ok, ^activity} = CommonAPI.pin(activity.id, user)
+
+ id = activity.id
+ user = refresh_record(user)
+
+ assert %User{pinned_activities: [^id]} = user
end
test "unlisted statuses can be pinned", %{user: user} do
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!", "visibility" => "unlisted"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!", visibility: "unlisted"})
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user)
end
@@ -306,7 +518,7 @@ defmodule Pleroma.Web.CommonAPITest do
end
test "max pinned statuses", %{user: user, activity: activity_one} do
- {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"})
assert {:ok, ^activity_one} = CommonAPI.pin(activity_one.id, user)
@@ -321,11 +533,13 @@ defmodule Pleroma.Web.CommonAPITest do
user = refresh_record(user)
- assert {:ok, ^activity} = CommonAPI.unpin(activity.id, user)
+ id = activity.id
+
+ assert match?({:ok, %{id: ^id}}, CommonAPI.unpin(activity.id, user))
user = refresh_record(user)
- assert %User{info: %{pinned_activities: []}} = user
+ assert %User{pinned_activities: []} = user
end
test "should unpin when deleting a status", %{user: user, activity: activity} do
@@ -337,7 +551,7 @@ defmodule Pleroma.Web.CommonAPITest do
user = refresh_record(user)
- assert %User{info: %{pinned_activities: []}} = user
+ assert %User{pinned_activities: []} = user
end
end
@@ -372,7 +586,7 @@ defmodule Pleroma.Web.CommonAPITest do
reporter = insert(:user)
target_user = insert(:user)
- {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"})
+ {:ok, activity} = CommonAPI.post(target_user, %{status: "foobar"})
reporter_ap_id = reporter.ap_id
target_ap_id = target_user.ap_id
@@ -380,9 +594,17 @@ defmodule Pleroma.Web.CommonAPITest do
comment = "foobar"
report_data = %{
- "account_id" => target_user.id,
- "comment" => comment,
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: comment,
+ status_ids: [activity.id]
+ }
+
+ note_obj = %{
+ "type" => "Note",
+ "id" => activity_ap_id,
+ "content" => "foobar",
+ "published" => activity.object.data["published"],
+ "actor" => AccountView.render("show.json", %{user: target_user})
}
assert {:ok, flag_activity} = CommonAPI.report(reporter, report_data)
@@ -392,7 +614,7 @@ defmodule Pleroma.Web.CommonAPITest do
data: %{
"type" => "Flag",
"content" => ^comment,
- "object" => [^target_ap_id, ^activity_ap_id],
+ "object" => [^target_ap_id, ^note_obj],
"state" => "open"
}
} = flag_activity
@@ -404,14 +626,19 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, %Activity{id: report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
{:ok, report} = CommonAPI.update_report_state(report_id, "resolved")
assert report.data["state"] == "resolved"
+
+ [reported_user, activity_id] = report.data["object"]
+
+ assert reported_user == target_user.ap_id
+ assert activity_id == activity.data["id"]
end
test "does not update report state when state is unsupported" do
@@ -420,13 +647,42 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, %Activity{id: report_id}} =
CommonAPI.report(reporter, %{
- "account_id" => target_user.id,
- "comment" => "I feel offended",
- "status_ids" => [activity.id]
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
})
assert CommonAPI.update_report_state(report_id, "test") == {:error, "Unsupported state"}
end
+
+ test "updates state of multiple reports" do
+ [reporter, target_user] = insert_pair(:user)
+ activity = insert(:note_activity, user: target_user)
+
+ {:ok, %Activity{id: first_report_id}} =
+ CommonAPI.report(reporter, %{
+ account_id: target_user.id,
+ comment: "I feel offended",
+ status_ids: [activity.id]
+ })
+
+ {:ok, %Activity{id: second_report_id}} =
+ CommonAPI.report(reporter, %{
+ account_id: target_user.id,
+ comment: "I feel very offended!",
+ status_ids: [activity.id]
+ })
+
+ {:ok, report_ids} =
+ CommonAPI.update_report_state([first_report_id, second_report_id], "resolved")
+
+ first_report = Activity.get_by_id(first_report_id)
+ second_report = Activity.get_by_id(second_report_id)
+
+ assert report_ids -- [first_report_id, second_report_id] == []
+ assert first_report.data["state"] == "resolved"
+ assert second_report.data["state"] == "resolved"
+ end
end
describe "reblog muting" do
@@ -439,14 +695,14 @@ defmodule Pleroma.Web.CommonAPITest do
end
test "add a reblog mute", %{muter: muter, muted: muted} do
- {:ok, muter} = CommonAPI.hide_reblogs(muter, muted)
+ {:ok, _reblog_mute} = CommonAPI.hide_reblogs(muter, muted)
assert User.showing_reblogs?(muter, muted) == false
end
test "remove a reblog mute", %{muter: muter, muted: muted} do
- {:ok, muter} = CommonAPI.hide_reblogs(muter, muted)
- {:ok, muter} = CommonAPI.show_reblogs(muter, muted)
+ {:ok, _reblog_mute} = CommonAPI.hide_reblogs(muter, muted)
+ {:ok, _reblog_mute} = CommonAPI.show_reblogs(muter, muted)
assert User.showing_reblogs?(muter, muted) == true
end
@@ -456,7 +712,7 @@ defmodule Pleroma.Web.CommonAPITest do
test "also unsubscribes a user" do
[follower, followed] = insert_pair(:user)
{:ok, follower, followed, _} = CommonAPI.follow(follower, followed)
- {:ok, followed} = User.subscribe(follower, followed)
+ {:ok, _subscription} = User.subscribe(follower, followed)
assert User.subscribed_to?(follower, followed)
@@ -464,11 +720,55 @@ defmodule Pleroma.Web.CommonAPITest do
refute User.subscribed_to?(follower, followed)
end
+
+ test "cancels a pending follow for a local user" do
+ follower = insert(:user)
+ followed = insert(:user, locked: true)
+
+ assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+ CommonAPI.follow(follower, followed)
+
+ assert User.get_follow_state(follower, followed) == :follow_pending
+ assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
+ assert User.get_follow_state(follower, followed) == nil
+
+ assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
+ Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+
+ assert %{
+ data: %{
+ "type" => "Undo",
+ "object" => %{"type" => "Follow", "state" => "cancelled"}
+ }
+ } = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
+ end
+
+ test "cancels a pending follow for a remote user" do
+ follower = insert(:user)
+ followed = insert(:user, locked: true, local: false, ap_enabled: true)
+
+ assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+ CommonAPI.follow(follower, followed)
+
+ assert User.get_follow_state(follower, followed) == :follow_pending
+ assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
+ assert User.get_follow_state(follower, followed) == nil
+
+ assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
+ Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+
+ assert %{
+ data: %{
+ "type" => "Undo",
+ "object" => %{"type" => "Follow", "state" => "cancelled"}
+ }
+ } = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
+ end
end
describe "accept_follow_request/2" do
test "after acceptance, it sets all existing pending follow request states to 'accept'" do
- user = insert(:user, info: %{locked: true})
+ user = insert(:user, locked: true)
follower = insert(:user)
follower_two = insert(:user)
@@ -488,7 +788,7 @@ defmodule Pleroma.Web.CommonAPITest do
end
test "after rejection, it sets all existing pending follow request states to 'reject'" do
- user = insert(:user, info: %{locked: true})
+ user = insert(:user, locked: true)
follower = insert(:user)
follower_two = insert(:user)
@@ -506,6 +806,14 @@ defmodule Pleroma.Web.CommonAPITest do
assert Repo.get(Activity, follow_activity_two.id).data["state"] == "reject"
assert Repo.get(Activity, follow_activity_three.id).data["state"] == "pending"
end
+
+ test "doesn't create a following relationship if the corresponding follow request doesn't exist" do
+ user = insert(:user, locked: true)
+ not_follower = insert(:user)
+ CommonAPI.accept_follow_request(not_follower, user)
+
+ assert Pleroma.FollowingRelationship.following?(not_follower, user) == false
+ end
end
describe "vote/3" do
@@ -515,8 +823,8 @@ defmodule Pleroma.Web.CommonAPITest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Am I cute?",
- "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}
+ status: "Am I cute?",
+ poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity)
diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs
index 2588898d0..5708db6a4 100644
--- a/test/web/common_api/common_api_utils_test.exs
+++ b/test/web/common_api/common_api_utils_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPI.UtilsTest do
@@ -7,7 +7,6 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
alias Pleroma.Object
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
- alias Pleroma.Web.Endpoint
use Pleroma.DataCase
import ExUnit.CaptureLog
@@ -42,28 +41,6 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
end
end
- test "parses emoji from name and bio" do
- {:ok, user} = UserBuilder.insert(%{name: ":blank:", bio: ":firefox:"})
-
- expected = [
- %{
- "type" => "Emoji",
- "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}/emoji/Firefox.gif"},
- "name" => ":firefox:"
- },
- %{
- "type" => "Emoji",
- "icon" => %{
- "type" => "Image",
- "url" => "#{Endpoint.url()}/emoji/blank.png"
- },
- "name" => ":blank:"
- }
- ]
-
- assert expected == Utils.emoji_from_profile(user)
- end
-
describe "format_input/3" do
test "works for bare text/plain" do
text = "hello world!"
@@ -89,8 +66,8 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert output == expected
- text = "<p>hello world!</p>\n\n<p>second paragraph</p>"
- expected = "<p>hello world!</p>\n\n<p>second paragraph</p>"
+ text = "<p>hello world!</p><br/>\n<p>second paragraph</p>"
+ expected = "<p>hello world!</p><br/>\n<p>second paragraph</p>"
{output, [], []} = Utils.format_input(text, "text/html")
@@ -99,14 +76,14 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
test "works for bare text/markdown" do
text = "**hello world**"
- expected = "<p><strong>hello world</strong></p>\n"
+ expected = "<p><strong>hello world</strong></p>"
{output, [], []} = Utils.format_input(text, "text/markdown")
assert output == expected
text = "**hello world**\n\n*another paragraph*"
- expected = "<p><strong>hello world</strong></p>\n<p><em>another paragraph</em></p>\n"
+ expected = "<p><strong>hello world</strong></p><p><em>another paragraph</em></p>"
{output, [], []} = Utils.format_input(text, "text/markdown")
@@ -118,7 +95,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
by someone
"""
- expected = "<blockquote><p>cool quote</p>\n</blockquote>\n<p>by someone</p>\n"
+ expected = "<blockquote><p>cool quote</p></blockquote><p>by someone</p>"
{output, [], []} = Utils.format_input(text, "text/markdown")
@@ -134,7 +111,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert output == expected
text = "[b]hello world![/b]\n\nsecond paragraph!"
- expected = "<strong>hello world!</strong><br>\n<br>\nsecond paragraph!"
+ expected = "<strong>hello world!</strong><br><br>second paragraph!"
{output, [], []} = Utils.format_input(text, "text/bbcode")
@@ -143,7 +120,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
text = "[b]hello world![/b]\n\n<strong>second paragraph!</strong>"
expected =
- "<strong>hello world!</strong><br>\n<br>\n&lt;strong&gt;second paragraph!&lt;/strong&gt;"
+ "<strong>hello world!</strong><br><br>&lt;strong&gt;second paragraph!&lt;/strong&gt;"
{output, [], []} = Utils.format_input(text, "text/bbcode")
@@ -156,16 +133,14 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
text = "**hello world**\n\n*another @user__test and @user__test google.com paragraph*"
- expected =
- ~s(<p><strong>hello world</strong></p>\n<p><em>another <span class="h-card"><a data-user="#{
- user.id
- }" class="u-url mention" href="http://foo.com/user__test" rel="ugc">@<span>user__test</span></a></span> and <span class="h-card"><a data-user="#{
- user.id
- }" class="u-url mention" href="http://foo.com/user__test" rel="ugc">@<span>user__test</span></a></span> <a href="http://google.com" rel="ugc">google.com</a> paragraph</em></p>\n)
-
{output, _, _} = Utils.format_input(text, "text/markdown")
- assert output == expected
+ assert output ==
+ ~s(<p><strong>hello world</strong></p><p><em>another <span class="h-card"><a class="u-url mention" data-user="#{
+ user.id
+ }" href="http://foo.com/user__test" rel="ugc">@<span>user__test</span></a></span> and <span class="h-card"><a class="u-url mention" data-user="#{
+ user.id
+ }" href="http://foo.com/user__test" rel="ugc">@<span>user__test</span></a></span> <a href="http://google.com" rel="ugc">google.com</a> paragraph</em></p>)
end
end
@@ -253,7 +228,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
user = insert(:user)
mentioned_user = insert(:user)
third_user = insert(:user)
- {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
+ {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"})
mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "public", nil)
@@ -286,7 +261,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
user = insert(:user)
mentioned_user = insert(:user)
third_user = insert(:user)
- {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
+ {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"})
mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "unlisted", nil)
@@ -307,7 +282,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "private", nil)
assert length(to) == 2
- assert length(cc) == 0
+ assert Enum.empty?(cc)
assert mentioned_user.ap_id in to
assert user.follower_address in to
@@ -317,13 +292,13 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
user = insert(:user)
mentioned_user = insert(:user)
third_user = insert(:user)
- {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
+ {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"})
mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private", nil)
assert length(to) == 3
- assert length(cc) == 0
+ assert Enum.empty?(cc)
assert mentioned_user.ap_id in to
assert third_user.ap_id in to
@@ -338,7 +313,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "direct", nil)
assert length(to) == 1
- assert length(cc) == 0
+ assert Enum.empty?(cc)
assert mentioned_user.ap_id in to
end
@@ -347,39 +322,19 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
user = insert(:user)
mentioned_user = insert(:user)
third_user = insert(:user)
- {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
+ {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"})
mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct", nil)
assert length(to) == 2
- assert length(cc) == 0
+ assert Enum.empty?(cc)
assert mentioned_user.ap_id in to
assert third_user.ap_id in to
end
end
- describe "get_by_id_or_ap_id/1" do
- test "get activity by id" do
- activity = insert(:note_activity)
- %Pleroma.Activity{} = note = Utils.get_by_id_or_ap_id(activity.id)
- assert note.id == activity.id
- end
-
- test "get activity by ap_id" do
- activity = insert(:note_activity)
- %Pleroma.Activity{} = note = Utils.get_by_id_or_ap_id(activity.data["object"])
- assert note.id == activity.id
- end
-
- test "get activity by object when type isn't `Create` " do
- activity = insert(:like_activity)
- %Pleroma.Activity{} = like = Utils.get_by_id_or_ap_id(activity.id)
- assert like.data["object"] == activity.data["object"]
- end
- end
-
describe "to_master_date/1" do
test "removes microseconds from date (NaiveDateTime)" do
assert Utils.to_masto_date(~N[2015-01-23 23:50:07.123]) == "2015-01-23T23:50:07.000Z"
@@ -474,6 +429,13 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
activity = insert(:note_activity, user: user, note: object)
Pleroma.Repo.delete(object)
+ obj_url = activity.data["object"]
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^obj_url} ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
assert Utils.maybe_notify_mentioned_recipients(["test-test"], activity) == [
"test-test"
]
@@ -501,8 +463,8 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
desc = Jason.encode!(%{object.id => "test-desc"})
assert Utils.attachments_from_ids(%{
- "media_ids" => ["#{object.id}"],
- "descriptions" => desc
+ media_ids: ["#{object.id}"],
+ descriptions: desc
}) == [
Map.merge(object.data, %{"name" => "test-desc"})
]
@@ -510,7 +472,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
test "returns attachments without descs" do
object = insert(:note)
- assert Utils.attachments_from_ids(%{"media_ids" => ["#{object.id}"]}) == [object.data]
+ assert Utils.attachments_from_ids(%{media_ids: ["#{object.id}"]}) == [object.data]
end
test "returns [] when not pass media_ids" do
@@ -575,11 +537,11 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
end
describe "maybe_add_attachments/3" do
- test "returns parsed results when no_links is true" do
+ test "returns parsed results when attachment_links is false" do
assert Utils.maybe_add_attachments(
{"test", [], ["tags"]},
[],
- true
+ false
) == {"test", [], ["tags"]}
end
@@ -589,7 +551,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert Utils.maybe_add_attachments(
{"test", [], ["tags"]},
[attachment],
- false
+ true
) == {
"test<br><a href=\"SakuraPM.png\" class='attachment'>SakuraPM.png</a>",
[],
diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs
index c13db9526..3919ef93a 100644
--- a/test/web/fallback_test.exs
+++ b/test/web/fallback_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.FallbackTest do
diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs
index bdaefdce1..de90aa6e0 100644
--- a/test/web/federator_test.exs
+++ b/test/web/federator_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.FederatorTest do
@@ -21,18 +21,15 @@ defmodule Pleroma.Web.FederatorTest do
:ok
end
- clear_config_all([:instance, :federating]) do
- Pleroma.Config.put([:instance, :federating], true)
- end
-
- clear_config([:instance, :allow_relay])
- clear_config([:instance, :rewrite_policy])
- clear_config([:mrf_keyword])
+ setup_all do: clear_config([:instance, :federating], true)
+ setup do: clear_config([:instance, :allow_relay])
+ setup do: clear_config([:instance, :rewrite_policy])
+ setup do: clear_config([:mrf_keyword])
describe "Publish an activity" do
setup do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI"})
relay_mock = {
Pleroma.Web.ActivityPub.Relay,
@@ -81,14 +78,16 @@ defmodule Pleroma.Web.FederatorTest do
local: false,
nickname: "nick1@domain.com",
ap_id: "https://domain.com/users/nick1",
- info: %{ap_enabled: true, source_data: %{"inbox" => inbox1}}
+ inbox: inbox1,
+ ap_enabled: true
})
insert(:user, %{
local: false,
nickname: "nick2@domain2.com",
ap_id: "https://domain2.com/users/nick2",
- info: %{ap_enabled: true, source_data: %{"inbox" => inbox2}}
+ inbox: inbox2,
+ ap_enabled: true
})
dt = NaiveDateTime.utc_now()
@@ -97,7 +96,7 @@ defmodule Pleroma.Web.FederatorTest do
Instances.set_consistently_unreachable(URI.parse(inbox2).host)
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "HI @nick1@domain.com, @nick2@domain2.com!"})
+ CommonAPI.post(user, %{status: "HI @nick1@domain.com, @nick2@domain2.com!"})
expected_dt = NaiveDateTime.to_iso8601(dt)
@@ -131,6 +130,9 @@ defmodule Pleroma.Web.FederatorTest do
assert {:ok, job} = Federator.incoming_ap_doc(params)
assert {:ok, _activity} = ObanHelpers.perform(job)
+
+ assert {:ok, job} = Federator.incoming_ap_doc(params)
+ assert {:error, :already_present} = ObanHelpers.perform(job)
end
test "rejects incoming AP docs with incorrect origin" do
@@ -149,7 +151,7 @@ defmodule Pleroma.Web.FederatorTest do
}
assert {:ok, job} = Federator.incoming_ap_doc(params)
- assert :error = ObanHelpers.perform(job)
+ assert {:error, :origin_containment_failed} = ObanHelpers.perform(job)
end
test "it does not crash if MRF rejects the post" do
@@ -165,7 +167,7 @@ defmodule Pleroma.Web.FederatorTest do
|> Poison.decode!()
assert {:ok, job} = Federator.incoming_ap_doc(params)
- assert :error = ObanHelpers.perform(job)
+ assert {:error, _} = ObanHelpers.perform(job)
end
end
end
diff --git a/test/web/feed/feed_controller_test.exs b/test/web/feed/feed_controller_test.exs
deleted file mode 100644
index 1f44eae20..000000000
--- a/test/web/feed/feed_controller_test.exs
+++ /dev/null
@@ -1,227 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.Feed.FeedControllerTest do
- use Pleroma.Web.ConnCase
-
- import Pleroma.Factory
-
- alias Pleroma.Object
- alias Pleroma.User
-
- test "gets a feed", %{conn: conn} do
- activity = insert(:note_activity)
-
- note =
- insert(:note,
- data: %{
- "attachment" => [
- %{
- "url" => [%{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"}]
- }
- ],
- "inReplyTo" => activity.data["id"]
- }
- )
-
- note_activity = insert(:note_activity, note: note)
- object = Object.normalize(note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
-
- conn =
- conn
- |> put_req_header("content-type", "application/atom+xml")
- |> get("/users/#{user.nickname}/feed.atom")
-
- assert response(conn, 200) =~ object.data["content"]
- end
-
- test "returns 404 for a missing feed", %{conn: conn} do
- conn =
- conn
- |> put_req_header("content-type", "application/atom+xml")
- |> get("/users/nonexisting/feed.atom")
-
- assert response(conn, 404)
- end
-
- describe "feed_redirect" do
- test "undefined format. it redirects to feed", %{conn: conn} do
- note_activity = insert(:note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
-
- response =
- conn
- |> put_req_header("accept", "application/xml")
- |> get("/users/#{user.nickname}")
- |> response(302)
-
- assert response ==
- "<html><body>You are being <a href=\"#{Pleroma.Web.base_url()}/users/#{
- user.nickname
- }/feed.atom\">redirected</a>.</body></html>"
- end
-
- test "undefined format. it returns error when user not found", %{conn: conn} do
- response =
- conn
- |> put_req_header("accept", "application/xml")
- |> get("/users/jimm")
- |> response(404)
-
- assert response == ~S({"error":"Not found"})
- end
-
- test "activity+json format. it redirects on actual feed of user", %{conn: conn} do
- note_activity = insert(:note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
-
- response =
- conn
- |> put_req_header("accept", "application/activity+json")
- |> get("/users/#{user.nickname}")
- |> json_response(200)
-
- assert response["endpoints"] == %{
- "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize",
- "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps",
- "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token",
- "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox",
- "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media"
- }
-
- assert response["@context"] == [
- "https://www.w3.org/ns/activitystreams",
- "http://localhost:4001/schemas/litepub-0.1.jsonld",
- %{"@language" => "und"}
- ]
-
- assert Map.take(response, [
- "followers",
- "following",
- "id",
- "inbox",
- "manuallyApprovesFollowers",
- "name",
- "outbox",
- "preferredUsername",
- "summary",
- "tag",
- "type",
- "url"
- ]) == %{
- "followers" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/followers",
- "following" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/following",
- "id" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}",
- "inbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/inbox",
- "manuallyApprovesFollowers" => false,
- "name" => user.name,
- "outbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/outbox",
- "preferredUsername" => user.nickname,
- "summary" => user.bio,
- "tag" => [],
- "type" => "Person",
- "url" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}"
- }
- end
-
- test "activity+json format. it returns error whe use not found", %{conn: conn} do
- response =
- conn
- |> put_req_header("accept", "application/activity+json")
- |> get("/users/jimm")
- |> json_response(404)
-
- assert response == "Not found"
- end
-
- test "json format. it redirects on actual feed of user", %{conn: conn} do
- note_activity = insert(:note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
-
- response =
- conn
- |> put_req_header("accept", "application/json")
- |> get("/users/#{user.nickname}")
- |> json_response(200)
-
- assert response["endpoints"] == %{
- "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize",
- "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps",
- "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token",
- "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox",
- "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media"
- }
-
- assert response["@context"] == [
- "https://www.w3.org/ns/activitystreams",
- "http://localhost:4001/schemas/litepub-0.1.jsonld",
- %{"@language" => "und"}
- ]
-
- assert Map.take(response, [
- "followers",
- "following",
- "id",
- "inbox",
- "manuallyApprovesFollowers",
- "name",
- "outbox",
- "preferredUsername",
- "summary",
- "tag",
- "type",
- "url"
- ]) == %{
- "followers" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/followers",
- "following" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/following",
- "id" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}",
- "inbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/inbox",
- "manuallyApprovesFollowers" => false,
- "name" => user.name,
- "outbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/outbox",
- "preferredUsername" => user.nickname,
- "summary" => user.bio,
- "tag" => [],
- "type" => "Person",
- "url" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}"
- }
- end
-
- test "json format. it returns error whe use not found", %{conn: conn} do
- response =
- conn
- |> put_req_header("accept", "application/json")
- |> get("/users/jimm")
- |> json_response(404)
-
- assert response == "Not found"
- end
-
- test "html format. it redirects on actual feed of user", %{conn: conn} do
- note_activity = insert(:note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
-
- response =
- conn
- |> get("/users/#{user.nickname}")
- |> response(200)
-
- assert response ==
- Fallback.RedirectController.redirector_with_meta(
- conn,
- %{user: user}
- ).resp_body
- end
-
- test "html format. it returns error when user not found", %{conn: conn} do
- response =
- conn
- |> get("/users/jimm")
- |> json_response(404)
-
- assert response == %{"error" => "Not found"}
- end
- end
-end
diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs
new file mode 100644
index 000000000..3c29cd94f
--- /dev/null
+++ b/test/web/feed/tag_controller_test.exs
@@ -0,0 +1,184 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Feed.TagControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+ import SweetXml
+
+ alias Pleroma.Object
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.Feed.FeedView
+
+ setup do: clear_config([:feed])
+
+ test "gets a feed (ATOM)", %{conn: conn} do
+ Pleroma.Config.put(
+ [:feed, :post_title],
+ %{max_length: 25, omission: "..."}
+ )
+
+ user = insert(:user)
+ {:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"})
+
+ object = Object.normalize(activity1)
+
+ object_data =
+ Map.put(object.data, "attachment", [
+ %{
+ "url" => [
+ %{
+ "href" =>
+ "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
+ "mediaType" => "video/mp4",
+ "type" => "Link"
+ }
+ ]
+ }
+ ])
+
+ object
+ |> Ecto.Changeset.change(data: object_data)
+ |> Pleroma.Repo.update()
+
+ {:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"})
+
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"})
+
+ response =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get(tag_feed_path(conn, :feed, "pleromaart.atom"))
+ |> response(200)
+
+ xml = parse(response)
+
+ assert xpath(xml, ~x"//feed/title/text()") == '#pleromaart'
+
+ assert xpath(xml, ~x"//feed/entry/title/text()"l) == [
+ '42 This is :moominmamm...',
+ 'yeah #PleromaArt'
+ ]
+
+ assert xpath(xml, ~x"//feed/entry/author/name/text()"ls) == [user.nickname, user.nickname]
+ assert xpath(xml, ~x"//feed/entry/author/id/text()"ls) == [user.ap_id, user.ap_id]
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get("/tags/pleromaart.atom", %{"max_id" => activity2.id})
+
+ assert get_resp_header(conn, "content-type") == ["application/atom+xml; charset=utf-8"]
+ resp = response(conn, 200)
+ xml = parse(resp)
+
+ assert xpath(xml, ~x"//feed/title/text()") == '#pleromaart'
+
+ assert xpath(xml, ~x"//feed/entry/title/text()"l) == [
+ 'yeah #PleromaArt'
+ ]
+ end
+
+ test "gets a feed (RSS)", %{conn: conn} do
+ Pleroma.Config.put(
+ [:feed, :post_title],
+ %{max_length: 25, omission: "..."}
+ )
+
+ user = insert(:user)
+ {:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"})
+
+ object = Object.normalize(activity1)
+
+ object_data =
+ Map.put(object.data, "attachment", [
+ %{
+ "url" => [
+ %{
+ "href" =>
+ "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
+ "mediaType" => "video/mp4",
+ "type" => "Link"
+ }
+ ]
+ }
+ ])
+
+ object
+ |> Ecto.Changeset.change(data: object_data)
+ |> Pleroma.Repo.update()
+
+ {:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"})
+
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"})
+
+ response =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
+ |> response(200)
+
+ xml = parse(response)
+ assert xpath(xml, ~x"//channel/title/text()") == '#pleromaart'
+
+ assert xpath(xml, ~x"//channel/description/text()"s) ==
+ "These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse."
+
+ assert xpath(xml, ~x"//channel/link/text()") ==
+ '#{Pleroma.Web.base_url()}/tags/pleromaart.rss'
+
+ assert xpath(xml, ~x"//channel/webfeeds:logo/text()") ==
+ '#{Pleroma.Web.base_url()}/static/logo.png'
+
+ assert xpath(xml, ~x"//channel/item/title/text()"l) == [
+ '42 This is :moominmamm...',
+ 'yeah #PleromaArt'
+ ]
+
+ assert xpath(xml, ~x"//channel/item/pubDate/text()"sl) == [
+ FeedView.pub_date(activity2.data["published"]),
+ FeedView.pub_date(activity1.data["published"])
+ ]
+
+ assert xpath(xml, ~x"//channel/item/enclosure/@url"sl) == [
+ "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4"
+ ]
+
+ obj1 = Object.normalize(activity1)
+ obj2 = Object.normalize(activity2)
+
+ assert xpath(xml, ~x"//channel/item/description/text()"sl) == [
+ HtmlEntities.decode(FeedView.activity_content(obj2.data)),
+ HtmlEntities.decode(FeedView.activity_content(obj1.data))
+ ]
+
+ response =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get(tag_feed_path(conn, :feed, "pleromaart"))
+ |> response(200)
+
+ xml = parse(response)
+ assert xpath(xml, ~x"//channel/title/text()") == '#pleromaart'
+
+ assert xpath(xml, ~x"//channel/description/text()"s) ==
+ "These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse."
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get("/tags/pleromaart.rss", %{"max_id" => activity2.id})
+
+ assert get_resp_header(conn, "content-type") == ["application/rss+xml; charset=utf-8"]
+ resp = response(conn, 200)
+ xml = parse(resp)
+
+ assert xpath(xml, ~x"//channel/title/text()") == '#pleromaart'
+
+ assert xpath(xml, ~x"//channel/item/title/text()"l) == [
+ 'yeah #PleromaArt'
+ ]
+ end
+end
diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs
new file mode 100644
index 000000000..05ad427c2
--- /dev/null
+++ b/test/web/feed/user_controller_test.exs
@@ -0,0 +1,214 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Feed.UserControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+ import SweetXml
+
+ alias Pleroma.Config
+ alias Pleroma.Object
+ alias Pleroma.User
+
+ setup do: clear_config([:instance, :federating], true)
+
+ describe "feed" do
+ setup do: clear_config([:feed])
+
+ test "gets a feed", %{conn: conn} do
+ Config.put(
+ [:feed, :post_title],
+ %{max_length: 10, omission: "..."}
+ )
+
+ activity = insert(:note_activity)
+
+ note =
+ insert(:note,
+ data: %{
+ "content" => "This is :moominmamma: note ",
+ "attachment" => [
+ %{
+ "url" => [
+ %{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"}
+ ]
+ }
+ ],
+ "inReplyTo" => activity.data["id"]
+ }
+ )
+
+ note_activity = insert(:note_activity, note: note)
+ user = User.get_cached_by_ap_id(note_activity.data["actor"])
+
+ note2 =
+ insert(:note,
+ user: user,
+ data: %{
+ "content" => "42 This is :moominmamma: note ",
+ "inReplyTo" => activity.data["id"]
+ }
+ )
+
+ note_activity2 = insert(:note_activity, note: note2)
+ object = Object.normalize(note_activity)
+
+ resp =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get(user_feed_path(conn, :feed, user.nickname))
+ |> response(200)
+
+ activity_titles =
+ resp
+ |> SweetXml.parse()
+ |> SweetXml.xpath(~x"//entry/title/text()"l)
+
+ assert activity_titles == ['42 This...', 'This is...']
+ assert resp =~ object.data["content"]
+
+ resp =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get("/users/#{user.nickname}/feed", %{"max_id" => note_activity2.id})
+ |> response(200)
+
+ activity_titles =
+ resp
+ |> SweetXml.parse()
+ |> SweetXml.xpath(~x"//entry/title/text()"l)
+
+ assert activity_titles == ['This is...']
+ end
+
+ test "gets a rss feed", %{conn: conn} do
+ Pleroma.Config.put(
+ [:feed, :post_title],
+ %{max_length: 10, omission: "..."}
+ )
+
+ activity = insert(:note_activity)
+
+ note =
+ insert(:note,
+ data: %{
+ "content" => "This is :moominmamma: note ",
+ "attachment" => [
+ %{
+ "url" => [
+ %{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"}
+ ]
+ }
+ ],
+ "inReplyTo" => activity.data["id"]
+ }
+ )
+
+ note_activity = insert(:note_activity, note: note)
+ user = User.get_cached_by_ap_id(note_activity.data["actor"])
+
+ note2 =
+ insert(:note,
+ user: user,
+ data: %{
+ "content" => "42 This is :moominmamma: note ",
+ "inReplyTo" => activity.data["id"]
+ }
+ )
+
+ note_activity2 = insert(:note_activity, note: note2)
+ object = Object.normalize(note_activity)
+
+ resp =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get("/users/#{user.nickname}/feed.rss")
+ |> response(200)
+
+ activity_titles =
+ resp
+ |> SweetXml.parse()
+ |> SweetXml.xpath(~x"//item/title/text()"l)
+
+ assert activity_titles == ['42 This...', 'This is...']
+ assert resp =~ object.data["content"]
+
+ resp =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get("/users/#{user.nickname}/feed.rss", %{"max_id" => note_activity2.id})
+ |> response(200)
+
+ activity_titles =
+ resp
+ |> SweetXml.parse()
+ |> SweetXml.xpath(~x"//item/title/text()"l)
+
+ assert activity_titles == ['This is...']
+ end
+
+ test "returns 404 for a missing feed", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get(user_feed_path(conn, :feed, "nonexisting"))
+
+ assert response(conn, 404)
+ end
+ end
+
+ # Note: see ActivityPubControllerTest for JSON format tests
+ describe "feed_redirect" do
+ test "with html format, it redirects to user feed", %{conn: conn} do
+ note_activity = insert(:note_activity)
+ user = User.get_cached_by_ap_id(note_activity.data["actor"])
+
+ response =
+ conn
+ |> get("/users/#{user.nickname}")
+ |> response(200)
+
+ assert response ==
+ Fallback.RedirectController.redirector_with_meta(
+ conn,
+ %{user: user}
+ ).resp_body
+ end
+
+ test "with html format, it returns error when user is not found", %{conn: conn} do
+ response =
+ conn
+ |> get("/users/jimm")
+ |> json_response(404)
+
+ assert response == %{"error" => "Not found"}
+ end
+
+ test "with non-html / non-json format, it redirects to user feed in atom format", %{
+ conn: conn
+ } do
+ note_activity = insert(:note_activity)
+ user = User.get_cached_by_ap_id(note_activity.data["actor"])
+
+ conn =
+ conn
+ |> put_req_header("accept", "application/xml")
+ |> get("/users/#{user.nickname}")
+
+ assert conn.status == 302
+ assert redirected_to(conn) == "#{Pleroma.Web.base_url()}/users/#{user.nickname}/feed.atom"
+ end
+
+ test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do
+ response =
+ conn
+ |> put_req_header("accept", "application/xml")
+ |> get(user_feed_path(conn, :feed, "jimm"))
+ |> response(404)
+
+ assert response == ~S({"error":"Not found"})
+ end
+ end
+end
diff --git a/test/web/instances/instance_test.exs b/test/web/instances/instance_test.exs
index e54d708ad..e463200ca 100644
--- a/test/web/instances/instance_test.exs
+++ b/test/web/instances/instance_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Instances.InstanceTest do
@@ -10,9 +10,7 @@ defmodule Pleroma.Instances.InstanceTest do
import Pleroma.Factory
- clear_config_all([:instance, :federation_reachability_timeout_days]) do
- Pleroma.Config.put([:instance, :federation_reachability_timeout_days], 1)
- end
+ setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
describe "set_reachable/1" do
test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do
diff --git a/test/web/instances/instances_test.exs b/test/web/instances/instances_test.exs
index 65b03b155..d2618025c 100644
--- a/test/web/instances/instances_test.exs
+++ b/test/web/instances/instances_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.InstancesTest do
@@ -7,9 +7,7 @@ defmodule Pleroma.InstancesTest do
use Pleroma.DataCase
- clear_config_all([:instance, :federation_reachability_timeout_days]) do
- Pleroma.Config.put([:instance, :federation_reachability_timeout_days], 1)
- end
+ setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
describe "reachable?/1" do
test "returns `true` for host / url with unknown reachability status" do
diff --git a/test/web/masto_fe_controller_test.exs b/test/web/masto_fe_controller_test.exs
index ab9dab352..1d107d56c 100644
--- a/test/web/masto_fe_controller_test.exs
+++ b/test/web/masto_fe_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastoFEController do
@@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.MastoFEController do
import Pleroma.Factory
- clear_config([:instance, :public])
+ setup do: clear_config([:instance, :public])
test "put settings", %{conn: conn} do
user = insert(:user)
@@ -18,12 +18,13 @@ defmodule Pleroma.Web.MastodonAPI.MastoFEController do
conn =
conn
|> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:accounts"]))
|> put("/api/web/settings", %{"data" => %{"programming" => "socks"}})
assert _result = json_response(conn, 200)
user = User.get_cached_by_ap_id(user.ap_id)
- assert user.info.settings == %{"programming" => "socks"}
+ assert user.settings == %{"programming" => "socks"}
end
describe "index/2 redirections" do
@@ -63,12 +64,12 @@ defmodule Pleroma.Web.MastodonAPI.MastoFEController do
end
test "does not redirect logged in users to the login page", %{conn: conn, path: path} do
- token = insert(:oauth_token)
+ token = insert(:oauth_token, scopes: ["read"])
conn =
conn
|> assign(:user, token.user)
- |> put_session(:oauth_token, token.token)
+ |> assign(:token, token)
|> get(path)
assert conn.status == 200
diff --git a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs
index 618031b40..fdb6d4c5d 100644
--- a/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs
+++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
@@ -9,16 +9,16 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
- clear_config([:instance, :max_account_fields])
+
+ setup do: clear_config([:instance, :max_account_fields])
describe "updating credentials" do
- test "sets user settings in a generic way", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+ setup :request_content_type
+ test "sets user settings in a generic way", %{conn: conn} do
res_conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{
+ patch(conn, "/api/v1/accounts/update_credentials", %{
"pleroma_settings_store" => %{
pleroma_fe: %{
theme: "bla"
@@ -26,10 +26,10 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
}
})
- assert user = json_response(res_conn, 200)
- assert user["pleroma"]["settings_store"] == %{"pleroma_fe" => %{"theme" => "bla"}}
+ assert user_data = json_response_and_validate_schema(res_conn, 200)
+ assert user_data["pleroma"]["settings_store"] == %{"pleroma_fe" => %{"theme" => "bla"}}
- user = Repo.get(User, user["id"])
+ user = Repo.get(User, user_data["id"])
res_conn =
conn
@@ -42,15 +42,15 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
}
})
- assert user = json_response(res_conn, 200)
+ assert user_data = json_response_and_validate_schema(res_conn, 200)
- assert user["pleroma"]["settings_store"] ==
+ assert user_data["pleroma"]["settings_store"] ==
%{
"pleroma_fe" => %{"theme" => "bla"},
"masto_fe" => %{"theme" => "bla"}
}
- user = Repo.get(User, user["id"])
+ user = Repo.get(User, user_data["id"])
res_conn =
conn
@@ -63,9 +63,9 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
}
})
- assert user = json_response(res_conn, 200)
+ assert user_data = json_response_and_validate_schema(res_conn, 200)
- assert user["pleroma"]["settings_store"] ==
+ assert user_data["pleroma"]["settings_store"] ==
%{
"pleroma_fe" => %{"theme" => "bla"},
"masto_fe" => %{"theme" => "blub"}
@@ -73,188 +73,149 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
end
test "updates the user's bio", %{conn: conn} do
- user = insert(:user)
user2 = insert(:user)
conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{
- "note" => "I drink #cofe with @#{user2.nickname}"
+ patch(conn, "/api/v1/accounts/update_credentials", %{
+ "note" => "I drink #cofe with @#{user2.nickname}\n\nsuya.."
})
- assert user = json_response(conn, 200)
+ assert user_data = json_response_and_validate_schema(conn, 200)
- assert user["note"] ==
- ~s(I drink <a class="hashtag" data-tag="cofe" href="http://localhost:4001/tag/cofe">#cofe</a> with <span class="h-card"><a data-user="#{
+ assert user_data["note"] ==
+ ~s(I drink <a class="hashtag" data-tag="cofe" href="http://localhost:4001/tag/cofe">#cofe</a> with <span class="h-card"><a class="u-url mention" data-user="#{
user2.id
- }" class="u-url mention" href="#{user2.ap_id}" rel="ugc">@<span>#{user2.nickname}</span></a></span>)
+ }" href="#{user2.ap_id}" rel="ugc">@<span>#{user2.nickname}</span></a></span><br/><br/>suya..)
end
test "updates the user's locking status", %{conn: conn} do
- user = insert(:user)
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{locked: "true"})
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{locked: "true"})
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["locked"] == true
+ end
+
+ test "updates the user's allow_following_move", %{user: user, conn: conn} do
+ assert user.allow_following_move == true
+
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{allow_following_move: "false"})
- assert user = json_response(conn, 200)
- assert user["locked"] == true
+ assert refresh_record(user).allow_following_move == false
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["pleroma"]["allow_following_move"] == false
end
test "updates the user's default scope", %{conn: conn} do
- user = insert(:user)
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{default_scope: "unlisted"})
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{default_scope: "cofe"})
-
- assert user = json_response(conn, 200)
- assert user["source"]["privacy"] == "cofe"
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["source"]["privacy"] == "unlisted"
end
test "updates the user's hide_followers status", %{conn: conn} do
- user = insert(:user)
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_followers: "true"})
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{hide_followers: "true"})
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["pleroma"]["hide_followers"] == true
+ end
- assert user = json_response(conn, 200)
- assert user["pleroma"]["hide_followers"] == true
+ test "updates the user's discoverable status", %{conn: conn} do
+ assert %{"source" => %{"pleroma" => %{"discoverable" => true}}} =
+ conn
+ |> patch("/api/v1/accounts/update_credentials", %{discoverable: "true"})
+ |> json_response_and_validate_schema(:ok)
+
+ assert %{"source" => %{"pleroma" => %{"discoverable" => false}}} =
+ conn
+ |> patch("/api/v1/accounts/update_credentials", %{discoverable: "false"})
+ |> json_response_and_validate_schema(:ok)
end
test "updates the user's hide_followers_count and hide_follows_count", %{conn: conn} do
- user = insert(:user)
-
conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{
+ patch(conn, "/api/v1/accounts/update_credentials", %{
hide_followers_count: "true",
hide_follows_count: "true"
})
- assert user = json_response(conn, 200)
- assert user["pleroma"]["hide_followers_count"] == true
- assert user["pleroma"]["hide_follows_count"] == true
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["pleroma"]["hide_followers_count"] == true
+ assert user_data["pleroma"]["hide_follows_count"] == true
end
- test "updates the user's skip_thread_containment option", %{conn: conn} do
- user = insert(:user)
-
+ test "updates the user's skip_thread_containment option", %{user: user, conn: conn} do
response =
conn
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", %{skip_thread_containment: "true"})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response["pleroma"]["skip_thread_containment"] == true
- assert refresh_record(user).info.skip_thread_containment
+ assert refresh_record(user).skip_thread_containment
end
test "updates the user's hide_follows status", %{conn: conn} do
- user = insert(:user)
-
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{hide_follows: "true"})
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_follows: "true"})
- assert user = json_response(conn, 200)
- assert user["pleroma"]["hide_follows"] == true
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["pleroma"]["hide_follows"] == true
end
test "updates the user's hide_favorites status", %{conn: conn} do
- user = insert(:user)
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_favorites: "true"})
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{hide_favorites: "true"})
-
- assert user = json_response(conn, 200)
- assert user["pleroma"]["hide_favorites"] == true
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["pleroma"]["hide_favorites"] == true
end
test "updates the user's show_role status", %{conn: conn} do
- user = insert(:user)
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{show_role: "false"})
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{show_role: "false"})
-
- assert user = json_response(conn, 200)
- assert user["source"]["pleroma"]["show_role"] == false
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["source"]["pleroma"]["show_role"] == false
end
test "updates the user's no_rich_text status", %{conn: conn} do
- user = insert(:user)
-
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{no_rich_text: "true"})
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{no_rich_text: "true"})
- assert user = json_response(conn, 200)
- assert user["source"]["pleroma"]["no_rich_text"] == true
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["source"]["pleroma"]["no_rich_text"] == true
end
test "updates the user's name", %{conn: conn} do
- user = insert(:user)
-
conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "markorepairs"})
+ patch(conn, "/api/v1/accounts/update_credentials", %{"display_name" => "markorepairs"})
- assert user = json_response(conn, 200)
- assert user["display_name"] == "markorepairs"
+ assert user_data = json_response_and_validate_schema(conn, 200)
+ assert user_data["display_name"] == "markorepairs"
end
- test "updates the user's avatar", %{conn: conn} do
- user = insert(:user)
-
+ test "updates the user's avatar", %{user: user, conn: conn} do
new_avatar = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar})
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar})
- assert user_response = json_response(conn, 200)
+ assert user_response = json_response_and_validate_schema(conn, 200)
assert user_response["avatar"] != User.avatar_url(user)
end
- test "updates the user's banner", %{conn: conn} do
- user = insert(:user)
-
+ test "updates the user's banner", %{user: user, conn: conn} do
new_header = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header})
+ conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header})
- assert user_response = json_response(conn, 200)
+ assert user_response = json_response_and_validate_schema(conn, 200)
assert user_response["header"] != User.banner_url(user)
end
test "updates the user's background", %{conn: conn} do
- user = insert(:user)
-
new_header = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
@@ -262,94 +223,89 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
}
conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{
+ patch(conn, "/api/v1/accounts/update_credentials", %{
"pleroma_background_image" => new_header
})
- assert user_response = json_response(conn, 200)
+ assert user_response = json_response_and_validate_schema(conn, 200)
assert user_response["pleroma"]["background_image"]
end
- test "requires 'write:accounts' permission", %{conn: conn} do
+ test "requires 'write:accounts' permission" do
token1 = insert(:oauth_token, scopes: ["read"])
token2 = insert(:oauth_token, scopes: ["write", "follow"])
for token <- [token1, token2] do
conn =
- conn
+ build_conn()
+ |> put_req_header("content-type", "multipart/form-data")
|> put_req_header("authorization", "Bearer #{token.token}")
|> patch("/api/v1/accounts/update_credentials", %{})
if token == token1 do
assert %{"error" => "Insufficient permissions: write:accounts."} ==
- json_response(conn, 403)
+ json_response_and_validate_schema(conn, 403)
else
- assert json_response(conn, 200)
+ assert json_response_and_validate_schema(conn, 200)
end
end
end
- test "updates profile emojos", %{conn: conn} do
- user = insert(:user)
-
+ test "updates profile emojos", %{user: user, conn: conn} do
note = "*sips :blank:*"
name = "I am :firefox:"
- conn =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{
+ ret_conn =
+ patch(conn, "/api/v1/accounts/update_credentials", %{
"note" => note,
"display_name" => name
})
- assert json_response(conn, 200)
+ assert json_response_and_validate_schema(ret_conn, 200)
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}")
+ conn = get(conn, "/api/v1/accounts/#{user.id}")
- assert user = json_response(conn, 200)
+ assert user_data = json_response_and_validate_schema(conn, 200)
- assert user["note"] == note
- assert user["display_name"] == name
- assert [%{"shortcode" => "blank"}, %{"shortcode" => "firefox"}] = user["emojis"]
+ assert user_data["note"] == note
+ assert user_data["display_name"] == name
+ assert [%{"shortcode" => "blank"}, %{"shortcode" => "firefox"}] = user_data["emojis"]
end
test "update fields", %{conn: conn} do
- user = insert(:user)
-
fields = [
%{"name" => "<a href=\"http://google.com\">foo</a>", "value" => "<script>bar</script>"},
- %{"name" => "link", "value" => "cofe.io"}
+ %{"name" => "link.io", "value" => "cofe.io"}
]
- account =
+ account_data =
conn
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
- assert account["fields"] == [
- %{"name" => "foo", "value" => "bar"},
- %{"name" => "link", "value" => ~S(<a href="http://cofe.io" rel="ugc">cofe.io</a>)}
+ assert account_data["fields"] == [
+ %{"name" => "<a href=\"http://google.com\">foo</a>", "value" => "bar"},
+ %{
+ "name" => "link.io",
+ "value" => ~S(<a href="http://cofe.io" rel="ugc">cofe.io</a>)
+ }
]
- assert account["source"]["fields"] == [
+ assert account_data["source"]["fields"] == [
%{
"name" => "<a href=\"http://google.com\">foo</a>",
"value" => "<script>bar</script>"
},
- %{"name" => "link", "value" => "cofe.io"}
+ %{"name" => "link.io", "value" => "cofe.io"}
]
+ end
+ test "update fields via x-www-form-urlencoded", %{conn: conn} do
fields =
[
"fields_attributes[1][name]=link",
- "fields_attributes[1][value]=cofe.io",
- "fields_attributes[0][name]=<a href=\"http://google.com\">foo</a>",
+ "fields_attributes[1][value]=http://cofe.io",
+ "fields_attributes[0][name]=foo",
"fields_attributes[0][value]=bar"
]
|> Enum.join("&")
@@ -357,73 +313,71 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do
account =
conn
|> put_req_header("content-type", "application/x-www-form-urlencoded")
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", fields)
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert account["fields"] == [
%{"name" => "foo", "value" => "bar"},
- %{"name" => "link", "value" => ~S(<a href="http://cofe.io" rel="ugc">cofe.io</a>)}
+ %{
+ "name" => "link",
+ "value" => ~S(<a href="http://cofe.io" rel="ugc">http://cofe.io</a>)
+ }
]
assert account["source"]["fields"] == [
- %{
- "name" => "<a href=\"http://google.com\">foo</a>",
- "value" => "bar"
- },
- %{"name" => "link", "value" => "cofe.io"}
+ %{"name" => "foo", "value" => "bar"},
+ %{"name" => "link", "value" => "http://cofe.io"}
]
+ end
+ test "update fields with empty name", %{conn: conn} do
+ fields = [
+ %{"name" => "foo", "value" => ""},
+ %{"name" => "", "value" => "bar"}
+ ]
+
+ account =
+ conn
+ |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
+ |> json_response_and_validate_schema(200)
+
+ assert account["fields"] == [
+ %{"name" => "foo", "value" => ""}
+ ]
+ end
+
+ test "update fields when invalid request", %{conn: conn} do
name_limit = Pleroma.Config.get([:instance, :account_field_name_length])
value_limit = Pleroma.Config.get([:instance, :account_field_value_length])
+ long_name = Enum.map(0..name_limit, fn _ -> "x" end) |> Enum.join()
long_value = Enum.map(0..value_limit, fn _ -> "x" end) |> Enum.join()
- fields = [%{"name" => "<b>foo<b>", "value" => long_value}]
+ fields = [%{"name" => "foo", "value" => long_value}]
assert %{"error" => "Invalid request"} ==
conn
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
- |> json_response(403)
-
- long_name = Enum.map(0..name_limit, fn _ -> "x" end) |> Enum.join()
+ |> json_response_and_validate_schema(403)
fields = [%{"name" => long_name, "value" => "bar"}]
assert %{"error" => "Invalid request"} ==
conn
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
- |> json_response(403)
+ |> json_response_and_validate_schema(403)
Pleroma.Config.put([:instance, :max_account_fields], 1)
fields = [
- %{"name" => "<b>foo<b>", "value" => "<i>bar</i>"},
+ %{"name" => "foo", "value" => "bar"},
%{"name" => "link", "value" => "cofe.io"}
]
assert %{"error" => "Invalid request"} ==
conn
- |> assign(:user, user)
|> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
- |> json_response(403)
-
- fields = [
- %{"name" => "foo", "value" => ""},
- %{"name" => "", "value" => "bar"}
- ]
-
- account =
- conn
- |> assign(:user, user)
- |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields})
- |> json_response(200)
-
- assert account["fields"] == [
- %{"name" => "foo", "value" => ""}
- ]
+ |> json_response_and_validate_schema(403)
end
end
end
diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs
index 745383757..280bd6aca 100644
--- a/test/web/mastodon_api/controllers/account_controller_test.exs
+++ b/test/web/mastodon_api/controllers/account_controller_test.exs
@@ -1,78 +1,69 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
use Pleroma.Web.ConnCase
+ alias Pleroma.Config
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth.Token
import Pleroma.Factory
describe "account fetching" do
- test "works by id" do
- user = insert(:user)
+ setup do: clear_config([:instance, :limit_to_local_content])
- conn =
- build_conn()
- |> get("/api/v1/accounts/#{user.id}")
-
- assert %{"id" => id} = json_response(conn, 200)
- assert id == to_string(user.id)
+ test "works by id" do
+ %User{id: user_id} = insert(:user)
- conn =
- build_conn()
- |> get("/api/v1/accounts/-1")
+ assert %{"id" => ^user_id} =
+ build_conn()
+ |> get("/api/v1/accounts/#{user_id}")
+ |> json_response_and_validate_schema(200)
- assert %{"error" => "Can't find user"} = json_response(conn, 404)
+ assert %{"error" => "Can't find user"} =
+ build_conn()
+ |> get("/api/v1/accounts/-1")
+ |> json_response_and_validate_schema(404)
end
test "works by nickname" do
user = insert(:user)
- conn =
- build_conn()
- |> get("/api/v1/accounts/#{user.nickname}")
-
- assert %{"id" => id} = json_response(conn, 200)
- assert id == user.id
+ assert %{"id" => user_id} =
+ build_conn()
+ |> get("/api/v1/accounts/#{user.nickname}")
+ |> json_response_and_validate_schema(200)
end
test "works by nickname for remote users" do
- limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content])
- Pleroma.Config.put([:instance, :limit_to_local_content], false)
- user = insert(:user, nickname: "user@example.com", local: false)
+ Config.put([:instance, :limit_to_local_content], false)
- conn =
- build_conn()
- |> get("/api/v1/accounts/#{user.nickname}")
+ user = insert(:user, nickname: "user@example.com", local: false)
- Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local)
- assert %{"id" => id} = json_response(conn, 200)
- assert id == user.id
+ assert %{"id" => user_id} =
+ build_conn()
+ |> get("/api/v1/accounts/#{user.nickname}")
+ |> json_response_and_validate_schema(200)
end
test "respects limit_to_local_content == :all for remote user nicknames" do
- limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content])
- Pleroma.Config.put([:instance, :limit_to_local_content], :all)
+ Config.put([:instance, :limit_to_local_content], :all)
user = insert(:user, nickname: "user@example.com", local: false)
- conn =
- build_conn()
- |> get("/api/v1/accounts/#{user.nickname}")
-
- Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local)
- assert json_response(conn, 404)
+ assert build_conn()
+ |> get("/api/v1/accounts/#{user.nickname}")
+ |> json_response_and_validate_schema(404)
end
test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do
- limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content])
- Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated)
+ Config.put([:instance, :limit_to_local_content], :unauthenticated)
user = insert(:user, nickname: "user@example.com", local: false)
reading_user = insert(:user)
@@ -81,15 +72,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
build_conn()
|> get("/api/v1/accounts/#{user.nickname}")
- assert json_response(conn, 404)
+ assert json_response_and_validate_schema(conn, 404)
conn =
build_conn()
|> assign(:user, reading_user)
+ |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"]))
|> get("/api/v1/accounts/#{user.nickname}")
- Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local)
- assert %{"id" => id} = json_response(conn, 200)
+ assert %{"id" => id} = json_response_and_validate_schema(conn, 200)
assert id == user.id
end
@@ -100,67 +91,252 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
user_one = insert(:user, %{id: 1212})
user_two = insert(:user, %{nickname: "#{user_one.id}garbage"})
- resp_one =
+ acc_one =
conn
|> get("/api/v1/accounts/#{user_one.id}")
+ |> json_response_and_validate_schema(:ok)
- resp_two =
+ acc_two =
conn
|> get("/api/v1/accounts/#{user_two.nickname}")
+ |> json_response_and_validate_schema(:ok)
- resp_three =
+ acc_three =
conn
|> get("/api/v1/accounts/#{user_two.id}")
+ |> json_response_and_validate_schema(:ok)
- acc_one = json_response(resp_one, 200)
- acc_two = json_response(resp_two, 200)
- acc_three = json_response(resp_three, 200)
refute acc_one == acc_two
assert acc_two == acc_three
end
+
+ test "returns 404 when user is invisible", %{conn: conn} do
+ user = insert(:user, %{invisible: true})
+
+ assert %{"error" => "Can't find user"} =
+ conn
+ |> get("/api/v1/accounts/#{user.nickname}")
+ |> json_response_and_validate_schema(404)
+ end
+
+ test "returns 404 for internal.fetch actor", %{conn: conn} do
+ %User{nickname: "internal.fetch"} = InternalFetchActor.get_actor()
+
+ assert %{"error" => "Can't find user"} =
+ conn
+ |> get("/api/v1/accounts/internal.fetch")
+ |> json_response_and_validate_schema(404)
+ end
+ end
+
+ defp local_and_remote_users do
+ local = insert(:user)
+ remote = insert(:user, local: false)
+ {:ok, local: local, remote: remote}
+ end
+
+ describe "user fetching with restrict unauthenticated profiles for local and remote" do
+ setup do: local_and_remote_users()
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true)
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{local.id}")
+ |> json_response_and_validate_schema(:not_found)
+
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{remote.id}")
+ |> json_response_and_validate_schema(:not_found)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+ end
+
+ describe "user fetching with restrict unauthenticated profiles for local" do
+ setup do: local_and_remote_users()
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Can't find user"
+ }
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+ end
+
+ describe "user fetching with restrict unauthenticated profiles for remote" do
+ setup do: local_and_remote_users()
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Can't find user"
+ }
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
end
describe "user timelines" do
- test "gets a users statuses", %{conn: conn} do
+ setup do: oauth_access(["read:statuses"])
+
+ test "works with announces that are just addressed to public", %{conn: conn} do
+ user = insert(:user, ap_id: "https://honktest/u/test", local: false)
+ other_user = insert(:user)
+
+ {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"})
+
+ {:ok, announce, _} =
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "actor" => "https://honktest/u/test",
+ "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx",
+ "object" => post.data["object"],
+ "published" => "2019-06-25T19:33:58Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "type" => "Announce"
+ }
+ |> ActivityPub.persist(local: false)
+
+ assert resp =
+ conn
+ |> get("/api/v1/accounts/#{user.id}/statuses")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => id}] = resp
+ assert id == announce.id
+ end
+
+ test "respects blocks", %{user: user_one, conn: conn} do
+ user_two = insert(:user)
+ user_three = insert(:user)
+
+ User.block(user_one, user_two)
+
+ {:ok, activity} = CommonAPI.post(user_two, %{status: "User one sux0rz"})
+ {:ok, repeat, _} = CommonAPI.repeat(activity.id, user_three)
+
+ assert resp =
+ conn
+ |> get("/api/v1/accounts/#{user_two.id}/statuses")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => id}] = resp
+ assert id == activity.id
+
+ # Even a blocked user will deliver the full user timeline, there would be
+ # no point in looking at a blocked users timeline otherwise
+ assert resp =
+ conn
+ |> get("/api/v1/accounts/#{user_two.id}/statuses")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => id}] = resp
+ assert id == activity.id
+
+ # Third user's timeline includes the repeat when viewed by unauthenticated user
+ resp =
+ build_conn()
+ |> get("/api/v1/accounts/#{user_three.id}/statuses")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => id}] = resp
+ assert id == repeat.id
+
+ # When viewing a third user's timeline, the blocked users' statuses will NOT be shown
+ resp = get(conn, "/api/v1/accounts/#{user_three.id}/statuses")
+
+ assert [] == json_response_and_validate_schema(resp, 200)
+ end
+
+ test "gets users statuses", %{conn: conn} do
user_one = insert(:user)
user_two = insert(:user)
user_three = insert(:user)
- {:ok, user_three} = User.follow(user_three, user_one)
+ {:ok, _user_three} = User.follow(user_three, user_one)
- {:ok, activity} = CommonAPI.post(user_one, %{"status" => "HI!!!"})
+ {:ok, activity} = CommonAPI.post(user_one, %{status: "HI!!!"})
{:ok, direct_activity} =
CommonAPI.post(user_one, %{
- "status" => "Hi, @#{user_two.nickname}.",
- "visibility" => "direct"
+ status: "Hi, @#{user_two.nickname}.",
+ visibility: "direct"
})
{:ok, private_activity} =
- CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"})
+ CommonAPI.post(user_one, %{status: "private", visibility: "private"})
+ # TODO!!!
resp =
conn
|> get("/api/v1/accounts/#{user_one.id}/statuses")
+ |> json_response_and_validate_schema(200)
- assert [%{"id" => id}] = json_response(resp, 200)
+ assert [%{"id" => id}] = resp
assert id == to_string(activity.id)
resp =
conn
|> assign(:user, user_two)
+ |> assign(:token, insert(:oauth_token, user: user_two, scopes: ["read:statuses"]))
|> get("/api/v1/accounts/#{user_one.id}/statuses")
+ |> json_response_and_validate_schema(200)
- assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200)
+ assert [%{"id" => id_one}, %{"id" => id_two}] = resp
assert id_one == to_string(direct_activity.id)
assert id_two == to_string(activity.id)
resp =
conn
|> assign(:user, user_three)
+ |> assign(:token, insert(:oauth_token, user: user_three, scopes: ["read:statuses"]))
|> get("/api/v1/accounts/#{user_one.id}/statuses")
+ |> json_response_and_validate_schema(200)
- assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200)
+ assert [%{"id" => id_one}, %{"id" => id_two}] = resp
assert id_one == to_string(private_activity.id)
assert id_two == to_string(activity.id)
end
@@ -169,11 +345,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
note = insert(:note_activity)
user = User.get_cached_by_ap_id(note.data["actor"])
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true")
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?pinned=true")
- assert json_response(conn, 200) == []
+ assert json_response_and_validate_schema(conn, 200) == []
end
test "gets an users media", %{conn: conn} do
@@ -188,193 +362,244 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
{:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id)
- {:ok, image_post} = CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]})
+ {:ok, %{id: image_post_id}} = CommonAPI.post(user, %{status: "cofe", media_ids: [media_id]})
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "true"})
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true")
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(image_post.id)
+ assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200)
- conn =
- build_conn()
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "1"})
+ conn = get(build_conn(), "/api/v1/accounts/#{user.id}/statuses?only_media=1")
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(image_post.id)
+ assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200)
end
- test "gets a user's statuses without reblogs", %{conn: conn} do
- user = insert(:user)
- {:ok, post} = CommonAPI.post(user, %{"status" => "HI!!!"})
- {:ok, _, _} = CommonAPI.repeat(post.id, user)
+ test "gets a user's statuses without reblogs", %{user: user, conn: conn} do
+ {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "HI!!!"})
+ {:ok, _, _} = CommonAPI.repeat(post_id, user)
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "true"})
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=true")
+ assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200)
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(post.id)
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=1")
+ assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200)
+ end
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "1"})
+ test "filters user's statuses by a hashtag", %{user: user, conn: conn} do
+ {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "#hashtag"})
+ {:ok, _post} = CommonAPI.post(user, %{status: "hashtag"})
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(post.id)
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?tagged=hashtag")
+ assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200)
end
- test "filters user's statuses by a hashtag", %{conn: conn} do
- user = insert(:user)
- {:ok, post} = CommonAPI.post(user, %{"status" => "#hashtag"})
- {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"})
+ test "the user views their own timelines and excludes direct messages", %{
+ user: user,
+ conn: conn
+ } do
+ {:ok, %{id: public_activity_id}} =
+ CommonAPI.post(user, %{status: ".", visibility: "public"})
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"tagged" => "hashtag"})
+ {:ok, _direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(post.id)
+ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_visibilities[]=direct")
+ assert [%{"id" => ^public_activity_id}] = json_response_and_validate_schema(conn, 200)
end
+ end
- test "the user views their own timelines and excludes direct messages", %{conn: conn} do
- user = insert(:user)
- {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
- {:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ defp local_and_remote_activities(%{local: local, remote: remote}) do
+ insert(:note_activity, user: local)
+ insert(:note_activity, user: remote, local: false)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_visibilities" => ["direct"]})
+ :ok
+ end
+
+ describe "statuses with restrict unauthenticated profiles for local and remote" do
+ setup do: local_and_remote_users()
+ setup :local_and_remote_activities
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true)
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{local.id}/statuses")
+ |> json_response_and_validate_schema(:not_found)
+
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{remote.id}/statuses")
+ |> json_response_and_validate_schema(:not_found)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+ end
+ end
+
+ describe "statuses with restrict unauthenticated profiles for local" do
+ setup do: local_and_remote_users()
+ setup :local_and_remote_activities
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{local.id}/statuses")
+ |> json_response_and_validate_schema(:not_found)
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+ end
+ end
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(public_activity.id)
+ describe "statuses with restrict unauthenticated profiles for remote" do
+ setup do: local_and_remote_users()
+ setup :local_and_remote_activities
+
+ setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ assert %{"error" => "Can't find user"} ==
+ conn
+ |> get("/api/v1/accounts/#{remote.id}/statuses")
+ |> json_response_and_validate_schema(:not_found)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
end
end
describe "followers" do
- test "getting followers", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["read:accounts"])
+
+ test "getting followers", %{user: user, conn: conn} do
other_user = insert(:user)
- {:ok, user} = User.follow(user, other_user)
+ {:ok, %{id: user_id}} = User.follow(user, other_user)
- conn =
- conn
- |> get("/api/v1/accounts/#{other_user.id}/followers")
+ conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers")
- assert [%{"id" => id}] = json_response(conn, 200)
- assert id == to_string(user.id)
+ assert [%{"id" => ^user_id}] = json_response_and_validate_schema(conn, 200)
end
- test "getting followers, hide_followers", %{conn: conn} do
- user = insert(:user)
- other_user = insert(:user, %{info: %{hide_followers: true}})
+ test "getting followers, hide_followers", %{user: user, conn: conn} do
+ other_user = insert(:user, hide_followers: true)
{:ok, _user} = User.follow(user, other_user)
- conn =
- conn
- |> get("/api/v1/accounts/#{other_user.id}/followers")
+ conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers")
- assert [] == json_response(conn, 200)
+ assert [] == json_response_and_validate_schema(conn, 200)
end
- test "getting followers, hide_followers, same user requesting", %{conn: conn} do
+ test "getting followers, hide_followers, same user requesting" do
user = insert(:user)
- other_user = insert(:user, %{info: %{hide_followers: true}})
+ other_user = insert(:user, hide_followers: true)
{:ok, _user} = User.follow(user, other_user)
conn =
- conn
+ build_conn()
|> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"]))
|> get("/api/v1/accounts/#{other_user.id}/followers")
- refute [] == json_response(conn, 200)
+ refute [] == json_response_and_validate_schema(conn, 200)
end
- test "getting followers, pagination", %{conn: conn} do
- user = insert(:user)
- follower1 = insert(:user)
- follower2 = insert(:user)
- follower3 = insert(:user)
- {:ok, _} = User.follow(follower1, user)
- {:ok, _} = User.follow(follower2, user)
- {:ok, _} = User.follow(follower3, user)
-
- conn =
- conn
- |> assign(:user, user)
-
- res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/followers?since_id=#{follower1.id}")
+ test "getting followers, pagination", %{user: user, conn: conn} do
+ {:ok, %User{id: follower1_id}} = :user |> insert() |> User.follow(user)
+ {:ok, %User{id: follower2_id}} = :user |> insert() |> User.follow(user)
+ {:ok, %User{id: follower3_id}} = :user |> insert() |> User.follow(user)
- assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200)
- assert id3 == follower3.id
- assert id2 == follower2.id
+ assert [%{"id" => ^follower3_id}, %{"id" => ^follower2_id}] =
+ conn
+ |> get("/api/v1/accounts/#{user.id}/followers?since_id=#{follower1_id}")
+ |> json_response_and_validate_schema(200)
- res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3.id}")
+ assert [%{"id" => ^follower2_id}, %{"id" => ^follower1_id}] =
+ conn
+ |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3_id}")
+ |> json_response_and_validate_schema(200)
- assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200)
- assert id2 == follower2.id
- assert id1 == follower1.id
-
- res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3.id}")
+ res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3_id}")
- assert [%{"id" => id2}] = json_response(res_conn, 200)
- assert id2 == follower2.id
+ assert [%{"id" => ^follower2_id}] = json_response_and_validate_schema(res_conn, 200)
assert [link_header] = get_resp_header(res_conn, "link")
- assert link_header =~ ~r/min_id=#{follower2.id}/
- assert link_header =~ ~r/max_id=#{follower2.id}/
+ assert link_header =~ ~r/min_id=#{follower2_id}/
+ assert link_header =~ ~r/max_id=#{follower2_id}/
end
end
describe "following" do
- test "getting following", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["read:accounts"])
+
+ test "getting following", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
- conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/following")
+ conn = get(conn, "/api/v1/accounts/#{user.id}/following")
- assert [%{"id" => id}] = json_response(conn, 200)
+ assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200)
assert id == to_string(other_user.id)
end
- test "getting following, hide_follows", %{conn: conn} do
- user = insert(:user, %{info: %{hide_follows: true}})
+ test "getting following, hide_follows, other user requesting" do
+ user = insert(:user, hide_follows: true)
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
conn =
- conn
+ build_conn()
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"]))
|> get("/api/v1/accounts/#{user.id}/following")
- assert [] == json_response(conn, 200)
+ assert [] == json_response_and_validate_schema(conn, 200)
end
- test "getting following, hide_follows, same user requesting", %{conn: conn} do
- user = insert(:user, %{info: %{hide_follows: true}})
+ test "getting following, hide_follows, same user requesting" do
+ user = insert(:user, hide_follows: true)
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
conn =
- conn
+ build_conn()
|> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["read:accounts"]))
|> get("/api/v1/accounts/#{user.id}/following")
- refute [] == json_response(conn, 200)
+ refute [] == json_response_and_validate_schema(conn, 200)
end
- test "getting following, pagination", %{conn: conn} do
- user = insert(:user)
+ test "getting following, pagination", %{user: user, conn: conn} do
following1 = insert(:user)
following2 = insert(:user)
following3 = insert(:user)
@@ -382,31 +607,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
{:ok, _} = User.follow(user, following2)
{:ok, _} = User.follow(user, following3)
- conn =
- conn
- |> assign(:user, user)
-
- res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}")
+ res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}")
- assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200)
+ assert [%{"id" => id3}, %{"id" => id2}] = json_response_and_validate_schema(res_conn, 200)
assert id3 == following3.id
assert id2 == following2.id
- res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}")
+ res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}")
- assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200)
+ assert [%{"id" => id2}, %{"id" => id1}] = json_response_and_validate_schema(res_conn, 200)
assert id2 == following2.id
assert id1 == following1.id
res_conn =
- conn
- |> get("/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}")
+ get(conn, "/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}")
- assert [%{"id" => id2}] = json_response(res_conn, 200)
+ assert [%{"id" => id2}] = json_response_and_validate_schema(res_conn, 200)
assert id2 == following2.id
assert [link_header] = get_resp_header(res_conn, "link")
@@ -416,200 +632,177 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
end
describe "follow/unfollow" do
- test "following / unfollowing a user", %{conn: conn} do
- user = insert(:user)
- other_user = insert(:user)
-
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/follow")
-
- assert %{"id" => _id, "following" => true} = json_response(conn, 200)
-
- user = User.get_cached_by_id(user.id)
-
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/unfollow")
+ setup do: oauth_access(["follow"])
- assert %{"id" => _id, "following" => false} = json_response(conn, 200)
+ test "following / unfollowing a user", %{conn: conn} do
+ %{id: other_user_id, nickname: other_user_nickname} = insert(:user)
+
+ assert %{"id" => _id, "following" => true} =
+ conn
+ |> post("/api/v1/accounts/#{other_user_id}/follow")
+ |> json_response_and_validate_schema(200)
+
+ assert %{"id" => _id, "following" => false} =
+ conn
+ |> post("/api/v1/accounts/#{other_user_id}/unfollow")
+ |> json_response_and_validate_schema(200)
+
+ assert %{"id" => ^other_user_id} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/follows", %{"uri" => other_user_nickname})
+ |> json_response_and_validate_schema(200)
+ end
- user = User.get_cached_by_id(user.id)
+ test "cancelling follow request", %{conn: conn} do
+ %{id: other_user_id} = insert(:user, %{locked: true})
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/follows", %{"uri" => other_user.nickname})
+ assert %{"id" => ^other_user_id, "following" => false, "requested" => true} =
+ conn
+ |> post("/api/v1/accounts/#{other_user_id}/follow")
+ |> json_response_and_validate_schema(:ok)
- assert %{"id" => id} = json_response(conn, 200)
- assert id == to_string(other_user.id)
+ assert %{"id" => ^other_user_id, "following" => false, "requested" => false} =
+ conn
+ |> post("/api/v1/accounts/#{other_user_id}/unfollow")
+ |> json_response_and_validate_schema(:ok)
end
test "following without reblogs" do
- follower = insert(:user)
+ %{conn: conn} = oauth_access(["follow", "read:statuses"])
followed = insert(:user)
other_user = insert(:user)
- conn =
- build_conn()
- |> assign(:user, follower)
- |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=false")
-
- assert %{"showing_reblogs" => false} = json_response(conn, 200)
-
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"})
- {:ok, reblog, _} = CommonAPI.repeat(activity.id, followed)
+ ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=false")
- conn =
- build_conn()
- |> assign(:user, User.get_cached_by_id(follower.id))
- |> get("/api/v1/timelines/home")
+ assert %{"showing_reblogs" => false} = json_response_and_validate_schema(ret_conn, 200)
- assert [] == json_response(conn, 200)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
+ {:ok, %{id: reblog_id}, _} = CommonAPI.repeat(activity.id, followed)
- conn =
- build_conn()
- |> assign(:user, follower)
- |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true")
+ assert [] ==
+ conn
+ |> get("/api/v1/timelines/home")
+ |> json_response(200)
- assert %{"showing_reblogs" => true} = json_response(conn, 200)
-
- conn =
- build_conn()
- |> assign(:user, User.get_cached_by_id(follower.id))
- |> get("/api/v1/timelines/home")
+ assert %{"showing_reblogs" => true} =
+ conn
+ |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true")
+ |> json_response_and_validate_schema(200)
- expected_activity_id = reblog.id
- assert [%{"id" => ^expected_activity_id}] = json_response(conn, 200)
+ assert [%{"id" => ^reblog_id}] =
+ conn
+ |> get("/api/v1/timelines/home")
+ |> json_response(200)
end
- test "following / unfollowing errors" do
- user = insert(:user)
-
- conn =
- build_conn()
- |> assign(:user, user)
-
+ test "following / unfollowing errors", %{user: user, conn: conn} do
# self follow
conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow")
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+
+ assert %{"error" => "Can not follow yourself"} =
+ json_response_and_validate_schema(conn_res, 400)
# self unfollow
user = User.get_cached_by_id(user.id)
conn_res = post(conn, "/api/v1/accounts/#{user.id}/unfollow")
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+
+ assert %{"error" => "Can not unfollow yourself"} =
+ json_response_and_validate_schema(conn_res, 400)
# self follow via uri
user = User.get_cached_by_id(user.id)
- conn_res = post(conn, "/api/v1/follows", %{"uri" => user.nickname})
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+
+ assert %{"error" => "Can not follow yourself"} =
+ conn
+ |> put_req_header("content-type", "multipart/form-data")
+ |> post("/api/v1/follows", %{"uri" => user.nickname})
+ |> json_response_and_validate_schema(400)
# follow non existing user
conn_res = post(conn, "/api/v1/accounts/doesntexist/follow")
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404)
# follow non existing user via uri
- conn_res = post(conn, "/api/v1/follows", %{"uri" => "doesntexist"})
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+ conn_res =
+ conn
+ |> put_req_header("content-type", "multipart/form-data")
+ |> post("/api/v1/follows", %{"uri" => "doesntexist"})
+
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404)
# unfollow non existing user
conn_res = post(conn, "/api/v1/accounts/doesntexist/unfollow")
- assert %{"error" => "Record not found"} = json_response(conn_res, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404)
end
end
describe "mute/unmute" do
+ setup do: oauth_access(["write:mutes"])
+
test "with notifications", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/mute")
-
- response = json_response(conn, 200)
+ assert %{"id" => _id, "muting" => true, "muting_notifications" => true} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/accounts/#{other_user.id}/mute")
+ |> json_response_and_validate_schema(200)
- assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response
- user = User.get_cached_by_id(user.id)
+ conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute")
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/unmute")
-
- response = json_response(conn, 200)
- assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response
+ assert %{"id" => _id, "muting" => false, "muting_notifications" => false} =
+ json_response_and_validate_schema(conn, 200)
end
test "without notifications", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
- conn =
+ ret_conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"})
- response = json_response(conn, 200)
+ assert %{"id" => _id, "muting" => true, "muting_notifications" => false} =
+ json_response_and_validate_schema(ret_conn, 200)
- assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response
- user = User.get_cached_by_id(user.id)
+ conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute")
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/unmute")
-
- response = json_response(conn, 200)
- assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response
+ assert %{"id" => _id, "muting" => false, "muting_notifications" => false} =
+ json_response_and_validate_schema(conn, 200)
end
end
describe "pinned statuses" do
setup do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"})
+ %{conn: conn} = oauth_access(["read:statuses"], user: user)
- [user: user, activity: activity]
+ [conn: conn, user: user, activity: activity]
end
- test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do
- {:ok, _} = CommonAPI.pin(activity.id, user)
-
- result =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true")
- |> json_response(200)
-
- id_str = to_string(activity.id)
+ test "returns pinned statuses", %{conn: conn, user: user, activity: %{id: activity_id}} do
+ {:ok, _} = CommonAPI.pin(activity_id, user)
- assert [%{"id" => ^id_str, "pinned" => true}] = result
+ assert [%{"id" => ^activity_id, "pinned" => true}] =
+ conn
+ |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true")
+ |> json_response_and_validate_schema(200)
end
end
- test "blocking / unblocking a user", %{conn: conn} do
- user = insert(:user)
+ test "blocking / unblocking a user" do
+ %{conn: conn} = oauth_access(["follow"])
other_user = insert(:user)
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/block")
+ ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/block")
- assert %{"id" => _id, "blocking" => true} = json_response(conn, 200)
+ assert %{"id" => _id, "blocking" => true} = json_response_and_validate_schema(ret_conn, 200)
- user = User.get_cached_by_id(user.id)
-
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/accounts/#{other_user.id}/unblock")
+ conn = post(conn, "/api/v1/accounts/#{other_user.id}/unblock")
- assert %{"id" => _id, "blocking" => false} = json_response(conn, 200)
+ assert %{"id" => _id, "blocking" => false} = json_response_and_validate_schema(conn, 200)
end
describe "create account by app" do
@@ -624,28 +817,30 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
[valid_params: valid_params]
end
+ setup do: clear_config([:instance, :account_activation_required])
+
test "Account registration via Application", %{conn: conn} do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: "client_name",
redirect_uris: "urn:ietf:wg:oauth:2.0:oob",
scopes: "read, write, follow"
})
- %{
- "client_id" => client_id,
- "client_secret" => client_secret,
- "id" => _,
- "name" => "client_name",
- "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob",
- "vapid_key" => _,
- "website" => nil
- } = json_response(conn, 200)
+ assert %{
+ "client_id" => client_id,
+ "client_secret" => client_secret,
+ "id" => _,
+ "name" => "client_name",
+ "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob",
+ "vapid_key" => _,
+ "website" => nil
+ } = json_response_and_validate_schema(conn, 200)
conn =
- conn
- |> post("/oauth/token", %{
+ post(conn, "/oauth/token", %{
grant_type: "client_credentials",
client_id: client_id,
client_secret: client_secret
@@ -662,6 +857,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
conn =
build_conn()
+ |> put_req_header("content-type", "multipart/form-data")
|> put_req_header("authorization", "Bearer " <> token)
|> post("/api/v1/accounts", %{
username: "lain",
@@ -676,36 +872,216 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
"created_at" => _created_at,
"scope" => _scope,
"token_type" => "Bearer"
- } = json_response(conn, 200)
+ } = json_response_and_validate_schema(conn, 200)
token_from_db = Repo.get_by(Token, token: token)
assert token_from_db
token_from_db = Repo.preload(token_from_db, :user)
assert token_from_db.user
- assert token_from_db.user.info.confirmation_pending
+ assert token_from_db.user.confirmation_pending
end
test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do
_user = insert(:user, email: "lain@example.org")
app_token = insert(:oauth_token, user: nil)
+ res =
+ conn
+ |> put_req_header("authorization", "Bearer " <> app_token.token)
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/accounts", valid_params)
+
+ assert json_response_and_validate_schema(res, 400) == %{
+ "error" => "{\"email\":[\"has already been taken\"]}"
+ }
+ end
+
+ test "returns bad_request if missing required params", %{
+ conn: conn,
+ valid_params: valid_params
+ } do
+ app_token = insert(:oauth_token, user: nil)
+
conn =
conn
|> put_req_header("authorization", "Bearer " <> app_token.token)
+ |> put_req_header("content-type", "application/json")
res = post(conn, "/api/v1/accounts", valid_params)
- assert json_response(res, 400) == %{"error" => "{\"email\":[\"has already been taken\"]}"}
+ assert json_response_and_validate_schema(res, 200)
+
+ [{127, 0, 0, 1}, {127, 0, 0, 2}, {127, 0, 0, 3}, {127, 0, 0, 4}]
+ |> Stream.zip(Map.delete(valid_params, :email))
+ |> Enum.each(fn {ip, {attr, _}} ->
+ res =
+ conn
+ |> Map.put(:remote_ip, ip)
+ |> post("/api/v1/accounts", Map.delete(valid_params, attr))
+ |> json_response_and_validate_schema(400)
+
+ assert res == %{
+ "error" => "Missing field: #{attr}.",
+ "errors" => [
+ %{
+ "message" => "Missing field: #{attr}",
+ "source" => %{"pointer" => "/#{attr}"},
+ "title" => "Invalid value"
+ }
+ ]
+ }
+ end)
end
- test "rate limit", %{conn: conn} do
+ setup do: clear_config([:instance, :account_activation_required])
+
+ test "returns bad_request if missing email params when :account_activation_required is enabled",
+ %{conn: conn, valid_params: valid_params} do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+
app_token = insert(:oauth_token, user: nil)
conn =
- put_req_header(conn, "authorization", "Bearer " <> app_token.token)
+ conn
+ |> put_req_header("authorization", "Bearer " <> app_token.token)
+ |> put_req_header("content-type", "application/json")
+
+ res =
+ conn
+ |> Map.put(:remote_ip, {127, 0, 0, 5})
+ |> post("/api/v1/accounts", Map.delete(valid_params, :email))
+
+ assert json_response_and_validate_schema(res, 400) ==
+ %{"error" => "Missing parameter: email"}
+
+ res =
+ conn
+ |> Map.put(:remote_ip, {127, 0, 0, 6})
+ |> post("/api/v1/accounts", Map.put(valid_params, :email, ""))
+
+ assert json_response_and_validate_schema(res, 400) == %{
+ "error" => "{\"email\":[\"can't be blank\"]}"
+ }
+ end
+
+ test "allow registration without an email", %{conn: conn, valid_params: valid_params} do
+ app_token = insert(:oauth_token, user: nil)
+ conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token)
+
+ res =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> Map.put(:remote_ip, {127, 0, 0, 7})
+ |> post("/api/v1/accounts", Map.delete(valid_params, :email))
+
+ assert json_response_and_validate_schema(res, 200)
+ end
+
+ test "allow registration with an empty email", %{conn: conn, valid_params: valid_params} do
+ app_token = insert(:oauth_token, user: nil)
+ conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token)
+
+ res =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> Map.put(:remote_ip, {127, 0, 0, 8})
+ |> post("/api/v1/accounts", Map.put(valid_params, :email, ""))
+
+ assert json_response_and_validate_schema(res, 200)
+ end
+
+ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do
+ res =
+ conn
+ |> put_req_header("authorization", "Bearer " <> "invalid-token")
+ |> put_req_header("content-type", "multipart/form-data")
+ |> post("/api/v1/accounts", valid_params)
+
+ assert json_response_and_validate_schema(res, 403) == %{"error" => "Invalid credentials"}
+ end
+
+ test "registration from trusted app" do
+ clear_config([Pleroma.Captcha, :enabled], true)
+ app = insert(:oauth_app, trusted: true, scopes: ["read", "write", "follow", "push"])
+
+ conn =
+ build_conn()
+ |> post("/oauth/token", %{
+ "grant_type" => "client_credentials",
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+
+ assert %{"access_token" => token, "token_type" => "Bearer"} = json_response(conn, 200)
+
+ response =
+ build_conn()
+ |> Plug.Conn.put_req_header("authorization", "Bearer " <> token)
+ |> put_req_header("content-type", "multipart/form-data")
+ |> post("/api/v1/accounts", %{
+ nickname: "nickanme",
+ agreement: true,
+ email: "email@example.com",
+ fullname: "Lain",
+ username: "Lain",
+ password: "some_password",
+ confirm: "some_password"
+ })
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "access_token" => access_token,
+ "created_at" => _,
+ "scope" => ["read", "write", "follow", "push"],
+ "token_type" => "Bearer"
+ } = response
+
+ response =
+ build_conn()
+ |> Plug.Conn.put_req_header("authorization", "Bearer " <> access_token)
+ |> get("/api/v1/accounts/verify_credentials")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "acct" => "Lain",
+ "bot" => false,
+ "display_name" => "Lain",
+ "follow_requests_count" => 0,
+ "followers_count" => 0,
+ "following_count" => 0,
+ "locked" => false,
+ "note" => "",
+ "source" => %{
+ "fields" => [],
+ "note" => "",
+ "pleroma" => %{
+ "actor_type" => "Person",
+ "discoverable" => false,
+ "no_rich_text" => false,
+ "show_role" => true
+ },
+ "privacy" => "public",
+ "sensitive" => false
+ },
+ "statuses_count" => 0,
+ "username" => "Lain"
+ } = response
+ end
+ end
+
+ describe "create account by app / rate limit" do
+ setup do: clear_config([:rate_limit, :app_account_creation], {10_000, 2})
+
+ test "respects rate limit setting", %{conn: conn} do
+ app_token = insert(:oauth_token, user: nil)
+
+ conn =
+ conn
+ |> put_req_header("authorization", "Bearer " <> app_token.token)
|> Map.put(:remote_ip, {15, 15, 15, 15})
+ |> put_req_header("content-type", "multipart/form-data")
- for i <- 1..5 do
+ for i <- 1..2 do
conn =
conn
|> post("/api/v1/accounts", %{
@@ -720,170 +1096,211 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
"created_at" => _created_at,
"scope" => _scope,
"token_type" => "Bearer"
- } = json_response(conn, 200)
+ } = json_response_and_validate_schema(conn, 200)
token_from_db = Repo.get_by(Token, token: token)
assert token_from_db
token_from_db = Repo.preload(token_from_db, :user)
assert token_from_db.user
- assert token_from_db.user.info.confirmation_pending
+ assert token_from_db.user.confirmation_pending
end
conn =
- conn
- |> post("/api/v1/accounts", %{
+ post(conn, "/api/v1/accounts", %{
username: "6lain",
email: "6lain@example.org",
password: "PlzDontHackLain",
agreement: true
})
- assert json_response(conn, :too_many_requests) == %{"error" => "Throttled"}
+ assert json_response_and_validate_schema(conn, :too_many_requests) == %{
+ "error" => "Throttled"
+ }
end
+ end
- test "returns bad_request if missing required params", %{
- conn: conn,
- valid_params: valid_params
- } do
+ describe "create account with enabled captcha" do
+ setup %{conn: conn} do
app_token = insert(:oauth_token, user: nil)
conn =
conn
|> put_req_header("authorization", "Bearer " <> app_token.token)
+ |> put_req_header("content-type", "multipart/form-data")
- res = post(conn, "/api/v1/accounts", valid_params)
- assert json_response(res, 200)
+ [conn: conn]
+ end
- [{127, 0, 0, 1}, {127, 0, 0, 2}, {127, 0, 0, 3}, {127, 0, 0, 4}]
- |> Stream.zip(valid_params)
- |> Enum.each(fn {ip, {attr, _}} ->
- res =
- conn
- |> Map.put(:remote_ip, ip)
- |> post("/api/v1/accounts", Map.delete(valid_params, attr))
- |> json_response(400)
+ setup do: clear_config([Pleroma.Captcha, :enabled], true)
- assert res == %{"error" => "Missing parameters"}
- end)
+ test "creates an account and returns 200 if captcha is valid", %{conn: conn} do
+ %{token: token, answer_data: answer_data} = Pleroma.Captcha.new()
+
+ params = %{
+ username: "lain",
+ email: "lain@example.org",
+ password: "PlzDontHackLain",
+ agreement: true,
+ captcha_solution: Pleroma.Captcha.Mock.solution(),
+ captcha_token: token,
+ captcha_answer_data: answer_data
+ }
+
+ assert %{
+ "access_token" => access_token,
+ "created_at" => _,
+ "scope" => ["read"],
+ "token_type" => "Bearer"
+ } =
+ conn
+ |> post("/api/v1/accounts", params)
+ |> json_response_and_validate_schema(:ok)
+
+ assert Token |> Repo.get_by(token: access_token) |> Repo.preload(:user) |> Map.get(:user)
+
+ Cachex.del(:used_captcha_cache, token)
end
- test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do
- conn =
- conn
- |> put_req_header("authorization", "Bearer " <> "invalid-token")
+ test "returns 400 if any captcha field is not provided", %{conn: conn} do
+ captcha_fields = [:captcha_solution, :captcha_token, :captcha_answer_data]
- res = post(conn, "/api/v1/accounts", valid_params)
- assert json_response(res, 403) == %{"error" => "Invalid credentials"}
+ valid_params = %{
+ username: "lain",
+ email: "lain@example.org",
+ password: "PlzDontHackLain",
+ agreement: true,
+ captcha_solution: "xx",
+ captcha_token: "xx",
+ captcha_answer_data: "xx"
+ }
+
+ for field <- captcha_fields do
+ expected = %{
+ "error" => "{\"captcha\":[\"Invalid CAPTCHA (Missing parameter: #{field})\"]}"
+ }
+
+ assert expected ==
+ conn
+ |> post("/api/v1/accounts", Map.delete(valid_params, field))
+ |> json_response_and_validate_schema(:bad_request)
+ end
+ end
+
+ test "returns an error if captcha is invalid", %{conn: conn} do
+ params = %{
+ username: "lain",
+ email: "lain@example.org",
+ password: "PlzDontHackLain",
+ agreement: true,
+ captcha_solution: "cofe",
+ captcha_token: "cofe",
+ captcha_answer_data: "cofe"
+ }
+
+ assert %{"error" => "{\"captcha\":[\"Invalid answer data\"]}"} ==
+ conn
+ |> post("/api/v1/accounts", params)
+ |> json_response_and_validate_schema(:bad_request)
end
end
describe "GET /api/v1/accounts/:id/lists - account_lists" do
- test "returns lists to which the account belongs", %{conn: conn} do
- user = insert(:user)
+ test "returns lists to which the account belongs" do
+ %{user: user, conn: conn} = oauth_access(["read:lists"])
other_user = insert(:user)
- assert {:ok, %Pleroma.List{} = list} = Pleroma.List.create("Test List", user)
+ assert {:ok, %Pleroma.List{id: list_id} = list} = Pleroma.List.create("Test List", user)
{:ok, %{following: _following}} = Pleroma.List.follow(list, other_user)
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/#{other_user.id}/lists")
- |> json_response(200)
-
- assert res == [%{"id" => to_string(list.id), "title" => "Test List"}]
+ assert [%{"id" => list_id, "title" => "Test List"}] =
+ conn
+ |> get("/api/v1/accounts/#{other_user.id}/lists")
+ |> json_response_and_validate_schema(200)
end
end
describe "verify_credentials" do
- test "verify_credentials", %{conn: conn} do
- user = insert(:user)
-
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/verify_credentials")
+ test "verify_credentials" do
+ %{user: user, conn: conn} = oauth_access(["read:accounts"])
+ [notification | _] = insert_list(7, :notification, user: user)
+ Pleroma.Notification.set_read_up_to(user, notification.id)
+ conn = get(conn, "/api/v1/accounts/verify_credentials")
- response = json_response(conn, 200)
+ response = json_response_and_validate_schema(conn, 200)
assert %{"id" => id, "source" => %{"privacy" => "public"}} = response
assert response["pleroma"]["chat_token"]
+ assert response["pleroma"]["unread_notifications_count"] == 6
assert id == to_string(user.id)
end
- test "verify_credentials default scope unlisted", %{conn: conn} do
- user = insert(:user, %{info: %User.Info{default_scope: "unlisted"}})
+ test "verify_credentials default scope unlisted" do
+ user = insert(:user, default_scope: "unlisted")
+ %{conn: conn} = oauth_access(["read:accounts"], user: user)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/verify_credentials")
+ conn = get(conn, "/api/v1/accounts/verify_credentials")
+
+ assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} =
+ json_response_and_validate_schema(conn, 200)
- assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} = json_response(conn, 200)
assert id == to_string(user.id)
end
- test "locked accounts", %{conn: conn} do
- user = insert(:user, %{info: %User.Info{default_scope: "private"}})
+ test "locked accounts" do
+ user = insert(:user, default_scope: "private")
+ %{conn: conn} = oauth_access(["read:accounts"], user: user)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/verify_credentials")
+ conn = get(conn, "/api/v1/accounts/verify_credentials")
+
+ assert %{"id" => id, "source" => %{"privacy" => "private"}} =
+ json_response_and_validate_schema(conn, 200)
- assert %{"id" => id, "source" => %{"privacy" => "private"}} = json_response(conn, 200)
assert id == to_string(user.id)
end
end
describe "user relationships" do
- test "returns the relationships for the current user", %{conn: conn} do
- user = insert(:user)
- other_user = insert(:user)
- {:ok, user} = User.follow(user, other_user)
+ setup do: oauth_access(["read:follows"])
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/relationships", %{"id" => [other_user.id]})
+ test "returns the relationships for the current user", %{user: user, conn: conn} do
+ %{id: other_user_id} = other_user = insert(:user)
+ {:ok, _user} = User.follow(user, other_user)
- assert [relationship] = json_response(conn, 200)
+ assert [%{"id" => ^other_user_id}] =
+ conn
+ |> get("/api/v1/accounts/relationships?id=#{other_user.id}")
+ |> json_response_and_validate_schema(200)
- assert to_string(other_user.id) == relationship["id"]
+ assert [%{"id" => ^other_user_id}] =
+ conn
+ |> get("/api/v1/accounts/relationships?id[]=#{other_user.id}")
+ |> json_response_and_validate_schema(200)
end
test "returns an empty list on a bad request", %{conn: conn} do
- user = insert(:user)
+ conn = get(conn, "/api/v1/accounts/relationships", %{})
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/relationships", %{})
-
- assert [] = json_response(conn, 200)
+ assert [] = json_response_and_validate_schema(conn, 200)
end
end
- test "getting a list of mutes", %{conn: conn} do
- user = insert(:user)
+ test "getting a list of mutes" do
+ %{user: user, conn: conn} = oauth_access(["read:mutes"])
other_user = insert(:user)
- {:ok, user} = User.mute(user, other_user)
+ {:ok, _user_relationships} = User.mute(user, other_user)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/mutes")
+ conn = get(conn, "/api/v1/mutes")
other_user_id = to_string(other_user.id)
- assert [%{"id" => ^other_user_id}] = json_response(conn, 200)
+ assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200)
end
- test "getting a list of blocks", %{conn: conn} do
- user = insert(:user)
+ test "getting a list of blocks" do
+ %{user: user, conn: conn} = oauth_access(["read:blocks"])
other_user = insert(:user)
- {:ok, user} = User.block(user, other_user)
+ {:ok, _user_relationship} = User.block(user, other_user)
conn =
conn
@@ -891,6 +1308,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do
|> get("/api/v1/blocks")
other_user_id = to_string(other_user.id)
- assert [%{"id" => ^other_user_id}] = json_response(conn, 200)
+ assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200)
end
end
diff --git a/test/web/mastodon_api/controllers/app_controller_test.exs b/test/web/mastodon_api/controllers/app_controller_test.exs
index 51788155b..a0b8b126c 100644
--- a/test/web/mastodon_api/controllers/app_controller_test.exs
+++ b/test/web/mastodon_api/controllers/app_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.AppControllerTest do
@@ -16,8 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do
conn =
conn
- |> assign(:user, token.user)
- |> assign(:token, token)
+ |> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/v1/apps/verify_credentials")
app = Repo.preload(token, :app).app
@@ -28,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do
"vapid_key" => Push.vapid_config() |> Keyword.get(:public_key)
}
- assert expected == json_response(conn, 200)
+ assert expected == json_response_and_validate_schema(conn, 200)
end
test "creates an oauth app", %{conn: conn} do
@@ -37,6 +36,7 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> assign(:user, user)
|> post("/api/v1/apps", %{
client_name: app_attrs.client_name,
@@ -55,6 +55,6 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do
"vapid_key" => Push.vapid_config() |> Keyword.get(:public_key)
}
- assert expected == json_response(conn, 200)
+ assert expected == json_response_and_validate_schema(conn, 200)
end
end
diff --git a/test/web/mastodon_api/controllers/auth_controller_test.exs b/test/web/mastodon_api/controllers/auth_controller_test.exs
index 98b2a82e7..a485f8e41 100644
--- a/test/web/mastodon_api/controllers/auth_controller_test.exs
+++ b/test/web/mastodon_api/controllers/auth_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.AuthControllerTest do
@@ -85,6 +85,37 @@ defmodule Pleroma.Web.MastodonAPI.AuthControllerTest do
end
end
+ describe "POST /auth/password, with nickname" do
+ test "it returns 204", %{conn: conn} do
+ user = insert(:user)
+
+ assert conn
+ |> post("/auth/password?nickname=#{user.nickname}")
+ |> json_response(:no_content)
+
+ ObanHelpers.perform_all()
+ token_record = Repo.get_by(Pleroma.PasswordResetToken, user_id: user.id)
+
+ email = Pleroma.Emails.UserEmail.password_reset_email(user, token_record.token)
+ notify_email = Config.get([:instance, :notify_email])
+ instance_name = Config.get([:instance, :name])
+
+ assert_email_sent(
+ from: {instance_name, notify_email},
+ to: {user.name, user.email},
+ html_body: email.html_body
+ )
+ end
+
+ test "it doesn't fail when a user has no email", %{conn: conn} do
+ user = insert(:user, %{email: nil})
+
+ assert conn
+ |> post("/auth/password?nickname=#{user.nickname}")
+ |> json_response(:no_content)
+ end
+ end
+
describe "POST /auth/password, with invalid parameters" do
setup do
user = insert(:user)
diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs
index d89a87179..693ba51e5 100644
--- a/test/web/mastodon_api/controllers/conversation_controller_test.exs
+++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
@@ -10,35 +10,33 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
import Pleroma.Factory
- test "returns a list of conversations", %{conn: conn} do
- user_one = insert(:user)
+ setup do: oauth_access(["read:statuses"])
+
+ test "returns a list of conversations", %{user: user_one, conn: conn} do
user_two = insert(:user)
user_three = insert(:user)
{:ok, user_two} = User.follow(user_two, user_one)
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
{:ok, direct} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}, @#{user_three.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!",
+ visibility: "direct"
})
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 1
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1
{:ok, _follower_only} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}!",
- "visibility" => "private"
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "private"
})
- res_conn =
- conn
- |> assign(:user, user_one)
- |> get("/api/v1/conversations")
+ res_conn = get(conn, "/api/v1/conversations")
- assert response = json_response(res_conn, 200)
+ assert response = json_response_and_validate_schema(res_conn, 200)
assert [
%{
@@ -56,106 +54,154 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do
assert is_binary(res_id)
assert unread == false
assert res_last_status["id"] == direct.id
- assert User.get_cached_by_id(user_one.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0
+ end
+
+ test "filters conversations by recipients", %{user: user_one, conn: conn} do
+ user_two = insert(:user)
+ user_three = insert(:user)
+
+ {:ok, direct1} =
+ CommonAPI.post(user_one, %{
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "direct"
+ })
+
+ {:ok, _direct2} =
+ CommonAPI.post(user_one, %{
+ status: "Hi @#{user_three.nickname}!",
+ visibility: "direct"
+ })
+
+ {:ok, direct3} =
+ CommonAPI.post(user_one, %{
+ status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!",
+ visibility: "direct"
+ })
+
+ {:ok, _direct4} =
+ CommonAPI.post(user_two, %{
+ status: "Hi @#{user_three.nickname}!",
+ visibility: "direct"
+ })
+
+ {:ok, direct5} =
+ CommonAPI.post(user_two, %{
+ status: "Hi @#{user_one.nickname}!",
+ visibility: "direct"
+ })
+
+ assert [conversation1, conversation2] =
+ conn
+ |> get("/api/v1/conversations?recipients[]=#{user_two.id}")
+ |> json_response_and_validate_schema(200)
+
+ assert conversation1["last_status"]["id"] == direct5.id
+ assert conversation2["last_status"]["id"] == direct1.id
+
+ [conversation1] =
+ conn
+ |> get("/api/v1/conversations?recipients[]=#{user_two.id}&recipients[]=#{user_three.id}")
+ |> json_response_and_validate_schema(200)
+
+ assert conversation1["last_status"]["id"] == direct3.id
end
- test "updates the last_status on reply", %{conn: conn} do
- user_one = insert(:user)
+ test "updates the last_status on reply", %{user: user_one, conn: conn} do
user_two = insert(:user)
{:ok, direct} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}",
+ visibility: "direct"
})
{:ok, direct_reply} =
CommonAPI.post(user_two, %{
- "status" => "reply",
- "visibility" => "direct",
- "in_reply_to_status_id" => direct.id
+ status: "reply",
+ visibility: "direct",
+ in_reply_to_status_id: direct.id
})
[%{"last_status" => res_last_status}] =
conn
- |> assign(:user, user_one)
|> get("/api/v1/conversations")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert res_last_status["id"] == direct_reply.id
end
- test "the user marks a conversation as read", %{conn: conn} do
- user_one = insert(:user)
+ test "the user marks a conversation as read", %{user: user_one, conn: conn} do
user_two = insert(:user)
{:ok, direct} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}",
+ visibility: "direct"
})
- assert User.get_cached_by_id(user_one.id).info.unread_conversation_count == 0
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 1
+ assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1
- [%{"id" => direct_conversation_id, "unread" => true}] =
- conn
+ user_two_conn =
+ build_conn()
|> assign(:user, user_two)
+ |> assign(
+ :token,
+ insert(:oauth_token, user: user_two, scopes: ["read:statuses", "write:conversations"])
+ )
+
+ [%{"id" => direct_conversation_id, "unread" => true}] =
+ user_two_conn
|> get("/api/v1/conversations")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
%{"unread" => false} =
- conn
- |> assign(:user, user_two)
+ user_two_conn
|> post("/api/v1/conversations/#{direct_conversation_id}/read")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
- assert User.get_cached_by_id(user_one.id).info.unread_conversation_count == 0
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
# The conversation is marked as unread on reply
{:ok, _} =
CommonAPI.post(user_two, %{
- "status" => "reply",
- "visibility" => "direct",
- "in_reply_to_status_id" => direct.id
+ status: "reply",
+ visibility: "direct",
+ in_reply_to_status_id: direct.id
})
[%{"unread" => true}] =
conn
- |> assign(:user, user_one)
|> get("/api/v1/conversations")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
- assert User.get_cached_by_id(user_one.id).info.unread_conversation_count == 1
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
# A reply doesn't increment the user's unread_conversation_count if the conversation is unread
{:ok, _} =
CommonAPI.post(user_two, %{
- "status" => "reply",
- "visibility" => "direct",
- "in_reply_to_status_id" => direct.id
+ status: "reply",
+ visibility: "direct",
+ in_reply_to_status_id: direct.id
})
- assert User.get_cached_by_id(user_one.id).info.unread_conversation_count == 1
- assert User.get_cached_by_id(user_two.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1
+ assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0
end
- test "(vanilla) Mastodon frontend behaviour", %{conn: conn} do
- user_one = insert(:user)
+ test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do
user_two = insert(:user)
{:ok, direct} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "direct"
})
- res_conn =
- conn
- |> assign(:user, user_one)
- |> get("/api/v1/statuses/#{direct.id}/context")
+ res_conn = get(conn, "/api/v1/statuses/#{direct.id}/context")
assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200)
end
diff --git a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs
index 2d988b0b8..ab0027f90 100644
--- a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs
+++ b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs
@@ -1,16 +1,17 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.CustomEmojiControllerTest do
use Pleroma.Web.ConnCase, async: true
test "with tags", %{conn: conn} do
- [emoji | _body] =
- conn
- |> get("/api/v1/custom_emojis")
- |> json_response(200)
+ assert resp =
+ conn
+ |> get("/api/v1/custom_emojis")
+ |> json_response_and_validate_schema(200)
+ assert [emoji | _body] = resp
assert Map.has_key?(emoji, "shortcode")
assert Map.has_key?(emoji, "static_url")
assert Map.has_key?(emoji, "tags")
diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs
index 25a279cdc..01a24afcf 100644
--- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs
+++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do
@@ -9,43 +9,39 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do
import Pleroma.Factory
- test "blocking / unblocking a domain", %{conn: conn} do
- user = insert(:user)
+ test "blocking / unblocking a domain" do
+ %{user: user, conn: conn} = oauth_access(["write:blocks"])
other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"})
- conn =
+ ret_conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"})
- assert %{} = json_response(conn, 200)
+ assert %{} == json_response_and_validate_schema(ret_conn, 200)
user = User.get_cached_by_ap_id(user.ap_id)
assert User.blocks?(user, other_user)
- conn =
- build_conn()
- |> assign(:user, user)
+ ret_conn =
+ conn
+ |> put_req_header("content-type", "application/json")
|> delete("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"})
- assert %{} = json_response(conn, 200)
+ assert %{} == json_response_and_validate_schema(ret_conn, 200)
user = User.get_cached_by_ap_id(user.ap_id)
refute User.blocks?(user, other_user)
end
- test "getting a list of domain blocks", %{conn: conn} do
- user = insert(:user)
+ test "getting a list of domain blocks" do
+ %{user: user, conn: conn} = oauth_access(["read:blocks"])
{:ok, user} = User.block_domain(user, "bad.site")
{:ok, user} = User.block_domain(user, "even.worse.site")
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/domain_blocks")
-
- domain_blocks = json_response(conn, 200)
-
- assert "bad.site" in domain_blocks
- assert "even.worse.site" in domain_blocks
+ assert ["even.worse.site", "bad.site"] ==
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/domain_blocks")
+ |> json_response_and_validate_schema(200)
end
end
diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs
index 5d5b56c8e..f29547d13 100644
--- a/test/web/mastodon_api/controllers/filter_controller_test.exs
+++ b/test/web/mastodon_api/controllers/filter_controller_test.exs
@@ -1,16 +1,14 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
- use Pleroma.Web.ConnCase, async: true
+ use Pleroma.Web.ConnCase
alias Pleroma.Web.MastodonAPI.FilterView
- import Pleroma.Factory
-
- test "creating a filter", %{conn: conn} do
- user = insert(:user)
+ test "creating a filter" do
+ %{conn: conn} = oauth_access(["write:filters"])
filter = %Pleroma.Filter{
phrase: "knights",
@@ -19,10 +17,10 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
- assert response = json_response(conn, 200)
+ assert response = json_response_and_validate_schema(conn, 200)
assert response["phrase"] == filter.phrase
assert response["context"] == filter.context
assert response["irreversible"] == false
@@ -30,8 +28,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
assert response["id"] != ""
end
- test "fetching a list of filters", %{conn: conn} do
- user = insert(:user)
+ test "fetching a list of filters" do
+ %{user: user, conn: conn} = oauth_access(["read:filters"])
query_one = %Pleroma.Filter{
user_id: user.id,
@@ -52,20 +50,19 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
response =
conn
- |> assign(:user, user)
|> get("/api/v1/filters")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response ==
render_json(
FilterView,
- "filters.json",
+ "index.json",
filters: [filter_two, filter_one]
)
end
- test "get a filter", %{conn: conn} do
- user = insert(:user)
+ test "get a filter" do
+ %{user: user, conn: conn} = oauth_access(["read:filters"])
query = %Pleroma.Filter{
user_id: user.id,
@@ -76,22 +73,20 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
{:ok, filter} = Pleroma.Filter.create(query)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/filters/#{filter.filter_id}")
+ conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
- assert _response = json_response(conn, 200)
+ assert response = json_response_and_validate_schema(conn, 200)
end
- test "update a filter", %{conn: conn} do
- user = insert(:user)
+ test "update a filter" do
+ %{user: user, conn: conn} = oauth_access(["write:filters"])
query = %Pleroma.Filter{
user_id: user.id,
filter_id: 2,
phrase: "knight",
- context: ["home"]
+ context: ["home"],
+ hide: true
}
{:ok, _filter} = Pleroma.Filter.create(query)
@@ -103,19 +98,20 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> put("/api/v1/filters/#{query.filter_id}", %{
phrase: new.phrase,
context: new.context
})
- assert response = json_response(conn, 200)
+ assert response = json_response_and_validate_schema(conn, 200)
assert response["phrase"] == new.phrase
assert response["context"] == new.context
+ assert response["irreversible"] == true
end
- test "delete a filter", %{conn: conn} do
- user = insert(:user)
+ test "delete a filter" do
+ %{user: user, conn: conn} = oauth_access(["write:filters"])
query = %Pleroma.Filter{
user_id: user.id,
@@ -126,12 +122,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
{:ok, filter} = Pleroma.Filter.create(query)
- conn =
- conn
- |> assign(:user, user)
- |> delete("/api/v1/filters/#{filter.filter_id}")
+ conn = delete(conn, "/api/v1/filters/#{filter.filter_id}")
- assert response = json_response(conn, 200)
- assert response == %{}
+ assert json_response_and_validate_schema(conn, 200) == %{}
end
end
diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
index 4bf292df5..44e12d15a 100644
--- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs
+++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
@@ -11,43 +11,40 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
import Pleroma.Factory
describe "locked accounts" do
- test "/api/v1/follow_requests works" do
- user = insert(:user, %{info: %User.Info{locked: true}})
+ setup do
+ user = insert(:user, locked: true)
+ %{conn: conn} = oauth_access(["follow"], user: user)
+ %{user: user, conn: conn}
+ end
+
+ test "/api/v1/follow_requests works", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, _activity} = ActivityPub.follow(other_user, user)
-
- user = User.get_cached_by_id(user.id)
- other_user = User.get_cached_by_id(other_user.id)
+ {:ok, other_user} = User.follow(other_user, user, :follow_pending)
assert User.following?(other_user, user) == false
- conn =
- build_conn()
- |> assign(:user, user)
- |> get("/api/v1/follow_requests")
+ conn = get(conn, "/api/v1/follow_requests")
- assert [relationship] = json_response(conn, 200)
+ assert [relationship] = json_response_and_validate_schema(conn, 200)
assert to_string(other_user.id) == relationship["id"]
end
- test "/api/v1/follow_requests/:id/authorize works" do
- user = insert(:user, %{info: %User.Info{locked: true}})
+ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, _activity} = ActivityPub.follow(other_user, user)
+ {:ok, other_user} = User.follow(other_user, user, :follow_pending)
user = User.get_cached_by_id(user.id)
other_user = User.get_cached_by_id(other_user.id)
assert User.following?(other_user, user) == false
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/follow_requests/#{other_user.id}/authorize")
+ conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/authorize")
- assert relationship = json_response(conn, 200)
+ assert relationship = json_response_and_validate_schema(conn, 200)
assert to_string(other_user.id) == relationship["id"]
user = User.get_cached_by_id(user.id)
@@ -56,20 +53,16 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do
assert User.following?(other_user, user) == true
end
- test "/api/v1/follow_requests/:id/reject works" do
- user = insert(:user, %{info: %User.Info{locked: true}})
+ test "/api/v1/follow_requests/:id/reject works", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, _activity} = ActivityPub.follow(other_user, user)
user = User.get_cached_by_id(user.id)
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/follow_requests/#{other_user.id}/reject")
+ conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/reject")
- assert relationship = json_response(conn, 200)
+ assert relationship = json_response_and_validate_schema(conn, 200)
assert to_string(other_user.id) == relationship["id"]
user = User.get_cached_by_id(user.id)
diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs
index f8049f81f..2c61dc5ba 100644
--- a/test/web/mastodon_api/controllers/instance_controller_test.exs
+++ b/test/web/mastodon_api/controllers/instance_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
@@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
test "get instance information", %{conn: conn} do
conn = get(conn, "/api/v1/instance")
- assert result = json_response(conn, 200)
+ assert result = json_response_and_validate_schema(conn, 200)
email = Pleroma.Config.get([:instance, :email])
# Note: not checking for "max_toot_chars" since it's optional
@@ -34,6 +34,10 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
"banner_upload_limit" => _
} = result
+ assert result["pleroma"]["metadata"]["features"]
+ assert result["pleroma"]["metadata"]["federation"]
+ assert result["pleroma"]["vapid_public_key"]
+
assert email == from_config_email
end
@@ -41,25 +45,18 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
user = insert(:user, %{local: true})
user2 = insert(:user, %{local: true})
- {:ok, _user2} = User.deactivate(user2, !user2.info.deactivated)
+ {:ok, _user2} = User.deactivate(user2, !user2.deactivated)
insert(:user, %{local: false, nickname: "u@peer1.com"})
insert(:user, %{local: false, nickname: "u@peer2.com"})
- {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"})
-
- # Stats should count users with missing or nil `info.deactivated` value
-
- {:ok, _user} =
- user.id
- |> User.get_cached_by_id()
- |> User.update_info(&Ecto.Changeset.change(&1, %{deactivated: nil}))
+ {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"})
Pleroma.Stats.force_update()
conn = get(conn, "/api/v1/instance")
- assert result = json_response(conn, 200)
+ assert result = json_response_and_validate_schema(conn, 200)
stats = result["stats"]
@@ -77,7 +74,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
conn = get(conn, "/api/v1/instance/peers")
- assert result = json_response(conn, 200)
+ assert result = json_response_and_validate_schema(conn, 200)
assert ["peer1.com", "peer2.com"] == Enum.sort(result)
end
diff --git a/test/web/mastodon_api/controllers/list_controller_test.exs b/test/web/mastodon_api/controllers/list_controller_test.exs
index 093506309..57a9ef4a4 100644
--- a/test/web/mastodon_api/controllers/list_controller_test.exs
+++ b/test/web/mastodon_api/controllers/list_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
@@ -9,86 +9,84 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
import Pleroma.Factory
- test "creating a list", %{conn: conn} do
- user = insert(:user)
+ test "creating a list" do
+ %{conn: conn} = oauth_access(["write:lists"])
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/lists", %{"title" => "cuties"})
-
- assert %{"title" => title} = json_response(conn, 200)
- assert title == "cuties"
+ assert %{"title" => "cuties"} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/lists", %{"title" => "cuties"})
+ |> json_response_and_validate_schema(:ok)
end
- test "renders error for invalid params", %{conn: conn} do
- user = insert(:user)
+ test "renders error for invalid params" do
+ %{conn: conn} = oauth_access(["write:lists"])
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/lists", %{"title" => nil})
- assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+ assert %{"error" => "title - null value where string expected."} =
+ json_response_and_validate_schema(conn, 400)
end
- test "listing a user's lists", %{conn: conn} do
- user = insert(:user)
+ test "listing a user's lists" do
+ %{conn: conn} = oauth_access(["read:lists", "write:lists"])
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/lists", %{"title" => "cuties"})
+ |> json_response_and_validate_schema(:ok)
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/lists", %{"title" => "cofe"})
+ |> json_response_and_validate_schema(:ok)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/lists")
+ conn = get(conn, "/api/v1/lists")
assert [
%{"id" => _, "title" => "cofe"},
%{"id" => _, "title" => "cuties"}
- ] = json_response(conn, :ok)
+ ] = json_response_and_validate_schema(conn, :ok)
end
- test "adding users to a list", %{conn: conn} do
- user = insert(:user)
+ test "adding users to a list" do
+ %{user: user, conn: conn} = oauth_access(["write:lists"])
other_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user)
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+ assert %{} ==
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+ |> json_response_and_validate_schema(:ok)
- assert %{} == json_response(conn, 200)
%Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
assert following == [other_user.follower_address]
end
- test "removing users from a list", %{conn: conn} do
- user = insert(:user)
+ test "removing users from a list" do
+ %{user: user, conn: conn} = oauth_access(["write:lists"])
other_user = insert(:user)
third_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user)
{:ok, list} = Pleroma.List.follow(list, other_user)
{:ok, list} = Pleroma.List.follow(list, third_user)
- conn =
- conn
- |> assign(:user, user)
- |> delete("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+ assert %{} ==
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> delete("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
+ |> json_response_and_validate_schema(:ok)
- assert %{} == json_response(conn, 200)
%Pleroma.List{following: following} = Pleroma.List.get(list.id, user)
assert following == [third_user.follower_address]
end
- test "listing users in a list", %{conn: conn} do
- user = insert(:user)
+ test "listing users in a list" do
+ %{user: user, conn: conn} = oauth_access(["read:lists"])
other_user = insert(:user)
{:ok, list} = Pleroma.List.create("name", user)
{:ok, list} = Pleroma.List.follow(list, other_user)
@@ -98,12 +96,12 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
|> assign(:user, user)
|> get("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]})
- assert [%{"id" => id}] = json_response(conn, 200)
+ assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200)
assert id == to_string(other_user.id)
end
- test "retrieving a list", %{conn: conn} do
- user = insert(:user)
+ test "retrieving a list" do
+ %{user: user, conn: conn} = oauth_access(["read:lists"])
{:ok, list} = Pleroma.List.create("name", user)
conn =
@@ -111,56 +109,50 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do
|> assign(:user, user)
|> get("/api/v1/lists/#{list.id}")
- assert %{"id" => id} = json_response(conn, 200)
+ assert %{"id" => id} = json_response_and_validate_schema(conn, 200)
assert id == to_string(list.id)
end
- test "renders 404 if list is not found", %{conn: conn} do
- user = insert(:user)
+ test "renders 404 if list is not found" do
+ %{conn: conn} = oauth_access(["read:lists"])
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/lists/666")
+ conn = get(conn, "/api/v1/lists/666")
- assert %{"error" => "List not found"} = json_response(conn, :not_found)
+ assert %{"error" => "List not found"} = json_response_and_validate_schema(conn, :not_found)
end
- test "renaming a list", %{conn: conn} do
- user = insert(:user)
+ test "renaming a list" do
+ %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user)
- conn =
- conn
- |> assign(:user, user)
- |> put("/api/v1/lists/#{list.id}", %{"title" => "newname"})
-
- assert %{"title" => name} = json_response(conn, 200)
- assert name == "newname"
+ assert %{"title" => "newname"} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/lists/#{list.id}", %{"title" => "newname"})
+ |> json_response_and_validate_schema(:ok)
end
- test "validates title when renaming a list", %{conn: conn} do
- user = insert(:user)
+ test "validates title when renaming a list" do
+ %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user)
conn =
conn
|> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> put("/api/v1/lists/#{list.id}", %{"title" => " "})
- assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity)
+ assert %{"error" => "can't be blank"} ==
+ json_response_and_validate_schema(conn, :unprocessable_entity)
end
- test "deleting a list", %{conn: conn} do
- user = insert(:user)
+ test "deleting a list" do
+ %{user: user, conn: conn} = oauth_access(["write:lists"])
{:ok, list} = Pleroma.List.create("name", user)
- conn =
- conn
- |> assign(:user, user)
- |> delete("/api/v1/lists/#{list.id}")
+ conn = delete(conn, "/api/v1/lists/#{list.id}")
- assert %{} = json_response(conn, 200)
+ assert %{} = json_response_and_validate_schema(conn, 200)
assert is_nil(Repo.get(Pleroma.List, list.id))
end
end
diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs
index 1fcad873d..6dd40fb4a 100644
--- a/test/web/mastodon_api/controllers/marker_controller_test.exs
+++ b/test/web/mastodon_api/controllers/marker_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
test "gets markers with correct scopes", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["read:statuses"])
+ insert_list(7, :notification, user: user)
{:ok, %{"notifications" => marker}} =
Pleroma.Marker.upsert(
@@ -22,14 +23,15 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
conn
|> assign(:user, user)
|> assign(:token, token)
- |> get("/api/v1/markers", %{timeline: ["notifications"]})
- |> json_response(200)
+ |> get("/api/v1/markers?timeline[]=notifications")
+ |> json_response_and_validate_schema(200)
assert response == %{
"notifications" => %{
"last_read_id" => "69420",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
- "version" => 0
+ "version" => 0,
+ "pleroma" => %{"unread_count" => 7}
}
}
end
@@ -45,7 +47,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
|> assign(:user, user)
|> assign(:token, token)
|> get("/api/v1/markers", %{timeline: ["notifications"]})
- |> json_response(403)
+ |> json_response_and_validate_schema(403)
assert response == %{"error" => "Insufficient permissions: read:statuses."}
end
@@ -60,17 +62,19 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
conn
|> assign(:user, user)
|> assign(:token, token)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69420"}
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert %{
"notifications" => %{
"last_read_id" => "69420",
"updated_at" => _,
- "version" => 0
+ "version" => 0,
+ "pleroma" => %{"unread_count" => 0}
}
} = response
end
@@ -89,17 +93,19 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
conn
|> assign(:user, user)
|> assign(:token, token)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69888"}
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response == %{
"notifications" => %{
"last_read_id" => "69888",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
- "version" => 0
+ "version" => 0,
+ "pleroma" => %{"unread_count" => 0}
}
}
end
@@ -112,11 +118,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
conn
|> assign(:user, user)
|> assign(:token, token)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69420"}
})
- |> json_response(403)
+ |> json_response_and_validate_schema(403)
assert response == %{"error" => "Insufficient permissions: write:statuses."}
end
diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs
index 06c6a1cb3..906fd940f 100644
--- a/test/web/mastodon_api/controllers/media_controller_test.exs
+++ b/test/web/mastodon_api/controllers/media_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do
@@ -9,35 +9,30 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
- import Pleroma.Factory
+ describe "Upload media" do
+ setup do: oauth_access(["write:media"])
- describe "media upload" do
setup do
- user = insert(:user)
-
- conn =
- build_conn()
- |> assign(:user, user)
-
image = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
- [conn: conn, image: image]
+ [image: image]
end
- clear_config([:media_proxy])
- clear_config([Pleroma.Upload])
+ setup do: clear_config([:media_proxy])
+ setup do: clear_config([Pleroma.Upload])
- test "returns uploaded image", %{conn: conn, image: image} do
+ test "/api/v1/media", %{conn: conn, image: image} do
desc = "Description of the image"
media =
conn
+ |> put_req_header("content-type", "multipart/form-data")
|> post("/api/v1/media", %{"file" => image, "description" => desc})
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert media["type"] == "image"
assert media["description"] == desc
@@ -46,12 +41,38 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do
object = Object.get_by_id(media["id"])
assert object.data["actor"] == User.ap_id(conn.assigns[:user])
end
+
+ test "/api/v2/media", %{conn: conn, user: user, image: image} do
+ desc = "Description of the image"
+
+ response =
+ conn
+ |> put_req_header("content-type", "multipart/form-data")
+ |> post("/api/v2/media", %{"file" => image, "description" => desc})
+ |> json_response_and_validate_schema(202)
+
+ assert media_id = response["id"]
+
+ %{conn: conn} = oauth_access(["read:media"], user: user)
+
+ media =
+ conn
+ |> get("/api/v1/media/#{media_id}")
+ |> json_response_and_validate_schema(200)
+
+ assert media["type"] == "image"
+ assert media["description"] == desc
+ assert media["id"]
+
+ object = Object.get_by_id(media["id"])
+ assert object.data["actor"] == user.ap_id
+ end
end
- describe "PUT /api/v1/media/:id" do
- setup do
- actor = insert(:user)
+ describe "Update media description" do
+ setup do: oauth_access(["write:media"])
+ setup %{user: actor} do
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
@@ -65,28 +86,61 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do
description: "test-m"
)
- [actor: actor, object: object]
+ [object: object]
end
- test "updates name of media", %{conn: conn, actor: actor, object: object} do
+ test "/api/v1/media/:id good request", %{conn: conn, object: object} do
media =
conn
- |> assign(:user, actor)
+ |> put_req_header("content-type", "multipart/form-data")
|> put("/api/v1/media/#{object.id}", %{"description" => "test-media"})
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert media["description"] == "test-media"
assert refresh_record(object).data["name"] == "test-media"
end
+ end
+
+ describe "Get media by id (/api/v1/media/:id)" do
+ setup do: oauth_access(["read:media"])
+
+ setup %{user: actor} do
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image.jpg"),
+ filename: "an_image.jpg"
+ }
+
+ {:ok, %Object{} = object} =
+ ActivityPub.upload(
+ file,
+ actor: User.ap_id(actor),
+ description: "test-media"
+ )
+
+ [object: object]
+ end
- test "returns error wheb request is bad", %{conn: conn, actor: actor, object: object} do
+ test "it returns media object when requested by owner", %{conn: conn, object: object} do
media =
conn
- |> assign(:user, actor)
- |> put("/api/v1/media/#{object.id}", %{})
- |> json_response(400)
+ |> get("/api/v1/media/#{object.id}")
+ |> json_response_and_validate_schema(:ok)
+
+ assert media["description"] == "test-media"
+ assert media["type"] == "image"
+ assert media["id"]
+ end
+
+ test "it returns 403 if media object requested by non-owner", %{object: object, user: user} do
+ %{conn: conn, user: other_user} = oauth_access(["read:media"])
+
+ assert object.data["actor"] == user.ap_id
+ refute user.id == other_user.id
- assert media == %{"error" => "bad_request"}
+ conn
+ |> get("/api/v1/media/#{object.id}")
+ |> json_response(403)
end
end
end
diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs
index fa55a7cf9..562fc4d8e 100644
--- a/test/web/mastodon_api/controllers/notification_controller_test.exs
+++ b/test/web/mastodon_api/controllers/notification_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
@@ -12,11 +12,29 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
import Pleroma.Factory
- test "list of notifications", %{conn: conn} do
- user = insert(:user)
+ test "does NOT render account/pleroma/relationship by default" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, [_notification]} = Notification.create_notifications(activity)
+
+ response =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.all?(response, fn n ->
+ get_in(n, ["account", "pleroma", "relationship"]) == %{}
+ end)
+ end
+
+ test "list of notifications" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
@@ -26,84 +44,94 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
|> get("/api/v1/notifications")
expected_response =
- "hi <span class=\"h-card\"><a data-user=\"#{user.id}\" class=\"u-url mention\" href=\"#{
+ "hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{
user.ap_id
}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
- assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200)
+ assert [%{"status" => %{"content" => response}} | _rest] =
+ json_response_and_validate_schema(conn, 200)
+
assert response == expected_response
end
- test "getting a single notification", %{conn: conn} do
- user = insert(:user)
+ test "getting a single notification" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/notifications/#{notification.id}")
+ conn = get(conn, "/api/v1/notifications/#{notification.id}")
expected_response =
- "hi <span class=\"h-card\"><a data-user=\"#{user.id}\" class=\"u-url mention\" href=\"#{
+ "hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{
user.ap_id
}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
- assert %{"status" => %{"content" => response}} = json_response(conn, 200)
+ assert %{"status" => %{"content" => response}} = json_response_and_validate_schema(conn, 200)
assert response == expected_response
end
- test "dismissing a single notification", %{conn: conn} do
- user = insert(:user)
+ test "dismissing a single notification (deprecated endpoint)" do
+ %{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
- |> post("/api/v1/notifications/dismiss", %{"id" => notification.id})
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/notifications/dismiss", %{"id" => to_string(notification.id)})
- assert %{} = json_response(conn, 200)
+ assert %{} = json_response_and_validate_schema(conn, 200)
end
- test "clearing all notifications", %{conn: conn} do
- user = insert(:user)
+ test "dismissing a single notification" do
+ %{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
- {:ok, [_notification]} = Notification.create_notifications(activity)
+ {:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
- |> post("/api/v1/notifications/clear")
+ |> post("/api/v1/notifications/#{notification.id}/dismiss")
- assert %{} = json_response(conn, 200)
+ assert %{} = json_response_and_validate_schema(conn, 200)
+ end
- conn =
- build_conn()
- |> assign(:user, user)
- |> get("/api/v1/notifications")
+ test "clearing all notifications" do
+ %{user: user, conn: conn} = oauth_access(["write:notifications", "read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+
+ {:ok, [_notification]} = Notification.create_notifications(activity)
+
+ ret_conn = post(conn, "/api/v1/notifications/clear")
- assert all = json_response(conn, 200)
+ assert %{} = json_response_and_validate_schema(ret_conn, 200)
+
+ ret_conn = get(conn, "/api/v1/notifications")
+
+ assert all = json_response_and_validate_schema(ret_conn, 200)
assert all == []
end
- test "paginates notifications using min_id, since_id, max_id, and limit", %{conn: conn} do
- user = insert(:user)
+ test "paginates notifications using min_id, since_id, max_id, and limit" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
- {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
- {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
- {:ok, activity3} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
- {:ok, activity4} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
+ {:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, activity3} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, activity4} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
@@ -116,7 +144,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
result =
conn
|> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
@@ -124,7 +152,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
result =
conn
|> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
@@ -132,69 +160,147 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
result =
conn
|> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
end
- test "filters notifications using exclude_visibilities", %{conn: conn} do
- user = insert(:user)
- other_user = insert(:user)
+ describe "exclude_visibilities" do
+ test "filters notifications for mentions" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
- {:ok, public_activity} =
- CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "public"})
+ {:ok, public_activity} =
+ CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "public"})
- {:ok, direct_activity} =
- CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "direct"})
+ {:ok, direct_activity} =
+ CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
- {:ok, unlisted_activity} =
- CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "unlisted"})
+ {:ok, unlisted_activity} =
+ CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "unlisted"})
- {:ok, private_activity} =
- CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "private"})
+ {:ok, private_activity} =
+ CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "private"})
- conn = assign(conn, :user, user)
+ query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "private"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
+
+ assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
+ assert id == direct_activity.id
+
+ query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "direct"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
+
+ assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
+ assert id == private_activity.id
+
+ query = params_to_query(%{exclude_visibilities: ["public", "private", "direct"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
+
+ assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
+ assert id == unlisted_activity.id
+
+ query = params_to_query(%{exclude_visibilities: ["unlisted", "private", "direct"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
+
+ assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
+ assert id == public_activity.id
+ end
+
+ test "filters notifications for Like activities" do
+ user = insert(:user)
+ %{user: other_user, conn: conn} = oauth_access(["read:notifications"])
+
+ {:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
- conn_res =
- get(conn, "/api/v1/notifications", %{
- exclude_visibilities: ["public", "unlisted", "private"]
- })
+ {:ok, direct_activity} =
+ CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
- assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
- assert id == direct_activity.id
+ {:ok, unlisted_activity} =
+ CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
- conn_res =
- get(conn, "/api/v1/notifications", %{
- exclude_visibilities: ["public", "unlisted", "direct"]
- })
+ {:ok, private_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "private"})
- assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
- assert id == private_activity.id
+ {:ok, _} = CommonAPI.favorite(user, public_activity.id)
+ {:ok, _} = CommonAPI.favorite(user, direct_activity.id)
+ {:ok, _} = CommonAPI.favorite(user, unlisted_activity.id)
+ {:ok, _} = CommonAPI.favorite(user, private_activity.id)
- conn_res =
- get(conn, "/api/v1/notifications", %{
- exclude_visibilities: ["public", "private", "direct"]
- })
+ activity_ids =
+ conn
+ |> get("/api/v1/notifications?exclude_visibilities[]=direct")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["status"]["id"])
- assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
- assert id == unlisted_activity.id
+ assert public_activity.id in activity_ids
+ assert unlisted_activity.id in activity_ids
+ assert private_activity.id in activity_ids
+ refute direct_activity.id in activity_ids
- conn_res =
- get(conn, "/api/v1/notifications", %{
- exclude_visibilities: ["unlisted", "private", "direct"]
- })
+ activity_ids =
+ conn
+ |> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["status"]["id"])
- assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200)
- assert id == public_activity.id
+ assert public_activity.id in activity_ids
+ refute unlisted_activity.id in activity_ids
+ assert private_activity.id in activity_ids
+ assert direct_activity.id in activity_ids
+
+ activity_ids =
+ conn
+ |> get("/api/v1/notifications?exclude_visibilities[]=private")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["status"]["id"])
+
+ assert public_activity.id in activity_ids
+ assert unlisted_activity.id in activity_ids
+ refute private_activity.id in activity_ids
+ assert direct_activity.id in activity_ids
+
+ activity_ids =
+ conn
+ |> get("/api/v1/notifications?exclude_visibilities[]=public")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["status"]["id"])
+
+ refute public_activity.id in activity_ids
+ assert unlisted_activity.id in activity_ids
+ assert private_activity.id in activity_ids
+ assert direct_activity.id in activity_ids
+ end
+
+ test "filters notifications for Announce activities" do
+ user = insert(:user)
+ %{user: other_user, conn: conn} = oauth_access(["read:notifications"])
+
+ {:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
+
+ {:ok, unlisted_activity} =
+ CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
+
+ {:ok, _, _} = CommonAPI.repeat(public_activity.id, user)
+ {:ok, _, _} = CommonAPI.repeat(unlisted_activity.id, user)
+
+ activity_ids =
+ conn
+ |> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["status"]["id"])
+
+ assert public_activity.id in activity_ids
+ refute unlisted_activity.id in activity_ids
+ end
end
- test "filters notifications using exclude_types", %{conn: conn} do
- user = insert(:user)
+ test "filters notifications using exclude_types" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
- {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"})
- {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"})
- {:ok, favorite_activity, _} = CommonAPI.favorite(create_activity.id, other_user)
+ {:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
+ {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
+ {:ok, favorite_activity} = CommonAPI.favorite(other_user, create_activity.id)
{:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user)
@@ -203,142 +309,257 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
reblog_notification_id = get_notification_id_by_activity(reblog_activity)
follow_notification_id = get_notification_id_by_activity(follow_activity)
- conn = assign(conn, :user, user)
+ query = params_to_query(%{exclude_types: ["mention", "favourite", "reblog"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
+
+ assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
+
+ query = params_to_query(%{exclude_types: ["favourite", "reblog", "follow"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
- conn_res =
- get(conn, "/api/v1/notifications", %{exclude_types: ["mention", "favourite", "reblog"]})
+ assert [%{"id" => ^mention_notification_id}] =
+ json_response_and_validate_schema(conn_res, 200)
- assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200)
+ query = params_to_query(%{exclude_types: ["reblog", "follow", "mention"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
- conn_res =
- get(conn, "/api/v1/notifications", %{exclude_types: ["favourite", "reblog", "follow"]})
+ assert [%{"id" => ^favorite_notification_id}] =
+ json_response_and_validate_schema(conn_res, 200)
- assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200)
+ query = params_to_query(%{exclude_types: ["follow", "mention", "favourite"]})
+ conn_res = get(conn, "/api/v1/notifications?" <> query)
- conn_res =
- get(conn, "/api/v1/notifications", %{exclude_types: ["reblog", "follow", "mention"]})
+ assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
+ end
+
+ test "filters notifications using include_types" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
+ {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
+ {:ok, favorite_activity} = CommonAPI.favorite(other_user, create_activity.id)
+ {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user)
+ {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user)
+
+ mention_notification_id = get_notification_id_by_activity(mention_activity)
+ favorite_notification_id = get_notification_id_by_activity(favorite_activity)
+ reblog_notification_id = get_notification_id_by_activity(reblog_activity)
+ follow_notification_id = get_notification_id_by_activity(follow_activity)
+
+ conn_res = get(conn, "/api/v1/notifications?include_types[]=follow")
+
+ assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
- assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200)
+ conn_res = get(conn, "/api/v1/notifications?include_types[]=mention")
- conn_res =
- get(conn, "/api/v1/notifications", %{exclude_types: ["follow", "mention", "favourite"]})
+ assert [%{"id" => ^mention_notification_id}] =
+ json_response_and_validate_schema(conn_res, 200)
- assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200)
+ conn_res = get(conn, "/api/v1/notifications?include_types[]=favourite")
+
+ assert [%{"id" => ^favorite_notification_id}] =
+ json_response_and_validate_schema(conn_res, 200)
+
+ conn_res = get(conn, "/api/v1/notifications?include_types[]=reblog")
+
+ assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
+
+ result = conn |> get("/api/v1/notifications") |> json_response_and_validate_schema(200)
+
+ assert length(result) == 4
+
+ query = params_to_query(%{include_types: ["follow", "mention", "favourite", "reblog"]})
+
+ result =
+ conn
+ |> get("/api/v1/notifications?" <> query)
+ |> json_response_and_validate_schema(200)
+
+ assert length(result) == 4
end
- test "destroy multiple", %{conn: conn} do
- user = insert(:user)
+ test "destroy multiple" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
other_user = insert(:user)
- {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
- {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"})
- {:ok, activity3} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"})
- {:ok, activity4} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"})
+ {:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
+ {:ok, activity3} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
+ {:ok, activity4} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
notification3_id = get_notification_id_by_activity(activity3)
notification4_id = get_notification_id_by_activity(activity4)
- conn = assign(conn, :user, user)
-
result =
conn
|> get("/api/v1/notifications")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result
conn2 =
conn
|> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:notifications"]))
result =
conn2
|> get("/api/v1/notifications")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
- conn_destroy =
- conn
- |> delete("/api/v1/notifications/destroy_multiple", %{
- "ids" => [notification1_id, notification2_id]
- })
+ query = params_to_query(%{ids: [notification1_id, notification2_id]})
+ conn_destroy = delete(conn, "/api/v1/notifications/destroy_multiple?" <> query)
- assert json_response(conn_destroy, 200) == %{}
+ assert json_response_and_validate_schema(conn_destroy, 200) == %{}
result =
conn2
|> get("/api/v1/notifications")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
end
- test "doesn't see notifications after muting user with notifications", %{conn: conn} do
- user = insert(:user)
+ test "doesn't see notifications after muting user with notifications" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user, user2)
- {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"})
-
- conn = assign(conn, :user, user)
+ {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
- conn = get(conn, "/api/v1/notifications")
+ ret_conn = get(conn, "/api/v1/notifications")
- assert length(json_response(conn, 200)) == 1
+ assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
- {:ok, user} = User.mute(user, user2)
+ {:ok, _user_relationships} = User.mute(user, user2)
- conn = assign(build_conn(), :user, user)
conn = get(conn, "/api/v1/notifications")
- assert json_response(conn, 200) == []
+ assert json_response_and_validate_schema(conn, 200) == []
end
- test "see notifications after muting user without notifications", %{conn: conn} do
- user = insert(:user)
+ test "see notifications after muting user without notifications" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user, user2)
- {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
- conn = assign(conn, :user, user)
-
- conn = get(conn, "/api/v1/notifications")
+ ret_conn = get(conn, "/api/v1/notifications")
- assert length(json_response(conn, 200)) == 1
+ assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
- {:ok, user} = User.mute(user, user2, false)
+ {:ok, _user_relationships} = User.mute(user, user2, false)
- conn = assign(build_conn(), :user, user)
conn = get(conn, "/api/v1/notifications")
- assert length(json_response(conn, 200)) == 1
+ assert length(json_response_and_validate_schema(conn, 200)) == 1
end
- test "see notifications after muting user with notifications and with_muted parameter", %{
- conn: conn
- } do
- user = insert(:user)
+ test "see notifications after muting user with notifications and with_muted parameter" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user, user2)
- {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"})
+ {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
- conn = assign(conn, :user, user)
+ ret_conn = get(conn, "/api/v1/notifications")
+
+ assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
+
+ {:ok, _user_relationships} = User.mute(user, user2)
+
+ conn = get(conn, "/api/v1/notifications?with_muted=true")
+
+ assert length(json_response_and_validate_schema(conn, 200)) == 1
+ end
+
+ @tag capture_log: true
+ test "see move notifications" do
+ old_user = insert(:user)
+ new_user = insert(:user, also_known_as: [old_user.ap_id])
+ %{user: follower, conn: conn} = oauth_access(["read:notifications"])
+
+ old_user_url = old_user.ap_id
+
+ body =
+ File.read!("test/fixtures/users_mock/localhost.json")
+ |> String.replace("{{nickname}}", old_user.nickname)
+ |> Jason.encode!()
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^old_user_url} ->
+ %Tesla.Env{status: 200, body: body}
+ end)
+
+ User.follow(follower, old_user)
+ Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
+ Pleroma.Tests.ObanHelpers.perform_all()
conn = get(conn, "/api/v1/notifications")
- assert length(json_response(conn, 200)) == 1
+ assert length(json_response_and_validate_schema(conn, 200)) == 1
+ end
+
+ describe "link headers" do
+ test "preserves parameters in link headers" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, activity1} =
+ CommonAPI.post(other_user, %{
+ status: "hi @#{user.nickname}",
+ visibility: "public"
+ })
+
+ {:ok, activity2} =
+ CommonAPI.post(other_user, %{
+ status: "hi @#{user.nickname}",
+ visibility: "public"
+ })
+
+ notification1 = Repo.get_by(Notification, activity_id: activity1.id)
+ notification2 = Repo.get_by(Notification, activity_id: activity2.id)
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/notifications?limit=5")
+
+ assert [link_header] = get_resp_header(conn, "link")
+ assert link_header =~ ~r/limit=5/
+ assert link_header =~ ~r/min_id=#{notification2.id}/
+ assert link_header =~ ~r/max_id=#{notification1.id}/
+ end
+ end
+
+ describe "from specified user" do
+ test "account_id" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
- {:ok, user} = User.mute(user, user2)
+ %{id: account_id} = other_user1 = insert(:user)
+ other_user2 = insert(:user)
- conn = assign(build_conn(), :user, user)
- conn = get(conn, "/api/v1/notifications", %{"with_muted" => "true"})
+ {:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi @#{user.nickname}"})
+ {:ok, _activity} = CommonAPI.post(other_user2, %{status: "bye @#{user.nickname}"})
- assert length(json_response(conn, 200)) == 1
+ assert [%{"account" => %{"id" => ^account_id}}] =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/notifications?account_id=#{account_id}")
+ |> json_response_and_validate_schema(200)
+
+ assert %{"error" => "Account is not found"} =
+ conn
+ |> assign(:user, user)
+ |> get("/api/v1/notifications?account_id=cofe")
+ |> json_response_and_validate_schema(404)
+ end
end
defp get_notification_id_by_activity(%{id: id}) do
@@ -347,4 +568,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
|> Map.get(:id)
|> to_string()
end
+
+ defp params_to_query(%{} = params) do
+ Enum.map_join(params, "&", fn
+ {k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}")
+ {k, v} -> k <> "=" <> v
+ end)
+ end
end
diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs
index 40cf3e879..f41de6448 100644
--- a/test/web/mastodon_api/controllers/poll_controller_test.exs
+++ b/test/web/mastodon_api/controllers/poll_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
@@ -11,61 +11,55 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
import Pleroma.Factory
describe "GET /api/v1/polls/:id" do
- test "returns poll entity for object id", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["read:statuses"])
+ test "returns poll entity for object id", %{user: user, conn: conn} do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Pleroma does",
- "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20}
+ status: "Pleroma does",
+ poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20}
})
object = Object.normalize(activity)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/polls/#{object.id}")
+ conn = get(conn, "/api/v1/polls/#{object.id}")
- response = json_response(conn, 200)
+ response = json_response_and_validate_schema(conn, 200)
id = to_string(object.id)
assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
end
test "does not expose polls for private statuses", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{
- "status" => "Pleroma does",
- "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20},
- "visibility" => "private"
+ CommonAPI.post(other_user, %{
+ status: "Pleroma does",
+ poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20},
+ visibility: "private"
})
object = Object.normalize(activity)
- conn =
- conn
- |> assign(:user, other_user)
- |> get("/api/v1/polls/#{object.id}")
+ conn = get(conn, "/api/v1/polls/#{object.id}")
- assert json_response(conn, 404)
+ assert json_response_and_validate_schema(conn, 404)
end
end
describe "POST /api/v1/polls/:id/votes" do
+ setup do: oauth_access(["write:statuses"])
+
test "votes are added to the poll", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{
- "status" => "A very delicious sandwich",
- "poll" => %{
- "options" => ["Lettuce", "Grilled Bacon", "Tomato"],
- "expires_in" => 20,
- "multiple" => true
+ CommonAPI.post(other_user, %{
+ status: "A very delicious sandwich",
+ poll: %{
+ options: ["Lettuce", "Grilled Bacon", "Tomato"],
+ expires_in: 20,
+ multiple: true
}
})
@@ -73,10 +67,10 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
conn =
conn
- |> assign(:user, other_user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
- assert json_response(conn, 200)
+ assert json_response_and_validate_schema(conn, 200)
object = Object.get_by_id(object.id)
assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
@@ -84,21 +78,19 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
end)
end
- test "author can't vote", %{conn: conn} do
- user = insert(:user)
-
+ test "author can't vote", %{user: user, conn: conn} do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Am I cute?",
- "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}
+ status: "Am I cute?",
+ poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity)
assert conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
- |> json_response(422) == %{"error" => "Poll's author can't vote"}
+ |> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
object = Object.get_by_id(object.id)
@@ -106,21 +98,20 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
end
test "does not allow multiple choices on a single-choice question", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{
- "status" => "The glass is",
- "poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20}
+ CommonAPI.post(other_user, %{
+ status: "The glass is",
+ poll: %{options: ["half empty", "half full"], expires_in: 20}
})
object = Object.normalize(activity)
assert conn
- |> assign(:user, other_user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
- |> json_response(422) == %{"error" => "Too many choices"}
+ |> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
object = Object.get_by_id(object.id)
@@ -130,55 +121,51 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
end
test "does not allow choice index to be greater than options count", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{
- "status" => "Am I cute?",
- "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}
+ CommonAPI.post(other_user, %{
+ status: "Am I cute?",
+ poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity)
conn =
conn
- |> assign(:user, other_user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
- assert json_response(conn, 422) == %{"error" => "Invalid indices"}
+ assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
end
test "returns 404 error when object is not exist", %{conn: conn} do
- user = insert(:user)
-
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/1/votes", %{"choices" => [0]})
- assert json_response(conn, 404) == %{"error" => "Record not found"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
test "returns 404 when poll is private and not available for user", %{conn: conn} do
- user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{
- "status" => "Am I cute?",
- "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20},
- "visibility" => "private"
+ CommonAPI.post(other_user, %{
+ status: "Am I cute?",
+ poll: %{options: ["Yes", "No"], expires_in: 20},
+ visibility: "private"
})
object = Object.normalize(activity)
conn =
conn
- |> assign(:user, other_user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
- assert json_response(conn, 404) == %{"error" => "Record not found"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
end
diff --git a/test/web/mastodon_api/controllers/report_controller_test.exs b/test/web/mastodon_api/controllers/report_controller_test.exs
index 979ca48f3..6636cff96 100644
--- a/test/web/mastodon_api/controllers/report_controller_test.exs
+++ b/test/web/mastodon_api/controllers/report_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do
@@ -9,56 +9,54 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do
import Pleroma.Factory
+ setup do: oauth_access(["write:reports"])
+
setup do
- reporter = insert(:user)
target_user = insert(:user)
- {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"})
+ {:ok, activity} = CommonAPI.post(target_user, %{status: "foobar"})
- [reporter: reporter, target_user: target_user, activity: activity]
+ [target_user: target_user, activity: activity]
end
- test "submit a basic report", %{conn: conn, reporter: reporter, target_user: target_user} do
+ test "submit a basic report", %{conn: conn, target_user: target_user} do
assert %{"action_taken" => false, "id" => _} =
conn
- |> assign(:user, reporter)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/reports", %{"account_id" => target_user.id})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
test "submit a report with statuses and comment", %{
conn: conn,
- reporter: reporter,
target_user: target_user,
activity: activity
} do
assert %{"action_taken" => false, "id" => _} =
conn
- |> assign(:user, reporter)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/reports", %{
"account_id" => target_user.id,
"status_ids" => [activity.id],
"comment" => "bad status!",
"forward" => "false"
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
test "account_id is required", %{
conn: conn,
- reporter: reporter,
activity: activity
} do
- assert %{"error" => "Valid `account_id` required"} =
+ assert %{"error" => "Missing field: account_id."} =
conn
- |> assign(:user, reporter)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/reports", %{"status_ids" => [activity.id]})
- |> json_response(400)
+ |> json_response_and_validate_schema(400)
end
test "comment must be up to the size specified in the config", %{
conn: conn,
- reporter: reporter,
target_user: target_user
} do
max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
@@ -68,21 +66,30 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do
assert ^error =
conn
- |> assign(:user, reporter)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/reports", %{"account_id" => target_user.id, "comment" => comment})
- |> json_response(400)
+ |> json_response_and_validate_schema(400)
end
test "returns error when account is not exist", %{
conn: conn,
- reporter: reporter,
activity: activity
} do
conn =
conn
- |> assign(:user, reporter)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/reports", %{"status_ids" => [activity.id], "account_id" => "foo"})
- assert json_response(conn, 400) == %{"error" => "Account not found"}
+ assert json_response_and_validate_schema(conn, 400) == %{"error" => "Account not found"}
+ end
+
+ test "doesn't fail if an admin has no email", %{conn: conn, target_user: target_user} do
+ insert(:user, %{is_admin: true, email: nil})
+
+ assert %{"action_taken" => false, "id" => _} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/reports", %{"account_id" => target_user.id})
+ |> json_response_and_validate_schema(200)
end
end
diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
index 9ad6a4fa7..1ff871c89 100644
--- a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
+++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
@@ -1,113 +1,139 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do
- use Pleroma.Web.ConnCase, async: true
+ use Pleroma.Web.ConnCase
alias Pleroma.Repo
alias Pleroma.ScheduledActivity
import Pleroma.Factory
+ import Ecto.Query
+
+ setup do: clear_config([ScheduledActivity, :enabled])
+
+ test "shows scheduled activities" do
+ %{user: user, conn: conn} = oauth_access(["read:statuses"])
- test "shows scheduled activities", %{conn: conn} do
- user = insert(:user)
scheduled_activity_id1 = insert(:scheduled_activity, user: user).id |> to_string()
scheduled_activity_id2 = insert(:scheduled_activity, user: user).id |> to_string()
scheduled_activity_id3 = insert(:scheduled_activity, user: user).id |> to_string()
scheduled_activity_id4 = insert(:scheduled_activity, user: user).id |> to_string()
- conn =
- conn
- |> assign(:user, user)
-
# min_id
- conn_res =
- conn
- |> get("/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}")
+ conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}")
- result = json_response(conn_res, 200)
+ result = json_response_and_validate_schema(conn_res, 200)
assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
# since_id
- conn_res =
- conn
- |> get("/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}")
+ conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}")
- result = json_response(conn_res, 200)
+ result = json_response_and_validate_schema(conn_res, 200)
assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result
# max_id
- conn_res =
- conn
- |> get("/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}")
+ conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}")
- result = json_response(conn_res, 200)
+ result = json_response_and_validate_schema(conn_res, 200)
assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
end
- test "shows a scheduled activity", %{conn: conn} do
- user = insert(:user)
+ test "shows a scheduled activity" do
+ %{user: user, conn: conn} = oauth_access(["read:statuses"])
scheduled_activity = insert(:scheduled_activity, user: user)
- res_conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
+ res_conn = get(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}")
- assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200)
+ assert %{"id" => scheduled_activity_id} = json_response_and_validate_schema(res_conn, 200)
assert scheduled_activity_id == scheduled_activity.id |> to_string()
- res_conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/scheduled_statuses/404")
+ res_conn = get(conn, "/api/v1/scheduled_statuses/404")
- assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
end
- test "updates a scheduled activity", %{conn: conn} do
- user = insert(:user)
- scheduled_activity = insert(:scheduled_activity, user: user)
+ test "updates a scheduled activity" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], true)
+ %{user: user, conn: conn} = oauth_access(["write:statuses"])
+
+ scheduled_at = Timex.shift(NaiveDateTime.utc_now(), minutes: 60)
+
+ {:ok, scheduled_activity} =
+ ScheduledActivity.create(
+ user,
+ %{
+ scheduled_at: scheduled_at,
+ params: build(:note).data
+ }
+ )
+
+ job = Repo.one(from(j in Oban.Job, where: j.queue == "scheduled_activities"))
+
+ assert job.args == %{"activity_id" => scheduled_activity.id}
+ assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(scheduled_at)
new_scheduled_at =
- NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+ NaiveDateTime.utc_now()
+ |> Timex.shift(minutes: 120)
+ |> Timex.format!("%Y-%m-%dT%H:%M:%S.%fZ", :strftime)
res_conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
scheduled_at: new_scheduled_at
})
- assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200)
+ assert %{"scheduled_at" => expected_scheduled_at} =
+ json_response_and_validate_schema(res_conn, 200)
+
assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at)
+ job = refresh_record(job)
+
+ assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(new_scheduled_at)
res_conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
- assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
end
- test "deletes a scheduled activity", %{conn: conn} do
- user = insert(:user)
- scheduled_activity = insert(:scheduled_activity, user: user)
+ test "deletes a scheduled activity" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], true)
+ %{user: user, conn: conn} = oauth_access(["write:statuses"])
+ scheduled_at = Timex.shift(NaiveDateTime.utc_now(), minutes: 60)
+
+ {:ok, scheduled_activity} =
+ ScheduledActivity.create(
+ user,
+ %{
+ scheduled_at: scheduled_at,
+ params: build(:note).data
+ }
+ )
+
+ job = Repo.one(from(j in Oban.Job, where: j.queue == "scheduled_activities"))
+
+ assert job.args == %{"activity_id" => scheduled_activity.id}
res_conn =
conn
|> assign(:user, user)
|> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
- assert %{} = json_response(res_conn, 200)
- assert nil == Repo.get(ScheduledActivity, scheduled_activity.id)
+ assert %{} = json_response_and_validate_schema(res_conn, 200)
+ refute Repo.get(ScheduledActivity, scheduled_activity.id)
+ refute Repo.get(Oban.Job, job.id)
res_conn =
conn
|> assign(:user, user)
|> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
- assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
end
end
diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs
index 7953fad62..7d0cafccc 100644
--- a/test/web/mastodon_api/controllers/search_controller_test.exs
+++ b/test/web/mastodon_api/controllers/search_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
import Tesla.Mock
import Mock
- setup do
+ setup_all do
mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
@@ -27,8 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
capture_log(fn ->
results =
conn
- |> get("/api/v2/search", %{"q" => "2hu"})
- |> json_response(200)
+ |> get("/api/v2/search?q=2hu")
+ |> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
@@ -42,19 +42,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
- {:ok, activity} = CommonAPI.post(user, %{"status" => "This is about 2hu private 天子"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
{:ok, _activity} =
CommonAPI.post(user, %{
- "status" => "This is about 2hu, but private",
- "visibility" => "private"
+ status: "This is about 2hu, but private",
+ visibility: "private"
})
- {:ok, _} = CommonAPI.post(user_two, %{"status" => "This isn't"})
+ {:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
results =
- get(conn, "/api/v2/search", %{"q" => "2hu #private"})
- |> json_response(200)
+ conn
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
+ |> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
@@ -67,25 +68,47 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert status["id"] == to_string(activity.id)
results =
- get(conn, "/api/v2/search", %{"q" => "天子"})
- |> json_response(200)
+ get(conn, "/api/v2/search?q=天子")
+ |> json_response_and_validate_schema(200)
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
+
+ test "excludes a blocked users from search results", %{conn: conn} do
+ user = insert(:user)
+ user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"})
+ user_neo = insert(:user, %{nickname: "Agent Neo", name: "Agent"})
+
+ {:ok, act1} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
+ {:ok, act2} = CommonAPI.post(user_smith, %{status: "Agent Smith"})
+ {:ok, act3} = CommonAPI.post(user_neo, %{status: "Agent Smith"})
+ Pleroma.User.block(user, user_smith)
+
+ results =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
+ |> get("/api/v2/search?q=Agent")
+ |> json_response_and_validate_schema(200)
+
+ status_ids = Enum.map(results["statuses"], fn g -> g["id"] end)
+
+ assert act3.id in status_ids
+ refute act2.id in status_ids
+ refute act1.id in status_ids
+ end
end
describe ".account_search" do
test "account search", %{conn: conn} do
- user = insert(:user)
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
results =
conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/search", %{"q" => "shp"})
- |> json_response(200)
+ |> get("/api/v1/accounts/search?q=shp")
+ |> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
@@ -94,9 +117,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
results =
conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/search", %{"q" => "2hu"})
- |> json_response(200)
+ |> get("/api/v1/accounts/search?q=2hu")
+ |> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
@@ -104,13 +126,12 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
end
test "returns account if query contains a space", %{conn: conn} do
- user = insert(:user, %{nickname: "shp@shitposter.club"})
+ insert(:user, %{nickname: "shp@shitposter.club"})
results =
conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/search", %{"q" => "shp@shitposter.club xxx "})
- |> json_response(200)
+ |> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
+ |> json_response_and_validate_schema(200)
assert length(results) == 1
end
@@ -125,8 +146,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
capture_log(fn ->
results =
conn
- |> get("/api/v1/search", %{"q" => "2hu"})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu")
+ |> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
@@ -140,21 +161,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
- {:ok, activity} = CommonAPI.post(user, %{"status" => "This is about 2hu"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, _activity} =
CommonAPI.post(user, %{
- "status" => "This is about 2hu, but private",
- "visibility" => "private"
+ status: "This is about 2hu, but private",
+ visibility: "private"
})
- {:ok, _} = CommonAPI.post(user_two, %{"status" => "This isn't"})
+ {:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
- conn =
+ results =
conn
- |> get("/api/v1/search", %{"q" => "2hu"})
-
- assert results = json_response(conn, 200)
+ |> get("/api/v1/search?q=2hu")
+ |> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
@@ -165,15 +185,19 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert status["id"] == to_string(activity.id)
end
- test "search fetches remote statuses", %{conn: conn} do
+ test "search fetches remote statuses and prefers them over other results", %{conn: conn} do
capture_log(fn ->
- conn =
- conn
- |> get("/api/v1/search", %{"q" => "https://shitposter.club/notice/2827873"})
+ {:ok, %{id: activity_id}} =
+ CommonAPI.post(insert(:user), %{
+ status: "check out https://shitposter.club/notice/2827873"
+ })
- assert results = json_response(conn, 200)
+ results =
+ conn
+ |> get("/api/v1/search?q=https://shitposter.club/notice/2827873")
+ |> json_response_and_validate_schema(200)
- [status] = results["statuses"]
+ [status, %{"id" => ^activity_id}] = results["statuses"]
assert status["uri"] ==
"tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment"
@@ -183,16 +207,17 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
test "search doesn't show statuses that it shouldn't", %{conn: conn} do
{:ok, activity} =
CommonAPI.post(insert(:user), %{
- "status" => "This is about 2hu, but private",
- "visibility" => "private"
+ status: "This is about 2hu, but private",
+ visibility: "private"
})
capture_log(fn ->
- conn =
- conn
- |> get("/api/v1/search", %{"q" => Object.normalize(activity).data["id"]})
+ q = Object.normalize(activity).data["id"]
- assert results = json_response(conn, 200)
+ results =
+ conn
+ |> get("/api/v1/search?q=#{q}")
+ |> json_response_and_validate_schema(200)
[] = results["statuses"]
end)
@@ -201,22 +226,23 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
test "search fetches remote accounts", %{conn: conn} do
user = insert(:user)
- conn =
+ results =
conn
|> assign(:user, user)
- |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "true"})
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
+ |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=true")
+ |> json_response_and_validate_schema(200)
- assert results = json_response(conn, 200)
[account] = results["accounts"]
assert account["acct"] == "mike@osada.macgirvin.com"
end
test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do
- conn =
+ results =
conn
- |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "false"})
+ |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false")
+ |> json_response_and_validate_schema(200)
- assert results = json_response(conn, 200)
assert [] == results["accounts"]
end
@@ -225,21 +251,21 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
_user_two = insert(:user, %{nickname: "shp@shitposter.club"})
_user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
- {:ok, _activity1} = CommonAPI.post(user, %{"status" => "This is about 2hu"})
- {:ok, _activity2} = CommonAPI.post(user, %{"status" => "This is also about 2hu"})
+ {:ok, _activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
+ {:ok, _activity2} = CommonAPI.post(user, %{status: "This is also about 2hu"})
result =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1})
+ |> get("/api/v1/search?q=2hu&limit=1")
- assert results = json_response(result, 200)
+ assert results = json_response_and_validate_schema(result, 200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert [_] = results["accounts"]
results =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1, "offset" => 1})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu&limit=1&offset=1")
+ |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert [] = results["accounts"]
@@ -251,30 +277,30 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
user = insert(:user)
_user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "This is about 2hu"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "type" => "statuses"})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu&type=statuses")
+ |> json_response_and_validate_schema(200)
assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "type" => "accounts"})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu&type=accounts")
+ |> json_response_and_validate_schema(200)
end
test "search uses account_id to filter statuses by the author", %{conn: conn} do
user = insert(:user, %{nickname: "shp@shitposter.club"})
user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
- {:ok, activity1} = CommonAPI.post(user, %{"status" => "This is about 2hu"})
- {:ok, activity2} = CommonAPI.post(user_two, %{"status" => "This is also about 2hu"})
+ {:ok, activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
+ {:ok, activity2} = CommonAPI.post(user_two, %{status: "This is also about 2hu"})
results =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user.id})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu&account_id=#{user.id}")
+ |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert activity_id1 == activity1.id
@@ -282,8 +308,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
results =
conn
- |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user_two.id})
- |> json_response(200)
+ |> get("/api/v1/search?q=2hu&account_id=#{user_two.id}")
+ |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert activity_id2 == activity2.id
diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs
index 4da610b28..bdee88fd3 100644
--- a/test/web/mastodon_api/controllers/status_controller_test.exs
+++ b/test/web/mastodon_api/controllers/status_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
@@ -19,44 +19,35 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
import Pleroma.Factory
- clear_config([:instance, :federating])
- clear_config([:instance, :allow_relay])
+ setup do: clear_config([:instance, :federating])
+ setup do: clear_config([:instance, :allow_relay])
+ setup do: clear_config([:rich_media, :enabled])
describe "posting statuses" do
- setup do
- user = insert(:user)
-
- conn =
- build_conn()
- |> assign(:user, user)
-
- [conn: conn]
- end
+ setup do: oauth_access(["write:statuses"])
test "posting a status does not increment reblog_count when relaying", %{conn: conn} do
Pleroma.Config.put([:instance, :federating], true)
Pleroma.Config.get([:instance, :allow_relay], true)
- user = insert(:user)
response =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("api/v1/statuses", %{
"content_type" => "text/plain",
"source" => "Pleroma FE",
"status" => "Hello world",
"visibility" => "public"
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response["reblogs_count"] == 0
ObanHelpers.perform_all()
response =
conn
- |> assign(:user, user)
|> get("api/v1/statuses/#{response["id"]}", %{})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response["reblogs_count"] == 0
end
@@ -66,6 +57,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn_one =
conn
+ |> put_req_header("content-type", "application/json")
|> put_req_header("idempotency-key", idempotency_key)
|> post("/api/v1/statuses", %{
"status" => "cofe",
@@ -78,12 +70,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
assert ttl > :timer.seconds(6 * 60 * 60 - 1)
assert %{"content" => "cofe", "id" => id, "spoiler_text" => "2hu", "sensitive" => false} =
- json_response(conn_one, 200)
+ json_response_and_validate_schema(conn_one, 200)
assert Activity.get_by_id(id)
conn_two =
conn
+ |> put_req_header("content-type", "application/json")
|> put_req_header("idempotency-key", idempotency_key)
|> post("/api/v1/statuses", %{
"status" => "cofe",
@@ -96,13 +89,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn_three =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "cofe",
"spoiler_text" => "2hu",
"sensitive" => "false"
})
- assert %{"id" => third_id} = json_response(conn_three, 200)
+ assert %{"id" => third_id} = json_response_and_validate_schema(conn_three, 200)
refute id == third_id
# An activity that will expire:
@@ -111,12 +105,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn_four =
conn
+ |> put_req_header("content-type", "application/json")
|> post("api/v1/statuses", %{
"status" => "oolong",
"expires_in" => expires_in
})
- assert fourth_response = %{"id" => fourth_id} = json_response(conn_four, 200)
+ assert fourth_response =
+ %{"id" => fourth_id} = json_response_and_validate_schema(conn_four, 200)
+
assert activity = Activity.get_by_id(fourth_id)
assert expiration = ActivityExpiration.get_by_activity_id(fourth_id)
@@ -132,9 +129,35 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
NaiveDateTime.to_iso8601(expiration.scheduled_at)
end
- test "posting an undefined status with an attachment", %{conn: conn} do
- user = insert(:user)
+ test "it fails to create a status if `expires_in` is less or equal than an hour", %{
+ conn: conn
+ } do
+ # 1 hour
+ expires_in = 60 * 60
+
+ assert %{"error" => "Expiry date is too soon"} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("api/v1/statuses", %{
+ "status" => "oolong",
+ "expires_in" => expires_in
+ })
+ |> json_response_and_validate_schema(422)
+
+ # 30 minutes
+ expires_in = 30 * 60
+
+ assert %{"error" => "Expiry date is too soon"} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("api/v1/statuses", %{
+ "status" => "oolong",
+ "expires_in" => expires_in
+ })
+ |> json_response_and_validate_schema(422)
+ end
+ test "posting an undefined status with an attachment", %{user: user, conn: conn} do
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"),
@@ -145,23 +168,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"media_ids" => [to_string(upload.id)]
})
- assert json_response(conn, 200)
+ assert json_response_and_validate_schema(conn, 200)
end
- test "replying to a status", %{conn: conn} do
- user = insert(:user)
- {:ok, replied_to} = CommonAPI.post(user, %{"status" => "cofe"})
+ test "replying to a status", %{user: user, conn: conn} do
+ {:ok, replied_to} = CommonAPI.post(user, %{status: "cofe"})
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id})
- assert %{"content" => "xD", "id" => id} = json_response(conn, 200)
+ assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn, 200)
activity = Activity.get_by_id(id)
@@ -169,50 +192,60 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
assert Activity.get_in_reply_to_activity(activity).id == replied_to.id
end
- test "replying to a direct message with visibility other than direct", %{conn: conn} do
- user = insert(:user)
- {:ok, replied_to} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"})
+ test "replying to a direct message with visibility other than direct", %{
+ user: user,
+ conn: conn
+ } do
+ {:ok, replied_to} = CommonAPI.post(user, %{status: "suya..", visibility: "direct"})
Enum.each(["public", "private", "unlisted"], fn visibility ->
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "@#{user.nickname} hey",
"in_reply_to_id" => replied_to.id,
"visibility" => visibility
})
- assert json_response(conn, 422) == %{"error" => "The message visibility must be direct"}
+ assert json_response_and_validate_schema(conn, 422) == %{
+ "error" => "The message visibility must be direct"
+ }
end)
end
test "posting a status with an invalid in_reply_to_id", %{conn: conn} do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => ""})
- assert %{"content" => "xD", "id" => id} = json_response(conn, 200)
+ assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn, 200)
assert Activity.get_by_id(id)
end
test "posting a sensitive status", %{conn: conn} do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "cofe", "sensitive" => true})
- assert %{"content" => "cofe", "id" => id, "sensitive" => true} = json_response(conn, 200)
+ assert %{"content" => "cofe", "id" => id, "sensitive" => true} =
+ json_response_and_validate_schema(conn, 200)
+
assert Activity.get_by_id(id)
end
test "posting a fake status", %{conn: conn} do
real_conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" =>
"\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it"
})
- real_status = json_response(real_conn, 200)
+ real_status = json_response_and_validate_schema(real_conn, 200)
assert real_status
assert Object.get_by_ap_id(real_status["uri"])
@@ -227,13 +260,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
fake_conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" =>
"\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it",
"preview" => true
})
- fake_status = json_response(fake_conn, 200)
+ fake_status = json_response_and_validate_schema(fake_conn, 200)
assert fake_status
refute Object.get_by_ap_id(fake_status["uri"])
@@ -255,11 +289,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "https://example.com/ogp"
})
- assert %{"id" => id, "card" => %{"title" => "The Rock"}} = json_response(conn, 200)
+ assert %{"id" => id, "card" => %{"title" => "The Rock"}} =
+ json_response_and_validate_schema(conn, 200)
+
assert Activity.get_by_id(id)
end
@@ -269,9 +306,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
+ |> put_req_header("content-type", "application/json")
|> post("api/v1/statuses", %{"status" => content, "visibility" => "direct"})
- assert %{"id" => id} = response = json_response(conn, 200)
+ assert %{"id" => id} = response = json_response_and_validate_schema(conn, 200)
assert response["visibility"] == "direct"
assert response["pleroma"]["direct_conversation_id"]
assert activity = Activity.get_by_id(id)
@@ -282,26 +320,48 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
end
describe "posting scheduled statuses" do
+ setup do: oauth_access(["write:statuses"])
+
test "creates a scheduled activity", %{conn: conn} do
- user = insert(:user)
- scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+ scheduled_at =
+ NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+ |> NaiveDateTime.to_iso8601()
+ |> Kernel.<>("Z")
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "scheduled",
"scheduled_at" => scheduled_at
})
- assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200)
+ assert %{"scheduled_at" => expected_scheduled_at} =
+ json_response_and_validate_schema(conn, 200)
+
assert expected_scheduled_at == CommonAPI.Utils.to_masto_date(scheduled_at)
assert [] == Repo.all(Activity)
end
- test "creates a scheduled activity with a media attachment", %{conn: conn} do
- user = insert(:user)
- scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+ test "ignores nil values", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/statuses", %{
+ "status" => "not scheduled",
+ "scheduled_at" => nil
+ })
+
+ assert result = json_response_and_validate_schema(conn, 200)
+ assert Activity.get_by_id(result["id"])
+ end
+
+ test "creates a scheduled activity with a media attachment", %{user: user, conn: conn} do
+ scheduled_at =
+ NaiveDateTime.utc_now()
+ |> NaiveDateTime.add(:timer.minutes(120), :millisecond)
+ |> NaiveDateTime.to_iso8601()
+ |> Kernel.<>("Z")
file = %Plug.Upload{
content_type: "image/jpg",
@@ -313,43 +373,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"media_ids" => [to_string(upload.id)],
"status" => "scheduled",
"scheduled_at" => scheduled_at
})
- assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200)
+ assert %{"media_attachments" => [media_attachment]} =
+ json_response_and_validate_schema(conn, 200)
+
assert %{"type" => "image"} = media_attachment
end
test "skips the scheduling and creates the activity if scheduled_at is earlier than 5 minutes from now",
%{conn: conn} do
- user = insert(:user)
-
scheduled_at =
NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond)
+ |> NaiveDateTime.to_iso8601()
+ |> Kernel.<>("Z")
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "not scheduled",
"scheduled_at" => scheduled_at
})
- assert %{"content" => "not scheduled"} = json_response(conn, 200)
+ assert %{"content" => "not scheduled"} = json_response_and_validate_schema(conn, 200)
assert [] == Repo.all(ScheduledActivity)
end
- test "returns error when daily user limit is exceeded", %{conn: conn} do
- user = insert(:user)
-
+ test "returns error when daily user limit is exceeded", %{user: user, conn: conn} do
today =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(:timer.minutes(6), :millisecond)
|> NaiveDateTime.to_iso8601()
+ # TODO
+ |> Kernel.<>("Z")
attrs = %{params: %{}, scheduled_at: today}
{:ok, _} = ScheduledActivity.create(user, attrs)
@@ -357,24 +419,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today})
- assert %{"error" => "daily limit exceeded"} == json_response(conn, 422)
+ assert %{"error" => "daily limit exceeded"} == json_response_and_validate_schema(conn, 422)
end
- test "returns error when total user limit is exceeded", %{conn: conn} do
- user = insert(:user)
-
+ test "returns error when total user limit is exceeded", %{user: user, conn: conn} do
today =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(:timer.minutes(6), :millisecond)
|> NaiveDateTime.to_iso8601()
+ |> Kernel.<>("Z")
tomorrow =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(:timer.hours(36), :millisecond)
|> NaiveDateTime.to_iso8601()
+ |> Kernel.<>("Z")
attrs = %{params: %{}, scheduled_at: today}
{:ok, _} = ScheduledActivity.create(user, attrs)
@@ -383,27 +445,31 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow})
- assert %{"error" => "total limit exceeded"} == json_response(conn, 422)
+ assert %{"error" => "total limit exceeded"} == json_response_and_validate_schema(conn, 422)
end
end
describe "posting polls" do
+ setup do: oauth_access(["write:statuses"])
+
test "posting a poll", %{conn: conn} do
- user = insert(:user)
time = NaiveDateTime.utc_now()
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "Who is the #bestgrill?",
- "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420}
+ "poll" => %{
+ "options" => ["Rei", "Asuka", "Misato"],
+ "expires_in" => 420
+ }
})
- response = json_response(conn, 200)
+ response = json_response_and_validate_schema(conn, 200)
assert Enum.all?(response["poll"]["options"], fn %{"title" => title} ->
title in ["Rei", "Asuka", "Misato"]
@@ -411,31 +477,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430
refute response["poll"]["expred"]
+
+ question = Object.get_by_id(response["poll"]["id"])
+
+ # closed contains utc timezone
+ assert question.data["closed"] =~ "Z"
end
test "option limit is enforced", %{conn: conn} do
- user = insert(:user)
limit = Config.get([:instance, :poll_limits, :max_options])
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "desu~",
"poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1}
})
- %{"error" => error} = json_response(conn, 422)
+ %{"error" => error} = json_response_and_validate_schema(conn, 422)
assert error == "Poll can't contain more than #{limit} options"
end
test "option character limit is enforced", %{conn: conn} do
- user = insert(:user)
limit = Config.get([:instance, :poll_limits, :max_option_chars])
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "...",
"poll" => %{
@@ -444,17 +513,16 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
}
})
- %{"error" => error} = json_response(conn, 422)
+ %{"error" => error} = json_response_and_validate_schema(conn, 422)
assert error == "Poll options cannot be longer than #{limit} characters each"
end
test "minimal date limit is enforced", %{conn: conn} do
- user = insert(:user)
limit = Config.get([:instance, :poll_limits, :min_expiration])
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "imagine arbitrary limits",
"poll" => %{
@@ -463,17 +531,16 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
}
})
- %{"error" => error} = json_response(conn, 422)
+ %{"error" => error} = json_response_and_validate_schema(conn, 422)
assert error == "Expiration date is too soon"
end
test "maximum date limit is enforced", %{conn: conn} do
- user = insert(:user)
limit = Config.get([:instance, :poll_limits, :max_expiration])
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{
"status" => "imagine arbitrary limits",
"poll" => %{
@@ -482,28 +549,125 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
}
})
- %{"error" => error} = json_response(conn, 422)
+ %{"error" => error} = json_response_and_validate_schema(conn, 422)
assert error == "Expiration date is too far in the future"
end
end
- test "get a status", %{conn: conn} do
+ test "get a status" do
+ %{conn: conn} = oauth_access(["read:statuses"])
activity = insert(:note_activity)
- conn =
- conn
- |> get("/api/v1/statuses/#{activity.id}")
+ conn = get(conn, "/api/v1/statuses/#{activity.id}")
- assert %{"id" => id} = json_response(conn, 200)
+ assert %{"id" => id} = json_response_and_validate_schema(conn, 200)
assert id == to_string(activity.id)
end
- test "get a direct status", %{conn: conn} do
- user = insert(:user)
+ defp local_and_remote_activities do
+ local = insert(:note_activity)
+ remote = insert(:note_activity, local: false)
+ {:ok, local: local, remote: remote}
+ end
+
+ describe "status with restrict unauthenticated activities for local and remote" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :local], true)
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Record not found"
+ }
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Record not found"
+ }
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+ end
+
+ describe "status with restrict unauthenticated activities for local" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :local], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Record not found"
+ }
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+ end
+
+ describe "status with restrict unauthenticated activities for remote" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+
+ assert json_response_and_validate_schema(res_conn, :not_found) == %{
+ "error" => "Record not found"
+ }
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+ res_conn = get(conn, "/api/v1/statuses/#{local.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+
+ res_conn = get(conn, "/api/v1/statuses/#{remote.id}")
+ assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200)
+ end
+ end
+
+ test "getting a status that doesn't exist returns 404" do
+ %{conn: conn} = oauth_access(["read:statuses"])
+ activity = insert(:note_activity)
+
+ conn = get(conn, "/api/v1/statuses/#{String.downcase(activity.id)}")
+
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
+ end
+
+ test "get a direct status" do
+ %{user: user, conn: conn} = oauth_access(["read:statuses"])
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "@#{other_user.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "direct"})
conn =
conn
@@ -512,45 +676,120 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
[participation] = Participation.for_user(user)
- res = json_response(conn, 200)
+ res = json_response_and_validate_schema(conn, 200)
assert res["pleroma"]["direct_conversation_id"] == participation.id
end
- test "get statuses by IDs", %{conn: conn} do
+ test "get statuses by IDs" do
+ %{conn: conn} = oauth_access(["read:statuses"])
%{id: id1} = insert(:note_activity)
%{id: id2} = insert(:note_activity)
query_string = "ids[]=#{id1}&ids[]=#{id2}"
conn = get(conn, "/api/v1/statuses/?#{query_string}")
- assert [%{"id" => ^id1}, %{"id" => ^id2}] = Enum.sort_by(json_response(conn, :ok), & &1["id"])
+ assert [%{"id" => ^id1}, %{"id" => ^id2}] =
+ Enum.sort_by(json_response_and_validate_schema(conn, :ok), & &1["id"])
+ end
+
+ describe "getting statuses by ids with restricted unauthenticated for local and remote" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :local], true)
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ assert json_response_and_validate_schema(res_conn, 200) == []
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
+ end
+
+ describe "getting statuses by ids with restricted unauthenticated for local" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :local], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ remote_id = remote.id
+ assert [%{"id" => ^remote_id}] = json_response_and_validate_schema(res_conn, 200)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
+ end
+
+ describe "getting statuses by ids with restricted unauthenticated for remote" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true)
+
+ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ local_id = local.id
+ assert [%{"id" => ^local_id}] = json_response_and_validate_schema(res_conn, 200)
+ end
+
+ test "if user is authenticated", %{local: local, remote: remote} do
+ %{conn: conn} = oauth_access(["read"])
+
+ res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}")
+
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
end
describe "deleting a status" do
- test "when you created it", %{conn: conn} do
- activity = insert(:note_activity)
- author = User.get_cached_by_ap_id(activity.data["actor"])
+ test "when you created it" do
+ %{user: author, conn: conn} = oauth_access(["write:statuses"])
+ activity = insert(:note_activity, user: author)
conn =
conn
|> assign(:user, author)
|> delete("/api/v1/statuses/#{activity.id}")
- assert %{} = json_response(conn, 200)
+ assert %{} = json_response_and_validate_schema(conn, 200)
refute Activity.get_by_id(activity.id)
end
- test "when you didn't create it", %{conn: conn} do
- activity = insert(:note_activity)
- user = insert(:user)
+ test "when it doesn't exist" do
+ %{user: author, conn: conn} = oauth_access(["write:statuses"])
+ activity = insert(:note_activity, user: author)
conn =
conn
- |> assign(:user, user)
- |> delete("/api/v1/statuses/#{activity.id}")
+ |> assign(:user, author)
+ |> delete("/api/v1/statuses/#{String.downcase(activity.id)}")
+
+ assert %{"error" => "Record not found"} == json_response_and_validate_schema(conn, 404)
+ end
+
+ test "when you didn't create it" do
+ %{conn: conn} = oauth_access(["write:statuses"])
+ activity = insert(:note_activity)
+
+ conn = delete(conn, "/api/v1/statuses/#{activity.id}")
- assert %{"error" => _} = json_response(conn, 403)
+ assert %{"error" => _} = json_response_and_validate_schema(conn, 403)
assert Activity.get_by_id(activity.id) == activity
end
@@ -558,22 +797,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
test "when you're an admin or moderator", %{conn: conn} do
activity1 = insert(:note_activity)
activity2 = insert(:note_activity)
- admin = insert(:user, info: %{is_admin: true})
- moderator = insert(:user, info: %{is_moderator: true})
+ admin = insert(:user, is_admin: true)
+ moderator = insert(:user, is_moderator: true)
res_conn =
conn
|> assign(:user, admin)
+ |> assign(:token, insert(:oauth_token, user: admin, scopes: ["write:statuses"]))
|> delete("/api/v1/statuses/#{activity1.id}")
- assert %{} = json_response(res_conn, 200)
+ assert %{} = json_response_and_validate_schema(res_conn, 200)
res_conn =
conn
|> assign(:user, moderator)
+ |> assign(:token, insert(:oauth_token, user: moderator, scopes: ["write:statuses"]))
|> delete("/api/v1/statuses/#{activity2.id}")
- assert %{} = json_response(res_conn, 200)
+ assert %{} = json_response_and_validate_schema(res_conn, 200)
refute Activity.get_by_id(activity1.id)
refute Activity.get_by_id(activity2.id)
@@ -581,54 +822,69 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
end
describe "reblogging" do
+ setup do: oauth_access(["write:statuses"])
+
test "reblogs and returns the reblogged status", %{conn: conn} do
activity = insert(:note_activity)
- user = insert(:user)
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/reblog")
assert %{
"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1},
"reblogged" => true
- } = json_response(conn, 200)
+ } = json_response_and_validate_schema(conn, 200)
assert to_string(activity.id) == id
end
+ test "returns 404 if the reblogged status doesn't exist", %{conn: conn} do
+ activity = insert(:note_activity)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/statuses/#{String.downcase(activity.id)}/reblog")
+
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404)
+ end
+
test "reblogs privately and returns the reblogged status", %{conn: conn} do
activity = insert(:note_activity)
- user = insert(:user)
conn =
conn
- |> assign(:user, user)
- |> post("/api/v1/statuses/#{activity.id}/reblog", %{"visibility" => "private"})
+ |> put_req_header("content-type", "application/json")
+ |> post(
+ "/api/v1/statuses/#{activity.id}/reblog",
+ %{"visibility" => "private"}
+ )
assert %{
"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1},
"reblogged" => true,
"visibility" => "private"
- } = json_response(conn, 200)
+ } = json_response_and_validate_schema(conn, 200)
assert to_string(activity.id) == id
end
- test "reblogged status for another user", %{conn: conn} do
+ test "reblogged status for another user" do
activity = insert(:note_activity)
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
- CommonAPI.favorite(activity.id, user2)
+ {:ok, _} = CommonAPI.favorite(user2, activity.id)
{:ok, _bookmark} = Pleroma.Bookmark.create(user2.id, activity.id)
{:ok, reblog_activity1, _object} = CommonAPI.repeat(activity.id, user1)
{:ok, _, _object} = CommonAPI.repeat(activity.id, user2)
conn_res =
- conn
+ build_conn()
|> assign(:user, user3)
+ |> assign(:token, insert(:oauth_token, user: user3, scopes: ["read:statuses"]))
|> get("/api/v1/statuses/#{reblog_activity1.id}")
assert %{
@@ -636,11 +892,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
"reblogged" => false,
"favourited" => false,
"bookmarked" => false
- } = json_response(conn_res, 200)
+ } = json_response_and_validate_schema(conn_res, 200)
conn_res =
- conn
+ build_conn()
|> assign(:user, user2)
+ |> assign(:token, insert(:oauth_token, user: user2, scopes: ["read:statuses"]))
|> get("/api/v1/statuses/#{reblog_activity1.id}")
assert %{
@@ -648,187 +905,184 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
"reblogged" => true,
"favourited" => true,
"bookmarked" => true
- } = json_response(conn_res, 200)
+ } = json_response_and_validate_schema(conn_res, 200)
assert to_string(activity.id) == id
end
-
- test "returns 400 error when activity is not exist", %{conn: conn} do
- user = insert(:user)
-
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/statuses/foo/reblog")
-
- assert json_response(conn, 400) == %{"error" => "Could not repeat"}
- end
end
describe "unreblogging" do
- test "unreblogs and returns the unreblogged status", %{conn: conn} do
+ setup do: oauth_access(["write:statuses"])
+
+ test "unreblogs and returns the unreblogged status", %{user: user, conn: conn} do
activity = insert(:note_activity)
- user = insert(:user)
{:ok, _, _} = CommonAPI.repeat(activity.id, user)
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/unreblog")
- assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} = json_response(conn, 200)
+ assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} =
+ json_response_and_validate_schema(conn, 200)
assert to_string(activity.id) == id
end
- test "returns 400 error when activity is not exist", %{conn: conn} do
- user = insert(:user)
-
+ test "returns 404 error when activity does not exist", %{conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/foo/unreblog")
- assert json_response(conn, 400) == %{"error" => "Could not unrepeat"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
describe "favoriting" do
+ setup do: oauth_access(["write:favourites"])
+
test "favs a status and returns it", %{conn: conn} do
activity = insert(:note_activity)
- user = insert(:user)
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/favourite")
assert %{"id" => id, "favourites_count" => 1, "favourited" => true} =
- json_response(conn, 200)
+ json_response_and_validate_schema(conn, 200)
assert to_string(activity.id) == id
end
- test "returns 400 error for a wrong id", %{conn: conn} do
- user = insert(:user)
+ test "favoriting twice will just return 200", %{conn: conn} do
+ activity = insert(:note_activity)
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/statuses/#{activity.id}/favourite")
+
+ assert conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/statuses/#{activity.id}/favourite")
+ |> json_response_and_validate_schema(200)
+ end
+ test "returns 404 error for a wrong id", %{conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/1/favourite")
- assert json_response(conn, 400) == %{"error" => "Could not favorite"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
describe "unfavoriting" do
- test "unfavorites a status and returns it", %{conn: conn} do
+ setup do: oauth_access(["write:favourites"])
+
+ test "unfavorites a status and returns it", %{user: user, conn: conn} do
activity = insert(:note_activity)
- user = insert(:user)
- {:ok, _, _} = CommonAPI.favorite(activity.id, user)
+ {:ok, _} = CommonAPI.favorite(user, activity.id)
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/unfavourite")
assert %{"id" => id, "favourites_count" => 0, "favourited" => false} =
- json_response(conn, 200)
+ json_response_and_validate_schema(conn, 200)
assert to_string(activity.id) == id
end
- test "returns 400 error for a wrong id", %{conn: conn} do
- user = insert(:user)
-
+ test "returns 404 error for a wrong id", %{conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/1/unfavourite")
- assert json_response(conn, 400) == %{"error" => "Could not unfavorite"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
describe "pinned statuses" do
- setup do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ setup do: oauth_access(["write:accounts"])
- [user: user, activity: activity]
- end
+ setup %{user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"})
- clear_config([:instance, :max_pinned_statuses]) do
- Config.put([:instance, :max_pinned_statuses], 1)
+ %{activity: activity}
end
+ setup do: clear_config([:instance, :max_pinned_statuses], 1)
+
test "pin status", %{conn: conn, user: user, activity: activity} do
id_str = to_string(activity.id)
assert %{"id" => ^id_str, "pinned" => true} =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/pin")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert [%{"id" => ^id_str, "pinned" => true}] =
conn
- |> assign(:user, user)
|> get("/api/v1/accounts/#{user.id}/statuses?pinned=true")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do
- {:ok, dm} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"})
+ {:ok, dm} = CommonAPI.post(user, %{status: "test", visibility: "direct"})
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{dm.id}/pin")
- assert json_response(conn, 400) == %{"error" => "Could not pin"}
+ assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not pin"}
end
test "unpin status", %{conn: conn, user: user, activity: activity} do
{:ok, _} = CommonAPI.pin(activity.id, user)
+ user = refresh_record(user)
id_str = to_string(activity.id)
- user = refresh_record(user)
assert %{"id" => ^id_str, "pinned" => false} =
conn
|> assign(:user, user)
|> post("/api/v1/statuses/#{activity.id}/unpin")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert [] =
conn
- |> assign(:user, user)
|> get("/api/v1/accounts/#{user.id}/statuses?pinned=true")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
- test "/unpin: returns 400 error when activity is not exist", %{conn: conn, user: user} do
+ test "/unpin: returns 400 error when activity is not exist", %{conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/1/unpin")
- assert json_response(conn, 400) == %{"error" => "Could not unpin"}
+ assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not unpin"}
end
test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do
- {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"})
+ {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"})
id_str_one = to_string(activity_one.id)
assert %{"id" => ^id_str_one, "pinned" => true} =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{id_str_one}/pin")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
user = refresh_record(user)
@@ -836,7 +1090,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn
|> assign(:user, user)
|> post("/api/v1/statuses/#{activity_two.id}/pin")
- |> json_response(400)
+ |> json_response_and_validate_schema(400)
end
end
@@ -844,14 +1098,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
setup do
Config.put([:rich_media, :enabled], true)
- user = insert(:user)
- %{user: user}
+ oauth_access(["read:statuses"])
end
test "returns rich-media card", %{conn: conn, user: user} do
Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp"})
card_data = %{
"image" => "http://ia.media-imdb.com/images/rock.jpg",
@@ -877,19 +1130,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
response =
conn
|> get("/api/v1/statuses/#{activity.id}/card")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response == card_data
# works with private posts
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "https://example.com/ogp", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "https://example.com/ogp", visibility: "direct"})
response_two =
conn
- |> assign(:user, user)
|> get("/api/v1/statuses/#{activity.id}/card")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert response_two == card_data
end
@@ -897,13 +1149,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
test "replaces missing description with an empty string", %{conn: conn, user: user} do
Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "https://example.com/ogp-missing-data"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp-missing-data"})
response =
conn
|> get("/api/v1/statuses/#{activity.id}/card")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert response == %{
"type" => "link",
@@ -925,74 +1176,66 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
end
test "bookmarks" do
- user = insert(:user)
- for_user = insert(:user)
+ bookmarks_uri = "/api/v1/bookmarks"
- {:ok, activity1} =
- CommonAPI.post(user, %{
- "status" => "heweoo?"
- })
+ %{conn: conn} = oauth_access(["write:bookmarks", "read:bookmarks"])
+ author = insert(:user)
- {:ok, activity2} =
- CommonAPI.post(user, %{
- "status" => "heweoo!"
- })
+ {:ok, activity1} = CommonAPI.post(author, %{status: "heweoo?"})
+ {:ok, activity2} = CommonAPI.post(author, %{status: "heweoo!"})
response1 =
- build_conn()
- |> assign(:user, for_user)
+ conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity1.id}/bookmark")
- assert json_response(response1, 200)["bookmarked"] == true
+ assert json_response_and_validate_schema(response1, 200)["bookmarked"] == true
response2 =
- build_conn()
- |> assign(:user, for_user)
+ conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity2.id}/bookmark")
- assert json_response(response2, 200)["bookmarked"] == true
+ assert json_response_and_validate_schema(response2, 200)["bookmarked"] == true
- bookmarks =
- build_conn()
- |> assign(:user, for_user)
- |> get("/api/v1/bookmarks")
+ bookmarks = get(conn, bookmarks_uri)
- assert [json_response(response2, 200), json_response(response1, 200)] ==
- json_response(bookmarks, 200)
+ assert [
+ json_response_and_validate_schema(response2, 200),
+ json_response_and_validate_schema(response1, 200)
+ ] ==
+ json_response_and_validate_schema(bookmarks, 200)
response1 =
- build_conn()
- |> assign(:user, for_user)
+ conn
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity1.id}/unbookmark")
- assert json_response(response1, 200)["bookmarked"] == false
+ assert json_response_and_validate_schema(response1, 200)["bookmarked"] == false
- bookmarks =
- build_conn()
- |> assign(:user, for_user)
- |> get("/api/v1/bookmarks")
+ bookmarks = get(conn, bookmarks_uri)
- assert [json_response(response2, 200)] == json_response(bookmarks, 200)
+ assert [json_response_and_validate_schema(response2, 200)] ==
+ json_response_and_validate_schema(bookmarks, 200)
end
describe "conversation muting" do
+ setup do: oauth_access(["write:mutes"])
+
setup do
post_user = insert(:user)
- user = insert(:user)
-
- {:ok, activity} = CommonAPI.post(post_user, %{"status" => "HIE"})
-
- [user: user, activity: activity]
+ {:ok, activity} = CommonAPI.post(post_user, %{status: "HIE"})
+ %{activity: activity}
end
- test "mute conversation", %{conn: conn, user: user, activity: activity} do
+ test "mute conversation", %{conn: conn, activity: activity} do
id_str = to_string(activity.id)
assert %{"id" => ^id_str, "muted" => true} =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/mute")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
test "cannot mute already muted conversation", %{conn: conn, user: user, activity: activity} do
@@ -1000,23 +1243,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/mute")
- assert json_response(conn, 400) == %{"error" => "conversation is already muted"}
+ assert json_response_and_validate_schema(conn, 400) == %{
+ "error" => "conversation is already muted"
+ }
end
test "unmute conversation", %{conn: conn, user: user, activity: activity} do
{:ok, _} = CommonAPI.add_mute(user, activity)
id_str = to_string(activity.id)
- user = refresh_record(user)
assert %{"id" => ^id_str, "muted" => false} =
conn
- |> assign(:user, user)
+ # |> assign(:user, user)
|> post("/api/v1/statuses/#{activity.id}/unmute")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
end
end
@@ -1025,15 +1269,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
user2 = insert(:user)
user3 = insert(:user)
- {:ok, replied_to} = CommonAPI.post(user1, %{"status" => "cofe"})
+ {:ok, replied_to} = CommonAPI.post(user1, %{status: "cofe"})
# Reply to status from another user
conn1 =
conn
|> assign(:user, user2)
+ |> assign(:token, insert(:oauth_token, user: user2, scopes: ["write:statuses"]))
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id})
- assert %{"content" => "xD", "id" => id} = json_response(conn1, 200)
+ assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn1, 200)
activity = Activity.get_by_id_with_object(id)
@@ -1044,10 +1290,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn2 =
conn
|> assign(:user, user3)
+ |> assign(:token, insert(:oauth_token, user: user3, scopes: ["write:statuses"]))
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/statuses/#{activity.id}/reblog")
assert %{"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}} =
- json_response(conn2, 200)
+ json_response_and_validate_schema(conn2, 200)
assert to_string(activity.id) == id
@@ -1055,6 +1303,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
conn3 =
conn
|> assign(:user, user3)
+ |> assign(:token, insert(:oauth_token, user: user3, scopes: ["read:statuses"]))
|> get("api/v1/timelines/home")
[reblogged_activity] = json_response(conn3, 200)
@@ -1066,25 +1315,22 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
end
describe "GET /api/v1/statuses/:id/favourited_by" do
- setup do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test"})
+ setup do: oauth_access(["read:accounts"])
- conn =
- build_conn()
- |> assign(:user, user)
+ setup %{user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "test"})
- [conn: conn, activity: activity, user: user]
+ %{activity: activity}
end
test "returns users who have favorited the status", %{conn: conn, activity: activity} do
other_user = insert(:user)
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
response =
conn
|> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
[%{"id" => id}] = response
@@ -1098,7 +1344,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
response =
conn
|> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
@@ -1108,54 +1354,62 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
activity: activity
} do
other_user = insert(:user)
- {:ok, user} = User.block(user, other_user)
+ {:ok, _user_relationship} = User.block(user, other_user)
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
response =
conn
- |> assign(:user, user)
|> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
- test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do
+ test "does not fail on an unauthenticated request", %{activity: activity} do
other_user = insert(:user)
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
response =
- conn
- |> assign(:user, nil)
+ build_conn()
|> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
[%{"id" => id}] = response
assert id == other_user.id
end
- test "requires authentification for private posts", %{conn: conn, user: user} do
+ test "requires authentication for private posts", %{user: user} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "@#{other_user.nickname} wanna get some #cofe together?",
- "visibility" => "direct"
+ status: "@#{other_user.nickname} wanna get some #cofe together?",
+ visibility: "direct"
})
- {:ok, _, _} = CommonAPI.favorite(activity.id, other_user)
+ {:ok, _} = CommonAPI.favorite(other_user, activity.id)
- conn
- |> assign(:user, nil)
- |> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(404)
+ favourited_by_url = "/api/v1/statuses/#{activity.id}/favourited_by"
- response =
+ build_conn()
+ |> get(favourited_by_url)
+ |> json_response_and_validate_schema(404)
+
+ conn =
build_conn()
|> assign(:user, other_user)
- |> get("/api/v1/statuses/#{activity.id}/favourited_by")
- |> json_response(200)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"]))
+
+ conn
+ |> assign(:token, nil)
+ |> get(favourited_by_url)
+ |> json_response_and_validate_schema(404)
+
+ response =
+ conn
+ |> get(favourited_by_url)
+ |> json_response_and_validate_schema(200)
[%{"id" => id}] = response
assert id == other_user.id
@@ -1163,15 +1417,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
end
describe "GET /api/v1/statuses/:id/reblogged_by" do
- setup do
- user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "test"})
+ setup do: oauth_access(["read:accounts"])
- conn =
- build_conn()
- |> assign(:user, user)
+ setup %{user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "test"})
- [conn: conn, activity: activity, user: user]
+ %{activity: activity}
end
test "returns users who have reblogged the status", %{conn: conn, activity: activity} do
@@ -1181,7 +1432,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
response =
conn
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
[%{"id" => id}] = response
@@ -1195,7 +1446,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
response =
conn
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
@@ -1205,69 +1456,66 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
activity: activity
} do
other_user = insert(:user)
- {:ok, user} = User.block(user, other_user)
+ {:ok, _user_relationship} = User.block(user, other_user)
{:ok, _, _} = CommonAPI.repeat(activity.id, other_user)
response =
conn
- |> assign(:user, user)
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
test "does not return users who have reblogged the status privately", %{
- conn: %{assigns: %{user: user}} = conn,
+ conn: conn,
activity: activity
} do
other_user = insert(:user)
- {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{"visibility" => "private"})
+ {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{visibility: "private"})
response =
conn
- |> assign(:user, user)
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
- test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do
+ test "does not fail on an unauthenticated request", %{activity: activity} do
other_user = insert(:user)
{:ok, _, _} = CommonAPI.repeat(activity.id, other_user)
response =
- conn
- |> assign(:user, nil)
+ build_conn()
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
[%{"id" => id}] = response
assert id == other_user.id
end
- test "requires authentification for private posts", %{conn: conn, user: user} do
+ test "requires authentication for private posts", %{user: user} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "@#{other_user.nickname} wanna get some #cofe together?",
- "visibility" => "direct"
+ status: "@#{other_user.nickname} wanna get some #cofe together?",
+ visibility: "direct"
})
- conn
- |> assign(:user, nil)
+ build_conn()
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(404)
+ |> json_response_and_validate_schema(404)
response =
build_conn()
|> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"]))
|> get("/api/v1/statuses/#{activity.id}/reblogged_by")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert [] == response
end
@@ -1276,17 +1524,16 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
test "context" do
user = insert(:user)
- {:ok, %{id: id1}} = CommonAPI.post(user, %{"status" => "1"})
- {:ok, %{id: id2}} = CommonAPI.post(user, %{"status" => "2", "in_reply_to_status_id" => id1})
- {:ok, %{id: id3}} = CommonAPI.post(user, %{"status" => "3", "in_reply_to_status_id" => id2})
- {:ok, %{id: id4}} = CommonAPI.post(user, %{"status" => "4", "in_reply_to_status_id" => id3})
- {:ok, %{id: id5}} = CommonAPI.post(user, %{"status" => "5", "in_reply_to_status_id" => id4})
+ {:ok, %{id: id1}} = CommonAPI.post(user, %{status: "1"})
+ {:ok, %{id: id2}} = CommonAPI.post(user, %{status: "2", in_reply_to_status_id: id1})
+ {:ok, %{id: id3}} = CommonAPI.post(user, %{status: "3", in_reply_to_status_id: id2})
+ {:ok, %{id: id4}} = CommonAPI.post(user, %{status: "4", in_reply_to_status_id: id3})
+ {:ok, %{id: id5}} = CommonAPI.post(user, %{status: "5", in_reply_to_status_id: id4})
response =
build_conn()
- |> assign(:user, nil)
|> get("/api/v1/statuses/#{id3}/context")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert %{
"ancestors" => [%{"id" => ^id1}, %{"id" => ^id2}],
@@ -1294,21 +1541,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
} = response
end
- test "returns the favorites of a user", %{conn: conn} do
- user = insert(:user)
+ test "returns the favorites of a user" do
+ %{user: user, conn: conn} = oauth_access(["read:favourites"])
other_user = insert(:user)
- {:ok, _} = CommonAPI.post(other_user, %{"status" => "bla"})
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "traps are happy"})
+ {:ok, _} = CommonAPI.post(other_user, %{status: "bla"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "traps are happy"})
- {:ok, _, _} = CommonAPI.favorite(activity.id, user)
+ {:ok, _} = CommonAPI.favorite(user, activity.id)
- first_conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/favourites")
+ first_conn = get(conn, "/api/v1/favourites")
- assert [status] = json_response(first_conn, 200)
+ assert [status] = json_response_and_validate_schema(first_conn, 200)
assert status["id"] == to_string(activity.id)
assert [{"link", _link_header}] =
@@ -1317,27 +1561,43 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
# Honours query params
{:ok, second_activity} =
CommonAPI.post(other_user, %{
- "status" =>
- "Trees Are Never Sad Look At Them Every Once In Awhile They're Quite Beautiful."
+ status: "Trees Are Never Sad Look At Them Every Once In Awhile They're Quite Beautiful."
})
- {:ok, _, _} = CommonAPI.favorite(second_activity.id, user)
+ {:ok, _} = CommonAPI.favorite(user, second_activity.id)
last_like = status["id"]
- second_conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/favourites?since_id=#{last_like}")
+ second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like}")
- assert [second_status] = json_response(second_conn, 200)
+ assert [second_status] = json_response_and_validate_schema(second_conn, 200)
assert second_status["id"] == to_string(second_activity.id)
- third_conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/favourites?limit=0")
+ third_conn = get(conn, "/api/v1/favourites?limit=0")
+
+ assert [] = json_response_and_validate_schema(third_conn, 200)
+ end
+
+ test "expires_at is nil for another user" do
+ %{conn: conn, user: user} = oauth_access(["read:statuses"])
+ {:ok, activity} = CommonAPI.post(user, %{status: "foobar", expires_in: 1_000_000})
+
+ expires_at =
+ activity.id
+ |> ActivityExpiration.get_by_activity_id()
+ |> Map.get(:scheduled_at)
+ |> NaiveDateTime.to_iso8601()
+
+ assert %{"pleroma" => %{"expires_at" => ^expires_at}} =
+ conn
+ |> get("/api/v1/statuses/#{activity.id}")
+ |> json_response_and_validate_schema(:ok)
+
+ %{conn: conn} = oauth_access(["read:statuses"])
- assert [] = json_response(third_conn, 200)
+ assert %{"pleroma" => %{"expires_at" => nil}} =
+ conn
+ |> get("/api/v1/statuses/#{activity.id}")
+ |> json_response_and_validate_schema(:ok)
end
end
diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs
index 7dfb02f63..4aa260663 100644
--- a/test/web/mastodon_api/controllers/subscription_controller_test.exs
+++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs
@@ -1,11 +1,12 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
+
alias Pleroma.Web.Push
alias Pleroma.Web.Push.Subscription
@@ -27,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
build_conn()
|> assign(:user, user)
|> assign(:token, token)
+ |> put_req_header("content-type", "application/json")
%{conn: conn, user: user, token: token}
end
@@ -35,7 +37,10 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
quote do
vapid_details = Application.get_env(:web_push_encryption, :vapid_details, [])
Application.put_env(:web_push_encryption, :vapid_details, [])
- assert "Something went wrong" == unquote(yield)
+
+ assert %{"error" => "Web push subscription is disabled on this Pleroma instance"} ==
+ unquote(yield)
+
Application.put_env(:web_push_encryption, :vapid_details, vapid_details)
end
end
@@ -44,8 +49,8 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
test "returns error when push disabled ", %{conn: conn} do
assert_error_when_disable_push do
conn
- |> post("/api/v1/push/subscription", %{})
- |> json_response(500)
+ |> post("/api/v1/push/subscription", %{subscription: @sub})
+ |> json_response_and_validate_schema(403)
end
end
@@ -56,7 +61,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
"data" => %{"alerts" => %{"mention" => true, "test" => true}},
"subscription" => @sub
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
[subscription] = Pleroma.Repo.all(Subscription)
@@ -74,7 +79,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
assert_error_when_disable_push do
conn
|> get("/api/v1/push/subscription", %{})
- |> json_response(500)
+ |> json_response_and_validate_schema(403)
end
end
@@ -82,9 +87,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
res =
conn
|> get("/api/v1/push/subscription", %{})
- |> json_response(404)
+ |> json_response_and_validate_schema(404)
- assert "Not found" == res
+ assert %{"error" => "Record not found"} == res
end
test "returns a user subsciption", %{conn: conn, user: user, token: token} do
@@ -98,7 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
res =
conn
|> get("/api/v1/push/subscription", %{})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
expect = %{
"alerts" => %{"mention" => true},
@@ -127,7 +132,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
assert_error_when_disable_push do
conn
|> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}})
- |> json_response(500)
+ |> json_response_and_validate_schema(403)
end
end
@@ -137,7 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
|> put("/api/v1/push/subscription", %{
data: %{"alerts" => %{"mention" => false, "follow" => true}}
})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
expect = %{
"alerts" => %{"follow" => true, "mention" => false},
@@ -155,7 +160,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
assert_error_when_disable_push do
conn
|> delete("/api/v1/push/subscription", %{})
- |> json_response(500)
+ |> json_response_and_validate_schema(403)
end
end
@@ -163,9 +168,9 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
res =
conn
|> delete("/api/v1/push/subscription", %{})
- |> json_response(404)
+ |> json_response_and_validate_schema(404)
- assert "Not found" == res
+ assert %{"error" => "Record not found"} == res
end
test "returns empty result and delete user subsciption", %{
@@ -183,7 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do
res =
conn
|> delete("/api/v1/push/subscription", %{})
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert %{} == res
refute Pleroma.Repo.get(Subscription, subscription.id)
diff --git a/test/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/web/mastodon_api/controllers/suggestion_controller_test.exs
index 78620a873..7f08e187c 100644
--- a/test/web/mastodon_api/controllers/suggestion_controller_test.exs
+++ b/test/web/mastodon_api/controllers/suggestion_controller_test.exs
@@ -1,92 +1,18 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do
use Pleroma.Web.ConnCase
- alias Pleroma.Config
-
- import ExUnit.CaptureLog
- import Pleroma.Factory
- import Tesla.Mock
-
- setup do
- user = insert(:user)
- other_user = insert(:user)
- host = Config.get([Pleroma.Web.Endpoint, :url, :host])
- url500 = "http://test500?#{host}&#{user.nickname}"
- url200 = "http://test200?#{host}&#{user.nickname}"
-
- mock(fn
- %{method: :get, url: ^url500} ->
- %Tesla.Env{status: 500, body: "bad request"}
-
- %{method: :get, url: ^url200} ->
- %Tesla.Env{
- status: 200,
- body:
- ~s([{"acct":"yj455","avatar":"https://social.heldscal.la/avatar/201.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/201.jpeg"}, {"acct":"#{
- other_user.ap_id
- }","avatar":"https://social.heldscal.la/avatar/202.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/202.jpeg"}])
- }
- end)
-
- [user: user, other_user: other_user]
- end
-
- clear_config(:suggestions)
-
- test "returns empty result when suggestions disabled", %{conn: conn, user: user} do
- Config.put([:suggestions, :enabled], false)
+ setup do: oauth_access(["read"])
+ test "returns empty result", %{conn: conn} do
res =
conn
- |> assign(:user, user)
|> get("/api/v1/suggestions")
- |> json_response(200)
+ |> json_response_and_validate_schema(200)
assert res == []
end
-
- test "returns error", %{conn: conn, user: user} do
- Config.put([:suggestions, :enabled], true)
- Config.put([:suggestions, :third_party_engine], "http://test500?{{host}}&{{user}}")
-
- assert capture_log(fn ->
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/suggestions")
- |> json_response(500)
-
- assert res == "Something went wrong"
- end) =~ "Could not retrieve suggestions"
- end
-
- test "returns suggestions", %{conn: conn, user: user, other_user: other_user} do
- Config.put([:suggestions, :enabled], true)
- Config.put([:suggestions, :third_party_engine], "http://test200?{{host}}&{{user}}")
-
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/suggestions")
- |> json_response(200)
-
- assert res == [
- %{
- "acct" => "yj455",
- "avatar" => "https://social.heldscal.la/avatar/201.jpeg",
- "avatar_static" => "https://social.heldscal.la/avatar/s/201.jpeg",
- "id" => 0
- },
- %{
- "acct" => other_user.ap_id,
- "avatar" => "https://social.heldscal.la/avatar/202.jpeg",
- "avatar_static" => "https://social.heldscal.la/avatar/s/202.jpeg",
- "id" => other_user.id
- }
- ]
- end
end
diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs
index 61b6cea75..2375ac8e8 100644
--- a/test/web/mastodon_api/controllers/timeline_controller_test.exs
+++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
@@ -12,54 +12,44 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
alias Pleroma.User
alias Pleroma.Web.CommonAPI
- clear_config([:instance, :public])
-
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
describe "home" do
- test "the home timeline", %{conn: conn} do
- user = insert(:user)
- following = insert(:user)
+ setup do: oauth_access(["read:statuses"])
- {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
-
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/timelines/home")
-
- assert Enum.empty?(json_response(conn, :ok))
+ test "does NOT embed account/pleroma/relationship in statuses", %{
+ user: user,
+ conn: conn
+ } do
+ other_user = insert(:user)
- {:ok, user} = User.follow(user, following)
+ {:ok, _} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
- conn =
- build_conn()
+ response =
+ conn
|> assign(:user, user)
|> get("/api/v1/timelines/home")
+ |> json_response_and_validate_schema(200)
- assert [%{"content" => "test"}] = json_response(conn, :ok)
+ assert Enum.all?(response, fn n ->
+ get_in(n, ["account", "pleroma", "relationship"]) == %{}
+ end)
end
- test "the home timeline when the direct messages are excluded", %{conn: conn} do
- user = insert(:user)
- {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"})
- {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
+ test "the home timeline when the direct messages are excluded", %{user: user, conn: conn} do
+ {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
+ {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
- {:ok, unlisted_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"})
+ {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
- {:ok, private_activity} =
- CommonAPI.post(user, %{"status" => ".", "visibility" => "private"})
+ {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/timelines/home", %{"exclude_visibilities" => ["direct"]})
+ conn = get(conn, "/api/v1/timelines/home?exclude_visibilities[]=direct")
- assert status_ids = json_response(conn, :ok) |> Enum.map(& &1["id"])
+ assert status_ids = json_response_and_validate_schema(conn, :ok) |> Enum.map(& &1["id"])
assert public_activity.id in status_ids
assert unlisted_activity.id in status_ids
assert private_activity.id in status_ids
@@ -72,46 +62,125 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
test "the public timeline", %{conn: conn} do
following = insert(:user)
- {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"})
+ {:ok, _activity} = CommonAPI.post(following, %{status: "test"})
_activity = insert(:note_activity, local: false)
- conn = get(conn, "/api/v1/timelines/public", %{"local" => "False"})
+ conn = get(conn, "/api/v1/timelines/public?local=False")
- assert length(json_response(conn, :ok)) == 2
+ assert length(json_response_and_validate_schema(conn, :ok)) == 2
- conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "True"})
+ conn = get(build_conn(), "/api/v1/timelines/public?local=True")
- assert [%{"content" => "test"}] = json_response(conn, :ok)
+ assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok)
- conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "1"})
+ conn = get(build_conn(), "/api/v1/timelines/public?local=1")
- assert [%{"content" => "test"}] = json_response(conn, :ok)
+ assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok)
end
- test "the public timeline when public is set to false", %{conn: conn} do
- Config.put([:instance, :public], false)
+ test "the public timeline includes only public statuses for an authenticated user" do
+ %{user: user, conn: conn} = oauth_access(["read:statuses"])
+
+ {:ok, _activity} = CommonAPI.post(user, %{status: "test"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "private"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "unlisted"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "direct"})
- assert %{"error" => "This resource requires authentication."} ==
- conn
- |> get("/api/v1/timelines/public", %{"local" => "False"})
- |> json_response(:forbidden)
+ res_conn = get(conn, "/api/v1/timelines/public")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
end
+ end
- test "the public timeline includes only public statuses for an authenticated user" do
- user = insert(:user)
+ defp local_and_remote_activities do
+ insert(:note_activity)
+ insert(:note_activity, local: false)
+ :ok
+ end
- conn =
- build_conn()
- |> assign(:user, user)
+ describe "public with restrict unauthenticated timeline for local and federated timelines" do
+ setup do: local_and_remote_activities()
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "test"})
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "private"})
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "unlisted"})
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"})
+ setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true)
- res_conn = get(conn, "/api/v1/timelines/public")
- assert length(json_response(res_conn, 200)) == 1
+ setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true)
+
+ test "if user is unauthenticated", %{conn: conn} do
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+
+ assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
+ "error" => "authorization required for timeline view"
+ }
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+
+ assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
+ "error" => "authorization required for timeline view"
+ }
+ end
+
+ test "if user is authenticated" do
+ %{conn: conn} = oauth_access(["read:statuses"])
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
+ end
+
+ describe "public with restrict unauthenticated timeline for local" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true)
+
+ test "if user is unauthenticated", %{conn: conn} do
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+
+ assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
+ "error" => "authorization required for timeline view"
+ }
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
+
+ test "if user is authenticated", %{conn: _conn} do
+ %{conn: conn} = oauth_access(["read:statuses"])
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
+ end
+ end
+
+ describe "public with restrict unauthenticated timeline for remote" do
+ setup do: local_and_remote_activities()
+
+ setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true)
+
+ test "if user is unauthenticated", %{conn: conn} do
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+
+ assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
+ "error" => "authorization required for timeline view"
+ }
+ end
+
+ test "if user is authenticated", %{conn: _conn} do
+ %{conn: conn} = oauth_access(["read:statuses"])
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=true")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 1
+
+ res_conn = get(conn, "/api/v1/timelines/public?local=false")
+ assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
end
@@ -124,23 +193,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
{:ok, direct} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "direct"
})
{:ok, _follower_only} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}!",
- "visibility" => "private"
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "private"
})
- # Only direct should be visible here
- res_conn =
+ conn_user_two =
conn
|> assign(:user, user_two)
- |> get("api/v1/timelines/direct")
+ |> assign(:token, insert(:oauth_token, user: user_two, scopes: ["read:statuses"]))
+
+ # Only direct should be visible here
+ res_conn = get(conn_user_two, "api/v1/timelines/direct")
- [status] = json_response(res_conn, :ok)
+ assert [status] = json_response_and_validate_schema(res_conn, :ok)
assert %{"visibility" => "direct"} = status
assert status["url"] != direct.data["id"]
@@ -149,136 +220,126 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
res_conn =
build_conn()
|> assign(:user, user_one)
+ |> assign(:token, insert(:oauth_token, user: user_one, scopes: ["read:statuses"]))
|> get("api/v1/timelines/direct")
- [status] = json_response(res_conn, :ok)
+ [status] = json_response_and_validate_schema(res_conn, :ok)
assert %{"visibility" => "direct"} = status
# Both should be visible here
- res_conn =
- conn
- |> assign(:user, user_two)
- |> get("api/v1/timelines/home")
+ res_conn = get(conn_user_two, "api/v1/timelines/home")
- [_s1, _s2] = json_response(res_conn, :ok)
+ [_s1, _s2] = json_response_and_validate_schema(res_conn, :ok)
# Test pagination
Enum.each(1..20, fn _ ->
{:ok, _} =
CommonAPI.post(user_one, %{
- "status" => "Hi @#{user_two.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user_two.nickname}!",
+ visibility: "direct"
})
end)
- res_conn =
- conn
- |> assign(:user, user_two)
- |> get("api/v1/timelines/direct")
+ res_conn = get(conn_user_two, "api/v1/timelines/direct")
- statuses = json_response(res_conn, :ok)
+ statuses = json_response_and_validate_schema(res_conn, :ok)
assert length(statuses) == 20
- res_conn =
- conn
- |> assign(:user, user_two)
- |> get("api/v1/timelines/direct", %{max_id: List.last(statuses)["id"]})
+ max_id = List.last(statuses)["id"]
+
+ res_conn = get(conn_user_two, "api/v1/timelines/direct?max_id=#{max_id}")
- [status] = json_response(res_conn, :ok)
+ assert [status] = json_response_and_validate_schema(res_conn, :ok)
assert status["url"] != direct.data["id"]
end
- test "doesn't include DMs from blocked users", %{conn: conn} do
- blocker = insert(:user)
+ test "doesn't include DMs from blocked users" do
+ %{user: blocker, conn: conn} = oauth_access(["read:statuses"])
blocked = insert(:user)
- user = insert(:user)
- {:ok, blocker} = User.block(blocker, blocked)
+ other_user = insert(:user)
+ {:ok, _user_relationship} = User.block(blocker, blocked)
{:ok, _blocked_direct} =
CommonAPI.post(blocked, %{
- "status" => "Hi @#{blocker.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{blocker.nickname}!",
+ visibility: "direct"
})
{:ok, direct} =
- CommonAPI.post(user, %{
- "status" => "Hi @#{blocker.nickname}!",
- "visibility" => "direct"
+ CommonAPI.post(other_user, %{
+ status: "Hi @#{blocker.nickname}!",
+ visibility: "direct"
})
- res_conn =
- conn
- |> assign(:user, user)
- |> get("api/v1/timelines/direct")
+ res_conn = get(conn, "api/v1/timelines/direct")
- [status] = json_response(res_conn, :ok)
+ [status] = json_response_and_validate_schema(res_conn, :ok)
assert status["id"] == direct.id
end
end
describe "list" do
- test "list timeline", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["read:lists"])
+
+ test "list timeline", %{user: user, conn: conn} do
other_user = insert(:user)
- {:ok, _activity_one} = CommonAPI.post(user, %{"status" => "Marisa is cute."})
- {:ok, activity_two} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."})
+ {:ok, _activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."})
+ {:ok, activity_two} = CommonAPI.post(other_user, %{status: "Marisa is cute."})
{:ok, list} = Pleroma.List.create("name", user)
{:ok, list} = Pleroma.List.follow(list, other_user)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/timelines/list/#{list.id}")
+ conn = get(conn, "/api/v1/timelines/list/#{list.id}")
- assert [%{"id" => id}] = json_response(conn, :ok)
+ assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok)
assert id == to_string(activity_two.id)
end
- test "list timeline does not leak non-public statuses for unfollowed users", %{conn: conn} do
- user = insert(:user)
+ test "list timeline does not leak non-public statuses for unfollowed users", %{
+ user: user,
+ conn: conn
+ } do
other_user = insert(:user)
- {:ok, activity_one} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."})
+ {:ok, activity_one} = CommonAPI.post(other_user, %{status: "Marisa is cute."})
{:ok, _activity_two} =
CommonAPI.post(other_user, %{
- "status" => "Marisa is cute.",
- "visibility" => "private"
+ status: "Marisa is cute.",
+ visibility: "private"
})
{:ok, list} = Pleroma.List.create("name", user)
{:ok, list} = Pleroma.List.follow(list, other_user)
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/timelines/list/#{list.id}")
+ conn = get(conn, "/api/v1/timelines/list/#{list.id}")
- assert [%{"id" => id}] = json_response(conn, :ok)
+ assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok)
assert id == to_string(activity_one.id)
end
end
describe "hashtag" do
+ setup do: oauth_access(["n/a"])
+
@tag capture_log: true
test "hashtag timeline", %{conn: conn} do
following = insert(:user)
- {:ok, activity} = CommonAPI.post(following, %{"status" => "test #2hu"})
+ {:ok, activity} = CommonAPI.post(following, %{status: "test #2hu"})
nconn = get(conn, "/api/v1/timelines/tag/2hu")
- assert [%{"id" => id}] = json_response(nconn, :ok)
+ assert [%{"id" => id}] = json_response_and_validate_schema(nconn, :ok)
assert id == to_string(activity.id)
# works for different capitalization too
nconn = get(conn, "/api/v1/timelines/tag/2HU")
- assert [%{"id" => id}] = json_response(nconn, :ok)
+ assert [%{"id" => id}] = json_response_and_validate_schema(nconn, :ok)
assert id == to_string(activity.id)
end
@@ -286,26 +347,25 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do
test "multi-hashtag timeline", %{conn: conn} do
user = insert(:user)
- {:ok, activity_test} = CommonAPI.post(user, %{"status" => "#test"})
- {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"})
- {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"})
+ {:ok, activity_test} = CommonAPI.post(user, %{status: "#test"})
+ {:ok, activity_test1} = CommonAPI.post(user, %{status: "#test #test1"})
+ {:ok, activity_none} = CommonAPI.post(user, %{status: "#test #none"})
- any_test = get(conn, "/api/v1/timelines/tag/test", %{"any" => ["test1"]})
+ any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1")
- [status_none, status_test1, status_test] = json_response(any_test, :ok)
+ [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok)
assert to_string(activity_test.id) == status_test["id"]
assert to_string(activity_test1.id) == status_test1["id"]
assert to_string(activity_none.id) == status_none["id"]
- restricted_test =
- get(conn, "/api/v1/timelines/tag/test", %{"all" => ["test1"], "none" => ["none"]})
+ restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none")
- assert [status_test1] == json_response(restricted_test, :ok)
+ assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok)
- all_test = get(conn, "/api/v1/timelines/tag/test", %{"all" => ["none"]})
+ all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none")
- assert [status_none] == json_response(all_test, :ok)
+ assert [status_none] == json_response_and_validate_schema(all_test, :ok)
end
end
end
diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs
index 42a8779c0..bb4bc4396 100644
--- a/test/web/mastodon_api/mastodon_api_controller_test.exs
+++ b/test/web/mastodon_api/mastodon_api_controller_test.exs
@@ -1,105 +1,34 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
use Pleroma.Web.ConnCase
- alias Pleroma.Notification
- alias Pleroma.Repo
- alias Pleroma.Web.CommonAPI
+ describe "empty_array/2 (stubs)" do
+ test "GET /api/v1/accounts/:id/identity_proofs" do
+ %{user: user, conn: conn} = oauth_access(["read:accounts"])
- import Pleroma.Factory
- import Tesla.Mock
-
- setup do
- mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
- :ok
- end
-
- clear_config([:rich_media, :enabled])
-
- test "unimplemented follow_requests, blocks, domain blocks" do
- user = insert(:user)
-
- ["blocks", "domain_blocks", "follow_requests"]
- |> Enum.each(fn endpoint ->
- conn =
- build_conn()
- |> assign(:user, user)
- |> get("/api/v1/#{endpoint}")
-
- assert [] = json_response(conn, 200)
- end)
- end
-
- describe "link headers" do
- test "preserves parameters in link headers", %{conn: conn} do
- user = insert(:user)
- other_user = insert(:user)
-
- {:ok, activity1} =
- CommonAPI.post(other_user, %{
- "status" => "hi @#{user.nickname}",
- "visibility" => "public"
- })
-
- {:ok, activity2} =
- CommonAPI.post(other_user, %{
- "status" => "hi @#{user.nickname}",
- "visibility" => "public"
- })
-
- notification1 = Repo.get_by(Notification, activity_id: activity1.id)
- notification2 = Repo.get_by(Notification, activity_id: activity2.id)
-
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/notifications", %{media_only: true})
-
- assert [link_header] = get_resp_header(conn, "link")
- assert link_header =~ ~r/media_only=true/
- assert link_header =~ ~r/min_id=#{notification2.id}/
- assert link_header =~ ~r/max_id=#{notification1.id}/
- end
- end
-
- describe "empty_array, stubs for mastodon api" do
- test "GET /api/v1/accounts/:id/identity_proofs", %{conn: conn} do
- user = insert(:user)
-
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/accounts/#{user.id}/identity_proofs")
- |> json_response(200)
-
- assert res == []
+ assert [] ==
+ conn
+ |> get("/api/v1/accounts/#{user.id}/identity_proofs")
+ |> json_response(200)
end
- test "GET /api/v1/endorsements", %{conn: conn} do
- user = insert(:user)
-
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/endorsements")
- |> json_response(200)
+ test "GET /api/v1/endorsements" do
+ %{conn: conn} = oauth_access(["read:accounts"])
- assert res == []
+ assert [] ==
+ conn
+ |> get("/api/v1/endorsements")
+ |> json_response(200)
end
test "GET /api/v1/trends", %{conn: conn} do
- user = insert(:user)
-
- res =
- conn
- |> assign(:user, user)
- |> get("/api/v1/trends")
- |> json_response(200)
-
- assert res == []
+ assert [] ==
+ conn
+ |> get("/api/v1/trends")
+ |> json_response(200)
end
end
end
diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs
index 7fcb2bd55..a7f9c5205 100644
--- a/test/web/mastodon_api/mastodon_api_test.exs
+++ b/test/web/mastodon_api/mastodon_api_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPITest do
@@ -14,11 +14,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPITest do
import Pleroma.Factory
describe "follow/3" do
- test "returns error when user deactivated" do
+ test "returns error when followed user is deactivated" do
follower = insert(:user)
- user = insert(:user, local: true, info: %{deactivated: true})
+ user = insert(:user, local: true, deactivated: true)
{:error, error} = MastodonAPI.follow(follower, user)
- assert error == "Could not follow user: You are deactivated."
+ assert error == "Could not follow user: #{user.nickname} is deactivated."
end
test "following for user" do
@@ -75,9 +75,9 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPITest do
User.subscribe(subscriber, user)
- {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"})
+ {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"})
- {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi"})
+ {:ok, status1} = CommonAPI.post(user, %{status: "Magi"})
{:ok, [notification]} = Notification.create_notifications(status)
{:ok, [notification1]} = Notification.create_notifications(status1)
res = MastodonAPI.get_notifications(subscriber)
diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs
index ad209b4a3..487ec26c2 100644
--- a/test/web/mastodon_api/views/account_view_test.exs
+++ b/test/web/mastodon_api/views/account_view_test.exs
@@ -1,41 +1,39 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
use Pleroma.DataCase
- import Pleroma.Factory
+
alias Pleroma.User
+ alias Pleroma.UserRelationship
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
- test "Represent a user account" do
- source_data = %{
- "tag" => [
- %{
- "type" => "Emoji",
- "icon" => %{"url" => "/file.png"},
- "name" => ":karjalanpiirakka:"
- }
- ]
- }
+ import Pleroma.Factory
+ import Tesla.Mock
+
+ setup do
+ mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+ test "Represent a user account" do
background_image = %{
"url" => [%{"href" => "https://example.com/images/asuka_hospital.png"}]
}
user =
insert(:user, %{
- info: %{
- note_count: 5,
- follower_count: 3,
- source_data: source_data,
- background: background_image
- },
+ follower_count: 3,
+ note_count: 5,
+ background: background_image,
nickname: "shp@shitposter.club",
name: ":karjalanpiirakka: shp",
- bio: "<script src=\"invalid-html\"></script><span>valid html</span>",
- inserted_at: ~N[2017-08-15 15:47:06.597036]
+ bio:
+ "<script src=\"invalid-html\"></script><span>valid html</span>. a<br>b<br/>c<br >d<br />f '&<>\"",
+ inserted_at: ~N[2017-08-15 15:47:06.597036],
+ emoji: %{"karjalanpiirakka" => "/file.png"}
})
expected = %{
@@ -48,7 +46,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
followers_count: 3,
following_count: 0,
statuses_count: 5,
- note: "<span>valid html</span>",
+ note: "<span>valid html</span>. a<br/>b<br/>c<br/>d<br/>f &#39;&amp;&lt;&gt;&quot;",
url: user.ap_id,
avatar: "http://localhost:4001/images/avi.png",
avatar_static: "http://localhost:4001/images/avi.png",
@@ -65,9 +63,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
fields: [],
bot: false,
source: %{
- note: "valid html",
+ note: "valid html. a\nb\nc\nd\nf '&<>\"",
sensitive: false,
pleroma: %{
+ actor_type: "Person",
discoverable: false
},
fields: []
@@ -95,16 +94,17 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
user = insert(:user)
notification_settings = %{
- "followers" => true,
- "follows" => true,
- "non_follows" => true,
- "non_followers" => true
+ followers: true,
+ follows: true,
+ non_followers: true,
+ non_follows: true,
+ privacy_option: false
}
- privacy = user.info.default_scope
+ privacy = user.default_scope
assert %{
- pleroma: %{notification_settings: ^notification_settings},
+ pleroma: %{notification_settings: ^notification_settings, allow_following_move: true},
source: %{privacy: ^privacy}
} = AccountView.render("show.json", %{user: user, for: user})
end
@@ -112,7 +112,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
test "Represent a Service(bot) account" do
user =
insert(:user, %{
- info: %{note_count: 5, follower_count: 3, source_data: %{"type" => "Service"}},
+ follower_count: 3,
+ note_count: 5,
+ actor_type: "Service",
nickname: "shp@shitposter.club",
inserted_at: ~N[2017-08-15 15:47:06.597036]
})
@@ -140,6 +142,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
note: user.bio,
sensitive: false,
pleroma: %{
+ actor_type: "Service",
discoverable: false
},
fields: []
@@ -163,9 +166,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
assert expected == AccountView.render("show.json", %{user: user})
end
+ test "Represent a Funkwhale channel" do
+ {:ok, user} =
+ User.get_or_fetch_by_ap_id(
+ "https://channels.tests.funkwhale.audio/federation/actors/compositions"
+ )
+
+ assert represented = AccountView.render("show.json", %{user: user})
+ assert represented.acct == "compositions@channels.tests.funkwhale.audio"
+ assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions"
+ end
+
test "Represent a deactivated user for an admin" do
- admin = insert(:user, %{info: %{is_admin: true}})
- deactivated_user = insert(:user, %{info: %{deactivated: true}})
+ admin = insert(:user, is_admin: true)
+ deactivated_user = insert(:user, deactivated: true)
represented = AccountView.render("show.json", %{user: deactivated_user, for: admin})
assert represented[:pleroma][:deactivated] == true
end
@@ -184,33 +198,57 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
describe "relationship" do
+ defp test_relationship_rendering(user, other_user, expected_result) do
+ opts = %{user: user, target: other_user, relationships: nil}
+ assert expected_result == AccountView.render("relationship.json", opts)
+
+ relationships_opt = UserRelationship.view_relationships_option(user, [other_user])
+ opts = Map.put(opts, :relationships, relationships_opt)
+ assert expected_result == AccountView.render("relationship.json", opts)
+
+ assert [expected_result] ==
+ AccountView.render("relationships.json", %{user: user, targets: [other_user]})
+ end
+
+ @blank_response %{
+ following: false,
+ followed_by: false,
+ blocking: false,
+ blocked_by: false,
+ muting: false,
+ muting_notifications: false,
+ subscribing: false,
+ requested: false,
+ domain_blocking: false,
+ showing_reblogs: true,
+ endorsed: false
+ }
+
test "represent a relationship for the following and followed user" do
user = insert(:user)
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
{:ok, other_user} = User.follow(other_user, user)
- {:ok, other_user} = User.subscribe(user, other_user)
- {:ok, user} = User.mute(user, other_user, true)
- {:ok, user} = CommonAPI.hide_reblogs(user, other_user)
-
- expected = %{
- id: to_string(other_user.id),
- following: true,
- followed_by: true,
- blocking: false,
- blocked_by: false,
- muting: true,
- muting_notifications: true,
- subscribing: true,
- requested: false,
- domain_blocking: false,
- showing_reblogs: false,
- endorsed: false
- }
-
- assert expected ==
- AccountView.render("relationship.json", %{user: user, target: other_user})
+ {:ok, _subscription} = User.subscribe(user, other_user)
+ {:ok, _user_relationships} = User.mute(user, other_user, true)
+ {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, other_user)
+
+ expected =
+ Map.merge(
+ @blank_response,
+ %{
+ following: true,
+ followed_by: true,
+ muting: true,
+ muting_notifications: true,
+ subscribing: true,
+ showing_reblogs: false,
+ id: to_string(other_user.id)
+ }
+ )
+
+ test_relationship_rendering(user, other_user, expected)
end
test "represent a relationship for the blocking and blocked user" do
@@ -218,27 +256,17 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
- {:ok, other_user} = User.subscribe(user, other_user)
- {:ok, user} = User.block(user, other_user)
- {:ok, other_user} = User.block(other_user, user)
-
- expected = %{
- id: to_string(other_user.id),
- following: false,
- followed_by: false,
- blocking: true,
- blocked_by: true,
- muting: false,
- muting_notifications: false,
- subscribing: false,
- requested: false,
- domain_blocking: false,
- showing_reblogs: true,
- endorsed: false
- }
+ {:ok, _subscription} = User.subscribe(user, other_user)
+ {:ok, _user_relationship} = User.block(user, other_user)
+ {:ok, _user_relationship} = User.block(other_user, user)
- assert expected ==
- AccountView.render("relationship.json", %{user: user, target: other_user})
+ expected =
+ Map.merge(
+ @blank_response,
+ %{following: false, blocking: true, blocked_by: true, id: to_string(other_user.id)}
+ )
+
+ test_relationship_rendering(user, other_user, expected)
end
test "represent a relationship for the user blocking a domain" do
@@ -247,112 +275,35 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
{:ok, user} = User.block_domain(user, "bad.site")
- assert %{domain_blocking: true, blocking: false} =
- AccountView.render("relationship.json", %{user: user, target: other_user})
+ expected =
+ Map.merge(
+ @blank_response,
+ %{domain_blocking: true, blocking: false, id: to_string(other_user.id)}
+ )
+
+ test_relationship_rendering(user, other_user, expected)
end
test "represent a relationship for the user with a pending follow request" do
user = insert(:user)
- other_user = insert(:user, %{info: %User.Info{locked: true}})
+ other_user = insert(:user, locked: true)
{:ok, user, other_user, _} = CommonAPI.follow(user, other_user)
user = User.get_cached_by_id(user.id)
other_user = User.get_cached_by_id(other_user.id)
- expected = %{
- id: to_string(other_user.id),
- following: false,
- followed_by: false,
- blocking: false,
- blocked_by: false,
- muting: false,
- muting_notifications: false,
- subscribing: false,
- requested: true,
- domain_blocking: false,
- showing_reblogs: true,
- endorsed: false
- }
+ expected =
+ Map.merge(
+ @blank_response,
+ %{requested: true, following: false, id: to_string(other_user.id)}
+ )
- assert expected ==
- AccountView.render("relationship.json", %{user: user, target: other_user})
+ test_relationship_rendering(user, other_user, expected)
end
end
- test "represent an embedded relationship" do
- user =
- insert(:user, %{
- info: %{note_count: 5, follower_count: 0, source_data: %{"type" => "Service"}},
- nickname: "shp@shitposter.club",
- inserted_at: ~N[2017-08-15 15:47:06.597036]
- })
-
- other_user = insert(:user)
- {:ok, other_user} = User.follow(other_user, user)
- {:ok, other_user} = User.block(other_user, user)
- {:ok, _} = User.follow(insert(:user), user)
-
- expected = %{
- id: to_string(user.id),
- username: "shp",
- acct: user.nickname,
- display_name: user.name,
- locked: false,
- created_at: "2017-08-15T15:47:06.000Z",
- followers_count: 1,
- following_count: 0,
- statuses_count: 5,
- note: user.bio,
- url: user.ap_id,
- avatar: "http://localhost:4001/images/avi.png",
- avatar_static: "http://localhost:4001/images/avi.png",
- header: "http://localhost:4001/images/banner.png",
- header_static: "http://localhost:4001/images/banner.png",
- emojis: [],
- fields: [],
- bot: true,
- source: %{
- note: user.bio,
- sensitive: false,
- pleroma: %{
- discoverable: false
- },
- fields: []
- },
- pleroma: %{
- background_image: nil,
- confirmation_pending: false,
- tags: [],
- is_admin: false,
- is_moderator: false,
- hide_favorites: true,
- hide_followers: false,
- hide_follows: false,
- hide_followers_count: false,
- hide_follows_count: false,
- relationship: %{
- id: to_string(user.id),
- following: false,
- followed_by: false,
- blocking: true,
- blocked_by: false,
- subscribing: false,
- muting: false,
- muting_notifications: false,
- requested: false,
- domain_blocking: false,
- showing_reblogs: true,
- endorsed: false
- },
- skip_thread_containment: false
- }
- }
-
- assert expected == AccountView.render("show.json", %{user: user, for: other_user})
- end
-
test "returns the settings store if the requesting user is the represented user and it's requested specifically" do
- user = insert(:user, %{info: %User.Info{pleroma_settings_store: %{fe: "test"}}})
+ user = insert(:user, pleroma_settings_store: %{fe: "test"})
result =
AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true})
@@ -366,22 +317,29 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
assert result.pleroma[:settings_store] == nil
end
- test "sanitizes display names" do
+ test "doesn't sanitize display names" do
user = insert(:user, name: "<marquee> username </marquee>")
result = AccountView.render("show.json", %{user: user})
- refute result.display_name == "<marquee> username </marquee>"
+ assert result.display_name == "<marquee> username </marquee>"
+ end
+
+ test "never display nil user follow counts" do
+ user = insert(:user, following_count: 0, follower_count: 0)
+ result = AccountView.render("show.json", %{user: user})
+
+ assert result.following_count == 0
+ assert result.followers_count == 0
end
describe "hiding follows/following" do
test "shows when follows/followers stats are hidden and sets follow/follower count to 0" do
- info = %{
- hide_followers: true,
- hide_followers_count: true,
- hide_follows: true,
- hide_follows_count: true
- }
-
- user = insert(:user, info: info)
+ user =
+ insert(:user, %{
+ hide_followers: true,
+ hide_followers_count: true,
+ hide_follows: true,
+ hide_follows_count: true
+ })
other_user = insert(:user)
{:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user)
@@ -395,7 +353,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "shows when follows/followers are hidden" do
- user = insert(:user, info: %{hide_followers: true, hide_follows: true})
+ user = insert(:user, hide_followers: true, hide_follows: true)
other_user = insert(:user)
{:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
@@ -408,7 +366,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "shows actual follower/following count to the account owner" do
- user = insert(:user, info: %{hide_followers: true, hide_follows: true})
+ user = insert(:user, hide_followers: true, hide_follows: true)
other_user = insert(:user)
{:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
@@ -425,8 +383,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
{:ok, _activity} =
CommonAPI.post(other_user, %{
- "status" => "Hey @#{user.nickname}.",
- "visibility" => "direct"
+ status: "Hey @#{user.nickname}.",
+ visibility: "direct"
})
user = User.get_cached_by_ap_id(user.ap_id)
@@ -439,6 +397,24 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
:unread_conversation_count
] == 1
end
+
+ test "shows unread_count only to the account owner" do
+ user = insert(:user)
+ insert_list(7, :notification, user: user)
+ other_user = insert(:user)
+
+ user = User.get_cached_by_ap_id(user.ap_id)
+
+ assert AccountView.render(
+ "show.json",
+ %{user: user, for: other_user}
+ )[:pleroma][:unread_notifications_count] == nil
+
+ assert AccountView.render(
+ "show.json",
+ %{user: user, for: user}
+ )[:pleroma][:unread_notifications_count] == 7
+ end
end
describe "follow requests counter" do
@@ -456,7 +432,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "shows non-zero when follow requests are pending" do
- user = insert(:user, %{info: %{locked: true}})
+ user = insert(:user, locked: true)
assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user})
@@ -468,7 +444,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "decreases when accepting a follow request" do
- user = insert(:user, %{info: %{locked: true}})
+ user = insert(:user, locked: true)
assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user})
@@ -485,7 +461,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "decreases when rejecting a follow request" do
- user = insert(:user, %{info: %{locked: true}})
+ user = insert(:user, locked: true)
assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user})
@@ -502,14 +478,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
end
test "shows non-zero when historical unapproved requests are present" do
- user = insert(:user, %{info: %{locked: true}})
+ user = insert(:user, locked: true)
assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user})
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
- {:ok, user} = User.update_info(user, &User.Info.user_upgrade(&1, %{locked: false}))
+ {:ok, user} = User.update_and_set_cache(user, %{locked: false})
assert %{locked: false, follow_requests_count: 1} =
AccountView.render("show.json", %{user: user, for: user})
diff --git a/test/web/mastodon_api/views/conversation_view_test.exs b/test/web/mastodon_api/views/conversation_view_test.exs
index a2a880705..6f84366f8 100644
--- a/test/web/mastodon_api/views/conversation_view_test.exs
+++ b/test/web/mastodon_api/views/conversation_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ConversationViewTest do
@@ -16,7 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationViewTest do
other_user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "hey @#{other_user.nickname}", visibility: "direct"})
[participation] = Participation.for_user_with_last_activity_id(user)
@@ -30,5 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.ConversationViewTest do
assert [account] = conversation.accounts
assert account.id == other_user.id
+ assert conversation.last_status.pleroma.direct_conversation_id == participation.id
end
end
diff --git a/test/web/mastodon_api/views/list_view_test.exs b/test/web/mastodon_api/views/list_view_test.exs
index 59e896a7c..ca99242cb 100644
--- a/test/web/mastodon_api/views/list_view_test.exs
+++ b/test/web/mastodon_api/views/list_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ListViewTest do
diff --git a/test/web/mastodon_api/views/marker_view_test.exs b/test/web/mastodon_api/views/marker_view_test.exs
index 8a5c89d56..48a0a6d33 100644
--- a/test/web/mastodon_api/views/marker_view_test.exs
+++ b/test/web/mastodon_api/views/marker_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
@@ -8,19 +8,21 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
import Pleroma.Factory
test "returns markers" do
- marker1 = insert(:marker, timeline: "notifications", last_read_id: "17")
+ marker1 = insert(:marker, timeline: "notifications", last_read_id: "17", unread_count: 5)
marker2 = insert(:marker, timeline: "home", last_read_id: "42")
assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{
"home" => %{
last_read_id: "42",
updated_at: NaiveDateTime.to_iso8601(marker2.updated_at),
- version: 0
+ version: 0,
+ pleroma: %{unread_count: 0}
},
"notifications" => %{
last_read_id: "17",
updated_at: NaiveDateTime.to_iso8601(marker1.updated_at),
- version: 0
+ version: 0,
+ pleroma: %{unread_count: 5}
}
}
end
diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs
index c9043a69a..9839e48fc 100644
--- a/test/web/mastodon_api/views/notification_view_test.exs
+++ b/test/web/mastodon_api/views/notification_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
@@ -16,10 +16,25 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
alias Pleroma.Web.MastodonAPI.StatusView
import Pleroma.Factory
+ defp test_notifications_rendering(notifications, user, expected_result) do
+ result = NotificationView.render("index.json", %{notifications: notifications, for: user})
+
+ assert expected_result == result
+
+ result =
+ NotificationView.render("index.json", %{
+ notifications: notifications,
+ for: user,
+ relationships: nil
+ })
+
+ assert expected_result == result
+ end
+
test "Mention notification" do
user = insert(:user)
mentioned_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{mentioned_user.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{mentioned_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
user = User.get_cached_by_id(user.id)
@@ -27,22 +42,23 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
id: to_string(notification.id),
pleroma: %{is_seen: false},
type: "mention",
- account: AccountView.render("show.json", %{user: user, for: mentioned_user}),
+ account:
+ AccountView.render("show.json", %{
+ user: user,
+ for: mentioned_user
+ }),
status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
- result =
- NotificationView.render("index.json", %{notifications: [notification], for: mentioned_user})
-
- assert [expected] == result
+ test_notifications_rendering([notification], mentioned_user, [expected])
end
test "Favourite notification" do
user = insert(:user)
another_user = insert(:user)
- {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"})
- {:ok, favorite_activity, _object} = CommonAPI.favorite(create_activity.id, another_user)
+ {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
+ {:ok, favorite_activity} = CommonAPI.favorite(another_user, create_activity.id)
{:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id)
@@ -55,15 +71,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
created_at: Utils.to_masto_date(notification.inserted_at)
}
- result = NotificationView.render("index.json", %{notifications: [notification], for: user})
-
- assert [expected] == result
+ test_notifications_rendering([notification], user, [expected])
end
test "Reblog notification" do
user = insert(:user)
another_user = insert(:user)
- {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"})
+ {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, reblog_activity, _object} = CommonAPI.repeat(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(reblog_activity)
reblog_activity = Activity.get_by_id(create_activity.id)
@@ -77,9 +91,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
created_at: Utils.to_masto_date(notification.inserted_at)
}
- result = NotificationView.render("index.json", %{notifications: [notification], for: user})
-
- assert [expected] == result
+ test_notifications_rendering([notification], user, [expected])
end
test "Follow notification" do
@@ -96,15 +108,76 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
created_at: Utils.to_masto_date(notification.inserted_at)
}
- result =
- NotificationView.render("index.json", %{notifications: [notification], for: followed})
-
- assert [expected] == result
+ test_notifications_rendering([notification], followed, [expected])
User.perform(:delete, follower)
notification = Notification |> Repo.one() |> Repo.preload(:activity)
- assert [] ==
- NotificationView.render("index.json", %{notifications: [notification], for: followed})
+ test_notifications_rendering([notification], followed, [])
+ end
+
+ @tag capture_log: true
+ test "Move notification" do
+ old_user = insert(:user)
+ new_user = insert(:user, also_known_as: [old_user.ap_id])
+ follower = insert(:user)
+
+ old_user_url = old_user.ap_id
+
+ body =
+ File.read!("test/fixtures/users_mock/localhost.json")
+ |> String.replace("{{nickname}}", old_user.nickname)
+ |> Jason.encode!()
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^old_user_url} ->
+ %Tesla.Env{status: 200, body: body}
+ end)
+
+ User.follow(follower, old_user)
+ Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
+ Pleroma.Tests.ObanHelpers.perform_all()
+
+ old_user = refresh_record(old_user)
+ new_user = refresh_record(new_user)
+
+ [notification] = Notification.for_user(follower)
+
+ expected = %{
+ id: to_string(notification.id),
+ pleroma: %{is_seen: false},
+ type: "move",
+ account: AccountView.render("show.json", %{user: old_user, for: follower}),
+ target: AccountView.render("show.json", %{user: new_user, for: follower}),
+ created_at: Utils.to_masto_date(notification.inserted_at)
+ }
+
+ test_notifications_rendering([notification], follower, [expected])
+ end
+
+ test "EmojiReact notification" do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+ {:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+ activity = Repo.get(Activity, activity.id)
+
+ [notification] = Notification.for_user(user)
+
+ assert notification
+
+ expected = %{
+ id: to_string(notification.id),
+ pleroma: %{is_seen: false},
+ type: "pleroma:emoji_reaction",
+ emoji: "☕",
+ account: AccountView.render("show.json", %{user: other_user, for: user}),
+ status: StatusView.render("show.json", %{activity: activity, for: user}),
+ created_at: Utils.to_masto_date(notification.inserted_at)
+ }
+
+ test_notifications_rendering([notification], user, [expected])
end
end
diff --git a/test/web/mastodon_api/views/poll_view_test.exs b/test/web/mastodon_api/views/poll_view_test.exs
index 8cd7636a5..76672f36c 100644
--- a/test/web/mastodon_api/views/poll_view_test.exs
+++ b/test/web/mastodon_api/views/poll_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.PollViewTest do
@@ -22,10 +22,10 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Is Tenshi eating a corndog cute?",
- "poll" => %{
- "options" => ["absolutely!", "sure", "yes", "why are you even asking?"],
- "expires_in" => 20
+ status: "Is Tenshi eating a corndog cute?",
+ poll: %{
+ options: ["absolutely!", "sure", "yes", "why are you even asking?"],
+ expires_in: 20
}
})
@@ -43,7 +43,8 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
%{title: "why are you even asking?", votes_count: 0}
],
voted: false,
- votes_count: 0
+ votes_count: 0,
+ voters_count: nil
}
result = PollView.render("show.json", %{object: object})
@@ -61,17 +62,28 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Which Mastodon developer is your favourite?",
- "poll" => %{
- "options" => ["Gargron", "Eugen"],
- "expires_in" => 20,
- "multiple" => true
+ status: "Which Mastodon developer is your favourite?",
+ poll: %{
+ options: ["Gargron", "Eugen"],
+ expires_in: 20,
+ multiple: true
}
})
+ voter = insert(:user)
+
object = Object.normalize(activity)
- assert %{multiple: true} = PollView.render("show.json", %{object: object})
+ {:ok, _votes, object} = CommonAPI.vote(voter, object, [0, 1])
+
+ assert match?(
+ %{
+ multiple: true,
+ voters_count: 1,
+ votes_count: 2
+ },
+ PollView.render("show.json", %{object: object})
+ )
end
test "detects emoji" do
@@ -79,10 +91,10 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "What's with the smug face?",
- "poll" => %{
- "options" => [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"],
- "expires_in" => 20
+ status: "What's with the smug face?",
+ poll: %{
+ options: [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"],
+ expires_in: 20
}
})
@@ -97,11 +109,11 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "Which input devices do you use?",
- "poll" => %{
- "options" => ["mouse", "trackball", "trackpoint"],
- "multiple" => true,
- "expires_in" => 20
+ status: "Which input devices do you use?",
+ poll: %{
+ options: ["mouse", "trackball", "trackpoint"],
+ multiple: true,
+ expires_in: 20
}
})
diff --git a/test/web/mastodon_api/views/scheduled_activity_view_test.exs b/test/web/mastodon_api/views/scheduled_activity_view_test.exs
index 6387e4555..fbfd873ef 100644
--- a/test/web/mastodon_api/views/scheduled_activity_view_test.exs
+++ b/test/web/mastodon_api/views/scheduled_activity_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do
@@ -14,7 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do
test "A scheduled activity with a media attachment" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hi"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hi"})
scheduled_at =
NaiveDateTime.utc_now()
@@ -47,7 +47,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do
expected = %{
id: to_string(scheduled_activity.id),
media_attachments:
- %{"media_ids" => [upload.id]}
+ %{media_ids: [upload.id]}
|> Utils.attachments_from_ids()
|> Enum.map(&StatusView.render("attachment.json", %{attachment: &1})),
params: %{
diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs
index c200ad8fe..5d7adbe29 100644
--- a/test/web/mastodon_api/views/status_view_test.exs
+++ b/test/web/mastodon_api/views/status_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
@@ -7,25 +7,60 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
alias Pleroma.Activity
alias Pleroma.Bookmark
+ alias Pleroma.Conversation.Participation
+ alias Pleroma.HTML
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
+ alias Pleroma.UserRelationship
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.StatusView
+
import Pleroma.Factory
import Tesla.Mock
+ import OpenApiSpex.TestAssertions
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
- test "returns the direct conversation id when given the `with_conversation_id` option" do
+ test "has an emoji reaction list" do
+ user = insert(:user)
+ other_user = insert(:user)
+ third_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"})
+
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "☕")
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵")
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+ activity = Repo.get(Activity, activity.id)
+ status = StatusView.render("show.json", activity: activity)
+
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
+
+ assert status[:pleroma][:emoji_reactions] == [
+ %{name: "☕", count: 2, me: false},
+ %{name: "🍵", count: 1, me: false}
+ ]
+
+ status = StatusView.render("show.json", activity: activity, for: user)
+
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
+
+ assert status[:pleroma][:emoji_reactions] == [
+ %{name: "☕", count: 2, me: true},
+ %{name: "🍵", count: 1, me: false}
+ ]
+ end
+
+ test "loads and returns the direct conversation id when given the `with_direct_conversation_id` option" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
+ [participation] = Participation.for_user(user)
status =
StatusView.render("show.json",
@@ -34,17 +69,55 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
for: user
)
- assert status[:pleroma][:direct_conversation_id]
+ assert status[:pleroma][:direct_conversation_id] == participation.id
+
+ status = StatusView.render("show.json", activity: activity, for: user)
+ assert status[:pleroma][:direct_conversation_id] == nil
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
+ end
+
+ test "returns the direct conversation id when given the `direct_conversation_id` option" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
+ [participation] = Participation.for_user(user)
+
+ status =
+ StatusView.render("show.json",
+ activity: activity,
+ direct_conversation_id: participation.id,
+ for: user
+ )
+
+ assert status[:pleroma][:direct_conversation_id] == participation.id
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "returns a temporary ap_id based user for activities missing db users" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
Repo.delete(user)
Cachex.clear(:user_cache)
+ finger_url =
+ "https://localhost/.well-known/webfinger?resource=acct:#{user.nickname}@localhost"
+
+ Tesla.Mock.mock_global(fn
+ %{method: :get, url: "http://localhost/.well-known/host-meta"} ->
+ %Tesla.Env{status: 404, body: ""}
+
+ %{method: :get, url: "https://localhost/.well-known/host-meta"} ->
+ %Tesla.Env{status: 404, body: ""}
+
+ %{
+ method: :get,
+ url: ^finger_url
+ } ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
%{account: ms_user} = StatusView.render("show.json", activity: activity)
assert ms_user.acct == "erroruser@example.com"
@@ -53,7 +126,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
test "tries to get a user by nickname if fetching by ap_id doesn't work" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
{:ok, user} =
user
@@ -65,6 +138,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
result = StatusView.render("show.json", activity: activity)
assert result[:account][:id] == to_string(user.id)
+ assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a note with null content" do
@@ -83,6 +157,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
status = StatusView.render("show.json", %{activity: note})
assert status.content == ""
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a note activity" do
@@ -107,7 +182,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
in_reply_to_account_id: nil,
card: nil,
reblog: nil,
- content: HtmlSanitizeEx.basic_html(object_data["content"]),
+ content: HTML.filter_tags(object_data["content"]),
created_at: created_at,
reblogs_count: 0,
replies_count: 0,
@@ -119,7 +194,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
pinned: false,
sensitive: false,
poll: nil,
- spoiler_text: HtmlSanitizeEx.basic_html(object_data["summary"]),
+ spoiler_text: HTML.filter_tags(object_data["summary"]),
visibility: "public",
media_attachments: [],
mentions: [],
@@ -146,40 +221,53 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
local: true,
conversation_id: convo_id,
in_reply_to_account_acct: nil,
- content: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["content"])},
- spoiler_text: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["summary"])},
+ content: %{"text/plain" => HTML.strip_tags(object_data["content"])},
+ spoiler_text: %{"text/plain" => HTML.strip_tags(object_data["summary"])},
expires_at: nil,
direct_conversation_id: nil,
- thread_muted: false
+ thread_muted: false,
+ emoji_reactions: []
}
}
assert status == expected
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "tells if the message is muted for some reason" do
user = insert(:user)
other_user = insert(:user)
- {:ok, user} = User.mute(user, other_user)
+ {:ok, _user_relationships} = User.mute(user, other_user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"})
- status = StatusView.render("show.json", %{activity: activity})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "test"})
+ relationships_opt = UserRelationship.view_relationships_option(user, [other_user])
+
+ opts = %{activity: activity}
+ status = StatusView.render("show.json", opts)
assert status.muted == false
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
- status = StatusView.render("show.json", %{activity: activity, for: user})
+ status = StatusView.render("show.json", Map.put(opts, :relationships, relationships_opt))
+ assert status.muted == false
+
+ for_opts = %{activity: activity, for: user}
+ status = StatusView.render("show.json", for_opts)
+ assert status.muted == true
+ status = StatusView.render("show.json", Map.put(for_opts, :relationships, relationships_opt))
assert status.muted == true
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "tells if the message is thread muted" do
user = insert(:user)
other_user = insert(:user)
- {:ok, user} = User.mute(user, other_user)
+ {:ok, _user_relationships} = User.mute(user, other_user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"})
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "test"})
status = StatusView.render("show.json", %{activity: activity, for: user})
assert status.pleroma.thread_muted == false
@@ -194,7 +282,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
test "tells if the status is bookmarked" do
user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "Cute girls doing cute things"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "Cute girls doing cute things"})
status = StatusView.render("show.json", %{activity: activity})
assert status.bookmarked == false
@@ -216,8 +304,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
note = insert(:note_activity)
user = insert(:user)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "he", "in_reply_to_status_id" => note.id})
+ {:ok, activity} = CommonAPI.post(user, %{status: "he", in_reply_to_status_id: note.id})
status = StatusView.render("show.json", %{activity: activity})
@@ -232,12 +319,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
user = insert(:user)
mentioned = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "hi @#{mentioned.nickname}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "hi @#{mentioned.nickname}"})
status = StatusView.render("show.json", %{activity: activity})
assert status.mentions ==
Enum.map([mentioned], fn u -> AccountView.render("mention.json", %{user: u}) end)
+
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "create mentions from the 'to' field" do
@@ -326,11 +415,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
pleroma: %{mime_type: "image/png"}
}
+ api_spec = Pleroma.Web.ApiSpec.spec()
+
assert expected == StatusView.render("attachment.json", %{attachment: object})
+ assert_schema(expected, "Attachment", api_spec)
# If theres a "id", use that instead of the generated one
object = Map.put(object, "id", 2)
- assert %{id: "2"} = StatusView.render("attachment.json", %{attachment: object})
+ result = StatusView.render("attachment.json", %{attachment: object})
+
+ assert %{id: "2"} = result
+ assert_schema(result, "Attachment", api_spec)
end
test "put the url advertised in the Activity in to the url attribute" do
@@ -354,6 +449,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert represented[:id] == to_string(reblog.id)
assert represented[:reblog][:id] == to_string(activity.id)
assert represented[:emojis] == []
+ assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a peertube video" do
@@ -370,6 +466,38 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert represented[:id] == to_string(activity.id)
assert length(represented[:media_attachments]) == 1
+ assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec())
+ end
+
+ test "funkwhale audio" do
+ user = insert(:user)
+
+ {:ok, object} =
+ Pleroma.Object.Fetcher.fetch_object_from_id(
+ "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871"
+ )
+
+ %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"])
+
+ represented = StatusView.render("show.json", %{for: user, activity: activity})
+
+ assert represented[:id] == to_string(activity.id)
+ assert length(represented[:media_attachments]) == 1
+ end
+
+ test "a Mobilizon event" do
+ user = insert(:user)
+
+ {:ok, object} =
+ Pleroma.Object.Fetcher.fetch_object_from_id(
+ "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"
+ )
+
+ %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"])
+
+ represented = StatusView.render("show.json", %{for: user, activity: activity})
+
+ assert represented[:id] == to_string(activity.id)
end
describe "build_tags/1" do
@@ -428,7 +556,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
title: "Example website"
}
- %{provider_name: "Example site name"} =
+ %{provider_name: "example.com"} =
StatusView.render("card.json", %{page_url: page_url, rich_media: card})
end
@@ -443,44 +571,42 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
description: "Example description"
}
- %{provider_name: "Example site name"} =
+ %{provider_name: "example.com"} =
StatusView.render("card.json", %{page_url: page_url, rich_media: card})
end
end
- test "embeds a relationship in the account" do
+ test "does not embed a relationship in the account" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "drink more water"
+ status: "drink more water"
})
result = StatusView.render("show.json", %{activity: activity, for: other_user})
- assert result[:account][:pleroma][:relationship] ==
- AccountView.render("relationship.json", %{user: other_user, target: user})
+ assert result[:account][:pleroma][:relationship] == %{}
+ assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
- test "embeds a relationship in the account in reposts" do
+ test "does not embed a relationship in the account in reposts" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "˙˙ɐʎns"
+ status: "˙˙ɐʎns"
})
{:ok, activity, _object} = CommonAPI.repeat(activity.id, other_user)
result = StatusView.render("show.json", %{activity: activity, for: user})
- assert result[:account][:pleroma][:relationship] ==
- AccountView.render("relationship.json", %{user: user, target: other_user})
-
- assert result[:reblog][:account][:pleroma][:relationship] ==
- AccountView.render("relationship.json", %{user: user, target: user})
+ assert result[:account][:pleroma][:relationship] == %{}
+ assert result[:reblog][:account][:pleroma][:relationship] == %{}
+ assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
test "visibility/list" do
@@ -488,8 +614,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
{:ok, list} = Pleroma.List.create("foo", user)
- {:ok, activity} =
- CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
status = StatusView.render("show.json", activity: activity)
@@ -503,5 +628,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert status.length == listen_activity.data["object"]["length"]
assert status.title == listen_activity.data["object"]["title"]
+ assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
end
diff --git a/test/web/mastodon_api/views/push_subscription_view_test.exs b/test/web/mastodon_api/views/subscription_view_test.exs
index 4e4f5b7e6..981524c0e 100644
--- a/test/web/mastodon_api/views/push_subscription_view_test.exs
+++ b/test/web/mastodon_api/views/subscription_view_test.exs
@@ -1,11 +1,11 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
-defmodule Pleroma.Web.MastodonAPI.PushSubscriptionViewTest do
+defmodule Pleroma.Web.MastodonAPI.SubscriptionViewTest do
use Pleroma.DataCase
import Pleroma.Factory
- alias Pleroma.Web.MastodonAPI.PushSubscriptionView, as: View
+ alias Pleroma.Web.MastodonAPI.SubscriptionView, as: View
alias Pleroma.Web.Push
test "Represent a subscription" do
@@ -18,6 +18,6 @@ defmodule Pleroma.Web.MastodonAPI.PushSubscriptionViewTest do
server_key: Keyword.get(Push.vapid_config(), :public_key)
}
- assert expected == View.render("push_subscription.json", %{subscription: subscription})
+ assert expected == View.render("show.json", %{subscription: subscription})
end
end
diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs
index fdfdb5ec6..da79d38a5 100644
--- a/test/web/media_proxy/media_proxy_controller_test.exs
+++ b/test/web/media_proxy/media_proxy_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
@@ -7,11 +7,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
import Mock
alias Pleroma.Config
- setup do
- media_proxy_config = Config.get([:media_proxy]) || []
- on_exit(fn -> Config.put([:media_proxy], media_proxy_config) end)
- :ok
- end
+ setup do: clear_config(:media_proxy)
+ setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base])
test "it returns 404 when MediaProxy disabled", %{conn: conn} do
Config.put([:media_proxy, :enabled], false)
@@ -55,9 +52,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png")
invalid_url = String.replace(url, "test.png", "test-file.png")
response = get(conn, invalid_url)
- html = "<html><body>You are being <a href=\"#{url}\">redirected</a>.</body></html>"
assert response.status == 302
- assert response.resp_body == html
+ assert redirected_to(response) == url
end
test "it performs ReverseProxy.call when signature valid", %{conn: conn} do
diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs
index 96bdde219..69c2d5dae 100644
--- a/test/web/media_proxy/media_proxy_test.exs
+++ b/test/web/media_proxy/media_proxy_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MediaProxyTest do
@@ -8,7 +8,8 @@ defmodule Pleroma.Web.MediaProxyTest do
import Pleroma.Web.MediaProxy
alias Pleroma.Web.MediaProxy.MediaProxyController
- clear_config([:media_proxy, :enabled])
+ setup do: clear_config([:media_proxy, :enabled])
+ setup do: clear_config(Pleroma.Upload)
describe "when enabled" do
setup do
@@ -224,7 +225,6 @@ defmodule Pleroma.Web.MediaProxyTest do
end
test "ensure Pleroma.Upload base_url is always whitelisted" do
- upload_config = Pleroma.Config.get([Pleroma.Upload])
media_url = "https://media.pleroma.social"
Pleroma.Config.put([Pleroma.Upload, :base_url], media_url)
@@ -232,8 +232,6 @@ defmodule Pleroma.Web.MediaProxyTest do
encoded = url(url)
assert String.starts_with?(encoded, media_url)
-
- Pleroma.Config.put([Pleroma.Upload], upload_config)
end
end
end
diff --git a/test/web/metadata/feed_test.exs b/test/web/metadata/feed_test.exs
index 50e9ce52e..e6e5cc5ed 100644
--- a/test/web/metadata/feed_test.exs
+++ b/test/web/metadata/feed_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.Providers.FeedTest do
diff --git a/test/web/metadata/metadata_test.exs b/test/web/metadata/metadata_test.exs
new file mode 100644
index 000000000..3f8b29e58
--- /dev/null
+++ b/test/web/metadata/metadata_test.exs
@@ -0,0 +1,25 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MetadataTest do
+ use Pleroma.DataCase, async: true
+
+ import Pleroma.Factory
+
+ describe "restrict indexing remote users" do
+ test "for remote user" do
+ user = insert(:user, local: false)
+
+ assert Pleroma.Web.Metadata.build_tags(%{user: user}) =~
+ "<meta content=\"noindex, noarchive\" name=\"robots\">"
+ end
+
+ test "for local user" do
+ user = insert(:user)
+
+ refute Pleroma.Web.Metadata.build_tags(%{user: user}) =~
+ "<meta content=\"noindex, noarchive\" name=\"robots\">"
+ end
+ end
+end
diff --git a/test/web/metadata/opengraph_test.exs b/test/web/metadata/opengraph_test.exs
index 4283f72cd..218540e6c 100644
--- a/test/web/metadata/opengraph_test.exs
+++ b/test/web/metadata/opengraph_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.Providers.OpenGraphTest do
@@ -7,6 +7,8 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraphTest do
import Pleroma.Factory
alias Pleroma.Web.Metadata.Providers.OpenGraph
+ setup do: clear_config([Pleroma.Web.Metadata, :unfurl_nsfw])
+
test "it renders all supported types of attachments and skips unknown types" do
user = insert(:user)
diff --git a/test/web/metadata/player_view_test.exs b/test/web/metadata/player_view_test.exs
index 742b0ed8b..e6c990242 100644
--- a/test/web/metadata/player_view_test.exs
+++ b/test/web/metadata/player_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.PlayerViewTest do
diff --git a/test/web/metadata/rel_me_test.exs b/test/web/metadata/rel_me_test.exs
index 3874e077b..4107a8459 100644
--- a/test/web/metadata/rel_me_test.exs
+++ b/test/web/metadata/rel_me_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.Providers.RelMeTest do
diff --git a/test/web/metadata/restrict_indexing_test.exs b/test/web/metadata/restrict_indexing_test.exs
new file mode 100644
index 000000000..aad0bac42
--- /dev/null
+++ b/test/web/metadata/restrict_indexing_test.exs
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Metadata.Providers.RestrictIndexingTest do
+ use ExUnit.Case, async: true
+
+ describe "build_tags/1" do
+ test "for remote user" do
+ assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{
+ user: %Pleroma.User{local: false}
+ }) == [{:meta, [name: "robots", content: "noindex, noarchive"], []}]
+ end
+
+ test "for local user" do
+ assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{
+ user: %Pleroma.User{local: true}
+ }) == []
+ end
+ end
+end
diff --git a/test/web/metadata/twitter_card_test.exs b/test/web/metadata/twitter_card_test.exs
index 0814006d2..10931b5ba 100644
--- a/test/web/metadata/twitter_card_test.exs
+++ b/test/web/metadata/twitter_card_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do
@@ -13,6 +13,8 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do
alias Pleroma.Web.Metadata.Utils
alias Pleroma.Web.Router
+ setup do: clear_config([Pleroma.Web.Metadata, :unfurl_nsfw])
+
test "it renders twitter card for user info" do
user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994")
avatar_url = Utils.attachment_url(User.avatar_url(user))
@@ -26,10 +28,35 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do
]
end
- test "it does not render attachments if post is nsfw" do
+ test "it uses summary twittercard if post has no attachment" do
+ user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994")
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI"})
+
+ note =
+ insert(:note, %{
+ data: %{
+ "actor" => user.ap_id,
+ "tag" => [],
+ "id" => "https://pleroma.gov/objects/whatever",
+ "content" => "pleroma in a nutshell"
+ }
+ })
+
+ result = TwitterCard.build_tags(%{object: note, user: user, activity_id: activity.id})
+
+ assert [
+ {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []},
+ {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []},
+ {:meta, [property: "twitter:image", content: "http://localhost:4001/images/avi.png"],
+ []},
+ {:meta, [property: "twitter:card", content: "summary"], []}
+ ] == result
+ end
+
+ test "it renders avatar not attachment if post is nsfw and unfurl_nsfw is disabled" do
Pleroma.Config.put([Pleroma.Web.Metadata, :unfurl_nsfw], false)
user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994")
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI"})
note =
insert(:note, %{
@@ -67,13 +94,13 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do
{:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []},
{:meta, [property: "twitter:image", content: "http://localhost:4001/images/avi.png"],
[]},
- {:meta, [property: "twitter:card", content: "summary_large_image"], []}
+ {:meta, [property: "twitter:card", content: "summary"], []}
] == result
end
test "it renders supported types of attachments and skips unknown types" do
user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994")
- {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "HI"})
note =
insert(:note, %{
diff --git a/test/web/metadata/utils_test.exs b/test/web/metadata/utils_test.exs
new file mode 100644
index 000000000..8183256d8
--- /dev/null
+++ b/test/web/metadata/utils_test.exs
@@ -0,0 +1,32 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Metadata.UtilsTest do
+ use Pleroma.DataCase
+ import Pleroma.Factory
+ alias Pleroma.Web.Metadata.Utils
+
+ describe "scrub_html_and_truncate/1" do
+ test "it returns text without encode HTML" do
+ user = insert(:user)
+
+ note =
+ insert(:note, %{
+ data: %{
+ "actor" => user.ap_id,
+ "id" => "https://pleroma.gov/objects/whatever",
+ "content" => "Pleroma's really cool!"
+ }
+ })
+
+ assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
+ end
+ end
+
+ describe "scrub_html_and_truncate/2" do
+ test "it returns text without encode HTML" do
+ assert Utils.scrub_html_and_truncate("Pleroma's really cool!") == "Pleroma's really cool!"
+ end
+ end
+end
diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs
index eb83999bb..5176cde84 100644
--- a/test/web/mongooseim/mongoose_im_controller_test.exs
+++ b/test/web/mongooseim/mongoose_im_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MongooseIMController do
@@ -9,6 +9,7 @@ defmodule Pleroma.Web.MongooseIMController do
test "/user_exists", %{conn: conn} do
_user = insert(:user, nickname: "lain")
_remote_user = insert(:user, nickname: "alice", local: false)
+ _deactivated_user = insert(:user, nickname: "konata", deactivated: true)
res =
conn
@@ -30,10 +31,24 @@ defmodule Pleroma.Web.MongooseIMController do
|> json_response(404)
assert res == false
+
+ res =
+ conn
+ |> get(mongoose_im_path(conn, :user_exists), user: "konata")
+ |> json_response(404)
+
+ assert res == false
end
test "/check_password", %{conn: conn} do
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool"))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("cool"))
+
+ _deactivated_user =
+ insert(:user,
+ nickname: "konata",
+ deactivated: true,
+ password_hash: Pbkdf2.hash_pwd_salt("cool")
+ )
res =
conn
@@ -51,6 +66,13 @@ defmodule Pleroma.Web.MongooseIMController do
res =
conn
+ |> get(mongoose_im_path(conn, :check_password), user: "konata", pass: "cool")
+ |> json_response(404)
+
+ assert res == false
+
+ res =
+ conn
|> get(mongoose_im_path(conn, :check_password), user: "nobody", pass: "cool")
|> json_response(404)
diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs
index e15a0bfff..9bcc07b37 100644
--- a/test/web/node_info_test.exs
+++ b/test/web/node_info_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.NodeInfoTest do
@@ -7,6 +7,11 @@ defmodule Pleroma.Web.NodeInfoTest do
import Pleroma.Factory
+ alias Pleroma.Config
+
+ setup do: clear_config([:mrf_simple])
+ setup do: clear_config(:instance)
+
test "GET /.well-known/nodeinfo", %{conn: conn} do
links =
conn
@@ -24,8 +29,8 @@ defmodule Pleroma.Web.NodeInfoTest do
end
test "nodeinfo shows staff accounts", %{conn: conn} do
- moderator = insert(:user, %{local: true, info: %{is_moderator: true}})
- admin = insert(:user, %{local: true, info: %{is_admin: true}})
+ moderator = insert(:user, local: true, is_moderator: true)
+ admin = insert(:user, local: true, is_admin: true)
conn =
conn
@@ -44,7 +49,7 @@ defmodule Pleroma.Web.NodeInfoTest do
assert result = json_response(conn, 200)
- assert Pleroma.Config.get([Pleroma.User, :restricted_nicknames]) ==
+ assert Config.get([Pleroma.User, :restricted_nicknames]) ==
result["metadata"]["restrictedNicknames"]
end
@@ -61,9 +66,26 @@ defmodule Pleroma.Web.NodeInfoTest do
assert Pleroma.Application.repository() == result["software"]["repository"]
end
+ test "returns fieldsLimits field", %{conn: conn} do
+ Config.put([:instance, :max_account_fields], 10)
+ Config.put([:instance, :max_remote_account_fields], 15)
+ Config.put([:instance, :account_field_name_length], 255)
+ Config.put([:instance, :account_field_value_length], 2048)
+
+ response =
+ conn
+ |> get("/nodeinfo/2.1.json")
+ |> json_response(:ok)
+
+ assert response["metadata"]["fieldsLimits"]["maxFields"] == 10
+ assert response["metadata"]["fieldsLimits"]["maxRemoteFields"] == 15
+ assert response["metadata"]["fieldsLimits"]["nameLength"] == 255
+ assert response["metadata"]["fieldsLimits"]["valueLength"] == 2048
+ end
+
test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do
- option = Pleroma.Config.get([:instance, :safe_dm_mentions])
- Pleroma.Config.put([:instance, :safe_dm_mentions], true)
+ option = Config.get([:instance, :safe_dm_mentions])
+ Config.put([:instance, :safe_dm_mentions], true)
response =
conn
@@ -72,7 +94,7 @@ defmodule Pleroma.Web.NodeInfoTest do
assert "safe_dm_mentions" in response["metadata"]["features"]
- Pleroma.Config.put([:instance, :safe_dm_mentions], false)
+ Config.put([:instance, :safe_dm_mentions], false)
response =
conn
@@ -81,18 +103,66 @@ defmodule Pleroma.Web.NodeInfoTest do
refute "safe_dm_mentions" in response["metadata"]["features"]
- Pleroma.Config.put([:instance, :safe_dm_mentions], option)
+ Config.put([:instance, :safe_dm_mentions], option)
+ end
+
+ describe "`metadata/federation/enabled`" do
+ setup do: clear_config([:instance, :federating])
+
+ test "it shows if federation is enabled/disabled", %{conn: conn} do
+ Config.put([:instance, :federating], true)
+
+ response =
+ conn
+ |> get("/nodeinfo/2.1.json")
+ |> json_response(:ok)
+
+ assert response["metadata"]["federation"]["enabled"] == true
+
+ Config.put([:instance, :federating], false)
+
+ response =
+ conn
+ |> get("/nodeinfo/2.1.json")
+ |> json_response(:ok)
+
+ assert response["metadata"]["federation"]["enabled"] == false
+ end
+ end
+
+ test "it shows default features flags", %{conn: conn} do
+ response =
+ conn
+ |> get("/nodeinfo/2.1.json")
+ |> json_response(:ok)
+
+ default_features = [
+ "pleroma_api",
+ "mastodon_api",
+ "mastodon_api_streaming",
+ "polls",
+ "pleroma_explicit_addressing",
+ "shareable_emoji_packs",
+ "multifetch",
+ "pleroma_emoji_reactions",
+ "pleroma:api/v1/notifications:include_types_filter"
+ ]
+
+ assert MapSet.subset?(
+ MapSet.new(default_features),
+ MapSet.new(response["metadata"]["features"])
+ )
end
test "it shows MRF transparency data if enabled", %{conn: conn} do
- config = Pleroma.Config.get([:instance, :rewrite_policy])
- Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
+ config = Config.get([:instance, :rewrite_policy])
+ Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
- option = Pleroma.Config.get([:instance, :mrf_transparency])
- Pleroma.Config.put([:instance, :mrf_transparency], true)
+ option = Config.get([:instance, :mrf_transparency])
+ Config.put([:instance, :mrf_transparency], true)
simple_config = %{"reject" => ["example.com"]}
- Pleroma.Config.put(:mrf_simple, simple_config)
+ Config.put(:mrf_simple, simple_config)
response =
conn
@@ -101,25 +171,25 @@ defmodule Pleroma.Web.NodeInfoTest do
assert response["metadata"]["federation"]["mrf_simple"] == simple_config
- Pleroma.Config.put([:instance, :rewrite_policy], config)
- Pleroma.Config.put([:instance, :mrf_transparency], option)
- Pleroma.Config.put(:mrf_simple, %{})
+ Config.put([:instance, :rewrite_policy], config)
+ Config.put([:instance, :mrf_transparency], option)
+ Config.put(:mrf_simple, %{})
end
test "it performs exclusions from MRF transparency data if configured", %{conn: conn} do
- config = Pleroma.Config.get([:instance, :rewrite_policy])
- Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
+ config = Config.get([:instance, :rewrite_policy])
+ Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
- option = Pleroma.Config.get([:instance, :mrf_transparency])
- Pleroma.Config.put([:instance, :mrf_transparency], true)
+ option = Config.get([:instance, :mrf_transparency])
+ Config.put([:instance, :mrf_transparency], true)
- exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions])
- Pleroma.Config.put([:instance, :mrf_transparency_exclusions], ["other.site"])
+ exclusions = Config.get([:instance, :mrf_transparency_exclusions])
+ Config.put([:instance, :mrf_transparency_exclusions], ["other.site"])
simple_config = %{"reject" => ["example.com", "other.site"]}
expected_config = %{"reject" => ["example.com"]}
- Pleroma.Config.put(:mrf_simple, simple_config)
+ Config.put(:mrf_simple, simple_config)
response =
conn
@@ -129,9 +199,9 @@ defmodule Pleroma.Web.NodeInfoTest do
assert response["metadata"]["federation"]["mrf_simple"] == expected_config
assert response["metadata"]["federation"]["exclusions"] == true
- Pleroma.Config.put([:instance, :rewrite_policy], config)
- Pleroma.Config.put([:instance, :mrf_transparency], option)
- Pleroma.Config.put([:instance, :mrf_transparency_exclusions], exclusions)
- Pleroma.Config.put(:mrf_simple, %{})
+ Config.put([:instance, :rewrite_policy], config)
+ Config.put([:instance, :mrf_transparency], option)
+ Config.put([:instance, :mrf_transparency_exclusions], exclusions)
+ Config.put(:mrf_simple, %{})
end
end
diff --git a/test/web/oauth/app_test.exs b/test/web/oauth/app_test.exs
index 195b8c17f..899af648e 100644
--- a/test/web/oauth/app_test.exs
+++ b/test/web/oauth/app_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.AppTest do
diff --git a/test/web/oauth/authorization_test.exs b/test/web/oauth/authorization_test.exs
index 2e82a7b79..d74b26cf8 100644
--- a/test/web/oauth/authorization_test.exs
+++ b/test/web/oauth/authorization_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.AuthorizationTest do
diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs
index 1cbe133b7..011642c08 100644
--- a/test/web/oauth/ldap_authorization_test.exs
+++ b/test/web/oauth/ldap_authorization_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do
@@ -12,18 +12,14 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do
@skip if !Code.ensure_loaded?(:eldap), do: :skip
- clear_config_all([:ldap, :enabled]) do
- Pleroma.Config.put([:ldap, :enabled], true)
- end
+ setup_all do: clear_config([:ldap, :enabled], true)
- clear_config_all(Pleroma.Web.Auth.Authenticator) do
- Pleroma.Config.put(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator)
- end
+ setup_all do: clear_config(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator)
@tag @skip
test "authorizes the existing user using LDAP credentials" do
password = "testpassword"
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password))
app = insert(:oauth_app, scopes: ["read", "write"])
host = Pleroma.Config.get([:ldap, :host]) |> to_charlist
@@ -108,7 +104,7 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do
@tag @skip
test "falls back to the default authorization when LDAP is unavailable" do
password = "testpassword"
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password))
app = insert(:oauth_app, scopes: ["read", "write"])
host = Pleroma.Config.get([:ldap, :host]) |> to_charlist
@@ -152,7 +148,7 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do
@tag @skip
test "disallow authorization for wrong LDAP credentials" do
password = "testpassword"
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password))
app = insert(:oauth_app, scopes: ["read", "write"])
host = Pleroma.Config.get([:ldap, :host]) |> to_charlist
diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs
new file mode 100644
index 000000000..3c341facd
--- /dev/null
+++ b/test/web/oauth/mfa_controller_test.exs
@@ -0,0 +1,306 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth.MFAControllerTest do
+ use Pleroma.Web.ConnCase
+ import Pleroma.Factory
+
+ alias Pleroma.MFA
+ alias Pleroma.MFA.BackupCodes
+ alias Pleroma.MFA.TOTP
+ alias Pleroma.Repo
+ alias Pleroma.Web.OAuth.Authorization
+ alias Pleroma.Web.OAuth.OAuthController
+
+ setup %{conn: conn} do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ backup_codes: [Pbkdf2.hash_pwd_salt("test-code")],
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ app = insert(:oauth_app)
+ {:ok, conn: conn, user: user, app: app}
+ end
+
+ describe "show" do
+ setup %{conn: conn, user: user, app: app} do
+ mfa_token =
+ insert(:mfa_token,
+ user: user,
+ authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+ )
+
+ {:ok, conn: conn, mfa_token: mfa_token}
+ end
+
+ test "GET /oauth/mfa renders mfa forms", %{conn: conn, mfa_token: mfa_token} do
+ conn =
+ get(
+ conn,
+ "/oauth/mfa",
+ %{
+ "mfa_token" => mfa_token.token,
+ "state" => "a_state",
+ "redirect_uri" => "http://localhost:8080/callback"
+ }
+ )
+
+ assert response = html_response(conn, 200)
+ assert response =~ "Two-factor authentication"
+ assert response =~ mfa_token.token
+ assert response =~ "http://localhost:8080/callback"
+ end
+
+ test "GET /oauth/mfa renders mfa recovery forms", %{conn: conn, mfa_token: mfa_token} do
+ conn =
+ get(
+ conn,
+ "/oauth/mfa",
+ %{
+ "mfa_token" => mfa_token.token,
+ "state" => "a_state",
+ "redirect_uri" => "http://localhost:8080/callback",
+ "challenge_type" => "recovery"
+ }
+ )
+
+ assert response = html_response(conn, 200)
+ assert response =~ "Two-factor recovery"
+ assert response =~ mfa_token.token
+ assert response =~ "http://localhost:8080/callback"
+ end
+ end
+
+ describe "verify" do
+ setup %{conn: conn, user: user, app: app} do
+ mfa_token =
+ insert(:mfa_token,
+ user: user,
+ authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+ )
+
+ {:ok, conn: conn, user: user, mfa_token: mfa_token, app: app}
+ end
+
+ test "POST /oauth/mfa/verify, verify totp code", %{
+ conn: conn,
+ user: user,
+ mfa_token: mfa_token,
+ app: app
+ } do
+ otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+ conn =
+ conn
+ |> post("/oauth/mfa/verify", %{
+ "mfa" => %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "totp",
+ "code" => otp_token,
+ "state" => "a_state",
+ "redirect_uri" => OAuthController.default_redirect_uri(app)
+ }
+ })
+
+ target = redirected_to(conn)
+ target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+ query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+ assert %{"state" => "a_state", "code" => code} = query
+ assert target_url == OAuthController.default_redirect_uri(app)
+ auth = Repo.get_by(Authorization, token: code)
+ assert auth.scopes == ["write"]
+ end
+
+ test "POST /oauth/mfa/verify, verify recovery code", %{
+ conn: conn,
+ mfa_token: mfa_token,
+ app: app
+ } do
+ conn =
+ conn
+ |> post("/oauth/mfa/verify", %{
+ "mfa" => %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "recovery",
+ "code" => "test-code",
+ "state" => "a_state",
+ "redirect_uri" => OAuthController.default_redirect_uri(app)
+ }
+ })
+
+ target = redirected_to(conn)
+ target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
+ query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+ assert %{"state" => "a_state", "code" => code} = query
+ assert target_url == OAuthController.default_redirect_uri(app)
+ auth = Repo.get_by(Authorization, token: code)
+ assert auth.scopes == ["write"]
+ end
+ end
+
+ describe "challenge/totp" do
+ test "returns access token with valid code", %{conn: conn, user: user, app: app} do
+ otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+ mfa_token =
+ insert(:mfa_token,
+ user: user,
+ authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+ )
+
+ response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "totp",
+ "code" => otp_token,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(:ok)
+
+ ap_id = user.ap_id
+
+ assert match?(
+ %{
+ "access_token" => _,
+ "expires_in" => 600,
+ "me" => ^ap_id,
+ "refresh_token" => _,
+ "scope" => "write",
+ "token_type" => "Bearer"
+ },
+ response
+ )
+ end
+
+ test "returns errors when mfa token invalid", %{conn: conn, user: user, app: app} do
+ otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+
+ response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => "XXX",
+ "challenge_type" => "totp",
+ "code" => otp_token,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(400)
+
+ assert response == %{"error" => "Invalid code"}
+ end
+
+ test "returns error when otp code is invalid", %{conn: conn, user: user, app: app} do
+ mfa_token = insert(:mfa_token, user: user)
+
+ response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "totp",
+ "code" => "XXX",
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(400)
+
+ assert response == %{"error" => "Invalid code"}
+ end
+
+ test "returns error when client credentails is wrong ", %{conn: conn, user: user} do
+ otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
+ mfa_token = insert(:mfa_token, user: user)
+
+ response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "totp",
+ "code" => otp_token,
+ "client_id" => "xxx",
+ "client_secret" => "xxx"
+ })
+ |> json_response(400)
+
+ assert response == %{"error" => "Invalid code"}
+ end
+ end
+
+ describe "challenge/recovery" do
+ setup %{conn: conn} do
+ app = insert(:oauth_app)
+ {:ok, conn: conn, app: app}
+ end
+
+ test "returns access token with valid code", %{conn: conn, app: app} do
+ otp_secret = TOTP.generate_secret()
+
+ [code | _] = backup_codes = BackupCodes.generate()
+
+ hashed_codes =
+ backup_codes
+ |> Enum.map(&Pbkdf2.hash_pwd_salt(&1))
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ backup_codes: hashed_codes,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ mfa_token =
+ insert(:mfa_token,
+ user: user,
+ authorization: build(:oauth_authorization, app: app, scopes: ["write"])
+ )
+
+ response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "recovery",
+ "code" => code,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(:ok)
+
+ ap_id = user.ap_id
+
+ assert match?(
+ %{
+ "access_token" => _,
+ "expires_in" => 600,
+ "me" => ^ap_id,
+ "refresh_token" => _,
+ "scope" => "write",
+ "token_type" => "Bearer"
+ },
+ response
+ )
+
+ error_response =
+ conn
+ |> post("/oauth/mfa/challenge", %{
+ "mfa_token" => mfa_token.token,
+ "challenge_type" => "recovery",
+ "code" => code,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(400)
+
+ assert error_response == %{"error" => "Invalid code"}
+ end
+ end
+end
diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs
index 41aaf6189..d389e4ce0 100644
--- a/test/web/oauth/oauth_controller_test.exs
+++ b/test/web/oauth/oauth_controller_test.exs
@@ -1,11 +1,13 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.OAuthControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
+ alias Pleroma.MFA
+ alias Pleroma.MFA.TOTP
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
@@ -17,7 +19,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
key: "_test",
signing_salt: "cooldude"
]
- clear_config_all([:instance, :account_activation_required])
+ setup do: clear_config([:instance, :account_activation_required])
describe "in OAuth consumer mode, " do
setup do
@@ -30,12 +32,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
]
end
- clear_config([:auth, :oauth_consumer_strategies]) do
- Pleroma.Config.put(
- [:auth, :oauth_consumer_strategies],
- ~w(twitter facebook)
- )
- end
+ setup do: clear_config([:auth, :oauth_consumer_strategies], ~w(twitter facebook))
test "GET /oauth/authorize renders auth forms, including OAuth consumer form", %{
app: app,
@@ -314,7 +311,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
app: app,
conn: conn
} do
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword"))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("testpassword"))
registration = insert(:registration, user: nil)
redirect_uri = OAuthController.default_redirect_uri(app)
@@ -345,7 +342,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
app: app,
conn: conn
} do
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword"))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("testpassword"))
registration = insert(:registration, user: nil)
unlisted_redirect_uri = "http://cross-site-request.com"
@@ -450,7 +447,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
test "renders authentication page if user is already authenticated but `force_login` is tru-ish",
%{app: app, conn: conn} do
- token = insert(:oauth_token, app_id: app.id)
+ token = insert(:oauth_token, app: app)
conn =
conn
@@ -469,12 +466,35 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
assert html_response(conn, 200) =~ ~s(type="submit")
end
+ test "renders authentication page if user is already authenticated but user request with another client",
+ %{
+ app: app,
+ conn: conn
+ } do
+ token = insert(:oauth_token, app: app)
+
+ conn =
+ conn
+ |> put_session(:oauth_token, token.token)
+ |> get(
+ "/oauth/authorize",
+ %{
+ "response_type" => "code",
+ "client_id" => "another_client_id",
+ "redirect_uri" => OAuthController.default_redirect_uri(app),
+ "scope" => "read"
+ }
+ )
+
+ assert html_response(conn, 200) =~ ~s(type="submit")
+ end
+
test "with existing authentication and non-OOB `redirect_uri`, redirects to app with `token` and `state` params",
%{
app: app,
conn: conn
} do
- token = insert(:oauth_token, app_id: app.id)
+ token = insert(:oauth_token, app: app)
conn =
conn
@@ -500,7 +520,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
conn: conn
} do
unlisted_redirect_uri = "http://cross-site-request.com"
- token = insert(:oauth_token, app_id: app.id)
+ token = insert(:oauth_token, app: app)
conn =
conn
@@ -524,7 +544,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
app: app,
conn: conn
} do
- token = insert(:oauth_token, app_id: app.id)
+ token = insert(:oauth_token, app: app)
conn =
conn
@@ -544,11 +564,61 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
end
describe "POST /oauth/authorize" do
- test "redirects with oauth authorization" do
- user = insert(:user)
- app = insert(:oauth_app, scopes: ["read", "write", "follow"])
+ test "redirects with oauth authorization, " <>
+ "granting requested app-supported scopes to both admin- and non-admin users" do
+ app_scopes = ["read", "write", "admin", "secret_scope"]
+ app = insert(:oauth_app, scopes: app_scopes)
redirect_uri = OAuthController.default_redirect_uri(app)
+ non_admin = insert(:user, is_admin: false)
+ admin = insert(:user, is_admin: true)
+ scopes_subset = ["read:subscope", "write", "admin"]
+
+ # In case scope param is missing, expecting _all_ app-supported scopes to be granted
+ for user <- [non_admin, admin],
+ {requested_scopes, expected_scopes} <-
+ %{scopes_subset => scopes_subset, nil: app_scopes} do
+ conn =
+ post(
+ build_conn(),
+ "/oauth/authorize",
+ %{
+ "authorization" => %{
+ "name" => user.nickname,
+ "password" => "test",
+ "client_id" => app.client_id,
+ "redirect_uri" => redirect_uri,
+ "scope" => requested_scopes,
+ "state" => "statepassed"
+ }
+ }
+ )
+
+ target = redirected_to(conn)
+ assert target =~ redirect_uri
+
+ query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
+
+ assert %{"state" => "statepassed", "code" => code} = query
+ auth = Repo.get_by(Authorization, token: code)
+ assert auth
+ assert auth.scopes == expected_scopes
+ end
+ end
+
+ test "redirect to on two-factor auth page" do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ app = insert(:oauth_app, scopes: ["read", "write", "follow"])
+
conn =
build_conn()
|> post("/oauth/authorize", %{
@@ -556,21 +626,19 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
"name" => user.nickname,
"password" => "test",
"client_id" => app.client_id,
- "redirect_uri" => redirect_uri,
- "scope" => "read:subscope write",
+ "redirect_uri" => app.redirect_uris,
+ "scope" => "read write",
"state" => "statepassed"
}
})
- target = redirected_to(conn)
- assert target =~ redirect_uri
+ result = html_response(conn, 200)
- query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
-
- assert %{"state" => "statepassed", "code" => code} = query
- auth = Repo.get_by(Authorization, token: code)
- assert auth
- assert auth.scopes == ["read:subscope", "write"]
+ mfa_token = Repo.get_by(MFA.Token, user_id: user.id)
+ assert result =~ app.redirect_uris
+ assert result =~ "statepassed"
+ assert result =~ mfa_token.token
+ assert result =~ "Two-factor authentication"
end
test "returns 401 for wrong credentials", %{conn: conn} do
@@ -600,13 +668,13 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
assert result =~ "Invalid Username/Password"
end
- test "returns 401 for missing scopes", %{conn: conn} do
- user = insert(:user)
- app = insert(:oauth_app)
+ test "returns 401 for missing scopes" do
+ user = insert(:user, is_admin: false)
+ app = insert(:oauth_app, scopes: ["read", "write", "admin"])
redirect_uri = OAuthController.default_redirect_uri(app)
result =
- conn
+ build_conn()
|> post("/oauth/authorize", %{
"authorization" => %{
"name" => user.nickname,
@@ -682,7 +750,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
test "issues a token for `password` grant_type with valid credentials, with full permissions by default" do
password = "testpassword"
- user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
+ user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password))
app = insert(:oauth_app, scopes: ["read", "write"])
@@ -704,6 +772,46 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
assert token.scopes == app.scopes
end
+ test "issues a mfa token for `password` grant_type, when MFA enabled" do
+ password = "testpassword"
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ password_hash: Pbkdf2.hash_pwd_salt(password),
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ app = insert(:oauth_app, scopes: ["read", "write"])
+
+ response =
+ build_conn()
+ |> post("/oauth/token", %{
+ "grant_type" => "password",
+ "username" => user.nickname,
+ "password" => password,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(403)
+
+ assert match?(
+ %{
+ "supported_challenge_types" => "totp",
+ "mfa_token" => _,
+ "error" => "mfa_required"
+ },
+ response
+ )
+
+ token = Repo.get_by(MFA.Token, token: response["mfa_token"])
+ assert token.user_id == user.id
+ assert token.authorization_id
+ end
+
test "issues a token for request with HTTP basic auth client credentials" do
user = insert(:user)
app = insert(:oauth_app, scopes: ["scope1", "scope2", "scope3"])
@@ -779,11 +887,11 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
password = "testpassword"
{:ok, user} =
- insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
- |> User.change_info(&User.Info.confirmation_changeset(&1, need_confirmation: true))
- |> Repo.update()
+ insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password))
+ |> User.confirmation_changeset(need_confirmation: true)
+ |> User.update_and_set_cache()
- refute Pleroma.User.auth_active?(user)
+ refute Pleroma.User.account_status(user) == :active
app = insert(:oauth_app)
@@ -807,13 +915,13 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
user =
insert(:user,
- password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
- info: %{deactivated: true}
+ password_hash: Pbkdf2.hash_pwd_salt(password),
+ deactivated: true
)
app = insert(:oauth_app)
- conn =
+ resp =
build_conn()
|> post("/oauth/token", %{
"grant_type" => "password",
@@ -822,10 +930,12 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
+ |> json_response(403)
- assert resp = json_response(conn, 403)
- assert %{"error" => _} = resp
- refute Map.has_key?(resp, "access_token")
+ assert resp == %{
+ "error" => "Your account is currently disabled",
+ "identifier" => "account_is_disabled"
+ }
end
test "rejects token exchange for user with password_reset_pending set to true" do
@@ -833,13 +943,13 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
user =
insert(:user,
- password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
- info: %{password_reset_pending: true}
+ password_hash: Pbkdf2.hash_pwd_salt(password),
+ password_reset_pending: true
)
app = insert(:oauth_app, scopes: ["read", "write"])
- conn =
+ resp =
build_conn()
|> post("/oauth/token", %{
"grant_type" => "password",
@@ -848,12 +958,41 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
+ |> json_response(403)
- assert resp = json_response(conn, 403)
+ assert resp == %{
+ "error" => "Password reset is required",
+ "identifier" => "password_reset_required"
+ }
+ end
- assert resp["error"] == "Password reset is required"
- assert resp["identifier"] == "password_reset_required"
- refute Map.has_key?(resp, "access_token")
+ test "rejects token exchange for user with confirmation_pending set to true" do
+ Pleroma.Config.put([:instance, :account_activation_required], true)
+ password = "testpassword"
+
+ user =
+ insert(:user,
+ password_hash: Pbkdf2.hash_pwd_salt(password),
+ confirmation_pending: true
+ )
+
+ app = insert(:oauth_app, scopes: ["read", "write"])
+
+ resp =
+ build_conn()
+ |> post("/oauth/token", %{
+ "grant_type" => "password",
+ "username" => user.nickname,
+ "password" => password,
+ "client_id" => app.client_id,
+ "client_secret" => app.client_secret
+ })
+ |> json_response(403)
+
+ assert resp == %{
+ "error" => "Your login is missing a confirmed e-mail address",
+ "identifier" => "missing_confirmed_email"
+ }
end
test "rejects an invalid authorization code" do
@@ -876,7 +1015,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
end
describe "POST /oauth/token - refresh token" do
- clear_config([:oauth2, :issue_new_refresh_token])
+ setup do: clear_config([:oauth2, :issue_new_refresh_token])
test "issues a new access token with keep fresh token" do
Pleroma.Config.put([:oauth2, :issue_new_refresh_token], true)
diff --git a/test/web/oauth/token/utils_test.exs b/test/web/oauth/token/utils_test.exs
index dc1f9a986..a610d92f8 100644
--- a/test/web/oauth/token/utils_test.exs
+++ b/test/web/oauth/token/utils_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.Token.UtilsTest do
diff --git a/test/web/oauth/token_test.exs b/test/web/oauth/token_test.exs
index 5359940f8..40d71eb59 100644
--- a/test/web/oauth/token_test.exs
+++ b/test/web/oauth/token_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.TokenTest do
diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs
index 37b7b62f5..bb349cb19 100644
--- a/test/web/ostatus/ostatus_controller_test.exs
+++ b/test/web/ostatus/ostatus_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OStatus.OStatusControllerTest do
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
import Pleroma.Factory
+ alias Pleroma.Config
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.CommonAPI
@@ -16,40 +17,23 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
:ok
end
- clear_config_all([:instance, :federating]) do
- Pleroma.Config.put([:instance, :federating], true)
- end
-
- describe "GET object/2" do
- test "redirects to /notice/id for html format", %{conn: conn} do
- note_activity = insert(:note_activity)
- object = Object.normalize(note_activity)
- [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, object.data["id"]))
- url = "/objects/#{uuid}"
+ setup do: clear_config([:instance, :federating], true)
- conn =
- conn
- |> put_req_header("accept", "text/html")
- |> get(url)
-
- assert redirected_to(conn) == "/notice/#{note_activity.id}"
+ # Note: see ActivityPubControllerTest for JSON format tests
+ describe "GET /objects/:uuid (text/html)" do
+ setup %{conn: conn} do
+ conn = put_req_header(conn, "accept", "text/html")
+ %{conn: conn}
end
- test "500s when user not found", %{conn: conn} do
+ test "redirects to /notice/id for html format", %{conn: conn} do
note_activity = insert(:note_activity)
object = Object.normalize(note_activity)
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
- User.invalidate_cache(user)
- Pleroma.Repo.delete(user)
[_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, object.data["id"]))
url = "/objects/#{uuid}"
- conn =
- conn
- |> put_req_header("accept", "application/xml")
- |> get(url)
-
- assert response(conn, 500) == ~S({"error":"Something went wrong"})
+ conn = get(conn, url)
+ assert redirected_to(conn) == "/notice/#{note_activity.id}"
end
test "404s on private objects", %{conn: conn} do
@@ -62,39 +46,26 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
|> response(404)
end
- test "404s on nonexisting objects", %{conn: conn} do
+ test "404s on non-existing objects", %{conn: conn} do
conn
|> get("/objects/123")
|> response(404)
end
end
- describe "GET activity/2" do
- test "redirects to /notice/id for html format", %{conn: conn} do
- note_activity = insert(:note_activity)
- [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"]))
-
- conn =
- conn
- |> put_req_header("accept", "text/html")
- |> get("/activities/#{uuid}")
-
- assert redirected_to(conn) == "/notice/#{note_activity.id}"
+ # Note: see ActivityPubControllerTest for JSON format tests
+ describe "GET /activities/:uuid (text/html)" do
+ setup %{conn: conn} do
+ conn = put_req_header(conn, "accept", "text/html")
+ %{conn: conn}
end
- test "505s when user not found", %{conn: conn} do
+ test "redirects to /notice/id for html format", %{conn: conn} do
note_activity = insert(:note_activity)
[_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"]))
- user = User.get_cached_by_ap_id(note_activity.data["actor"])
- User.invalidate_cache(user)
- Pleroma.Repo.delete(user)
-
- conn =
- conn
- |> put_req_header("accept", "text/html")
- |> get("/activities/#{uuid}")
- assert response(conn, 500) == ~S({"error":"Something went wrong"})
+ conn = get(conn, "/activities/#{uuid}")
+ assert redirected_to(conn) == "/notice/#{note_activity.id}"
end
test "404s on private activities", %{conn: conn} do
@@ -111,37 +82,31 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
|> get("/activities/123")
|> response(404)
end
+ end
- test "gets an activity in AS2 format", %{conn: conn} do
+ describe "GET notice/2" do
+ test "redirects to a proper object URL when json requested and the object is local", %{
+ conn: conn
+ } do
note_activity = insert(:note_activity)
- [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"]))
- url = "/activities/#{uuid}"
+ expected_redirect_url = Object.normalize(note_activity).data["id"]
- conn =
+ redirect_url =
conn
|> put_req_header("accept", "application/activity+json")
- |> get(url)
-
- assert json_response(conn, 200)
- end
- end
-
- describe "GET notice/2" do
- test "gets a notice in xml format", %{conn: conn} do
- note_activity = insert(:note_activity)
+ |> get("/notice/#{note_activity.id}")
+ |> redirected_to()
- conn
- |> get("/notice/#{note_activity.id}")
- |> response(200)
+ assert redirect_url == expected_redirect_url
end
- test "gets a notice in AS2 format", %{conn: conn} do
- note_activity = insert(:note_activity)
+ test "returns a 404 on remote notice when json requested", %{conn: conn} do
+ note_activity = insert(:note_activity, local: false)
conn
|> put_req_header("accept", "application/activity+json")
|> get("/notice/#{note_activity.id}")
- |> json_response(200)
+ |> response(404)
end
test "500s when actor not found", %{conn: conn} do
@@ -157,32 +122,6 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
assert response(conn, 500) == ~S({"error":"Something went wrong"})
end
- test "only gets a notice in AS2 format for Create messages", %{conn: conn} do
- note_activity = insert(:note_activity)
- url = "/notice/#{note_activity.id}"
-
- conn =
- conn
- |> put_req_header("accept", "application/activity+json")
- |> get(url)
-
- assert json_response(conn, 200)
-
- user = insert(:user)
-
- {:ok, like_activity, _} = CommonAPI.favorite(note_activity.id, user)
- url = "/notice/#{like_activity.id}"
-
- assert like_activity.data["type"] == "Like"
-
- conn =
- build_conn()
- |> put_req_header("accept", "application/activity+json")
- |> get(url)
-
- assert response(conn, 404)
- end
-
test "render html for redirect for html format", %{conn: conn} do
note_activity = insert(:note_activity)
@@ -197,7 +136,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
user = insert(:user)
- {:ok, like_activity, _} = CommonAPI.favorite(note_activity.id, user)
+ {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
assert like_activity.data["type"] == "Like"
@@ -221,7 +160,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
assert response(conn, 404)
end
- test "404s a nonexisting notice", %{conn: conn} do
+ test "404s a non-existing notice", %{conn: conn} do
url = "/notice/123"
conn =
@@ -230,10 +169,21 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
assert response(conn, 404)
end
+
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn
+ } do
+ user = insert(:user)
+ note_activity = insert(:note_activity)
+
+ conn = put_req_header(conn, "accept", "text/html")
+
+ ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}", user)
+ end
end
describe "GET /notice/:id/embed_player" do
- test "render embed player", %{conn: conn} do
+ setup do
note_activity = insert(:note_activity)
object = Pleroma.Object.normalize(note_activity)
@@ -255,9 +205,11 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
|> Ecto.Changeset.change(data: object_data)
|> Pleroma.Repo.update()
- conn =
- conn
- |> get("/notice/#{note_activity.id}/embed_player")
+ %{note_activity: note_activity}
+ end
+
+ test "renders embed player", %{conn: conn, note_activity: note_activity} do
+ conn = get(conn, "/notice/#{note_activity.id}/embed_player")
assert Plug.Conn.get_resp_header(conn, "x-frame-options") == ["ALLOW"]
@@ -323,9 +275,19 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
|> Ecto.Changeset.change(data: object_data)
|> Pleroma.Repo.update()
- assert conn
- |> get("/notice/#{note_activity.id}/embed_player")
- |> response(404)
+ conn
+ |> get("/notice/#{note_activity.id}/embed_player")
+ |> response(404)
+ end
+
+ test "it requires authentication if instance is NOT federating", %{
+ conn: conn,
+ note_activity: note_activity
+ } do
+ user = insert(:user)
+ conn = put_req_header(conn, "accept", "text/html")
+
+ ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}/embed_player", user)
end
end
end
diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs
index 3b4665afd..103997c31 100644
--- a/test/web/pleroma_api/controllers/account_controller_test.exs
+++ b/test/web/pleroma_api/controllers/account_controller_test.exs
@@ -1,12 +1,11 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Config
- alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.CommonAPI
@@ -20,23 +19,40 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
setup do
{:ok, user} =
insert(:user)
- |> User.change_info(&User.Info.confirmation_changeset(&1, need_confirmation: true))
- |> Repo.update()
+ |> User.confirmation_changeset(need_confirmation: true)
+ |> User.update_and_set_cache()
- assert user.info.confirmation_pending
+ assert user.confirmation_pending
[user: user]
end
- clear_config([:instance, :account_activation_required]) do
- Config.put([:instance, :account_activation_required], true)
- end
+ setup do: clear_config([:instance, :account_activation_required], true)
test "resend account confirmation email", %{conn: conn, user: user} do
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "application/json")
|> post("/api/v1/pleroma/accounts/confirmation_resend?email=#{user.email}")
- |> json_response(:no_content)
+ |> json_response_and_validate_schema(:no_content)
+
+ ObanHelpers.perform_all()
+
+ email = Pleroma.Emails.UserEmail.account_confirmation_email(user)
+ notify_email = Config.get([:instance, :notify_email])
+ instance_name = Config.get([:instance, :name])
+
+ assert_email_sent(
+ from: {instance_name, notify_email},
+ to: {user.name, user.email},
+ html_body: email.html_body
+ )
+ end
+
+ test "resend account confirmation email (with nickname)", %{conn: conn, user: user} do
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/pleroma/accounts/confirmation_resend?nickname=#{user.nickname}")
+ |> json_response_and_validate_schema(:no_content)
ObanHelpers.perform_all()
@@ -53,13 +69,14 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
end
describe "PATCH /api/v1/pleroma/accounts/update_avatar" do
- test "user avatar can be set", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+
+ test "user avatar can be set", %{user: user, conn: conn} do
avatar_image = File.read!("test/fixtures/avatar_data_uri")
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image})
user = refresh_record(user)
@@ -76,102 +93,96 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
]
} = user.avatar
- assert %{"url" => _} = json_response(conn, 200)
+ assert %{"url" => _} = json_response_and_validate_schema(conn, 200)
end
- test "user avatar can be reset", %{conn: conn} do
- user = insert(:user)
-
+ test "user avatar can be reset", %{user: user, conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""})
user = User.get_cached_by_id(user.id)
assert user.avatar == nil
- assert %{"url" => nil} = json_response(conn, 200)
+ assert %{"url" => nil} = json_response_and_validate_schema(conn, 200)
end
end
describe "PATCH /api/v1/pleroma/accounts/update_banner" do
- test "can set profile banner", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+ test "can set profile banner", %{user: user, conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image})
user = refresh_record(user)
- assert user.info.banner["type"] == "Image"
+ assert user.banner["type"] == "Image"
- assert %{"url" => _} = json_response(conn, 200)
+ assert %{"url" => _} = json_response_and_validate_schema(conn, 200)
end
- test "can reset profile banner", %{conn: conn} do
- user = insert(:user)
-
+ test "can reset profile banner", %{user: user, conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""})
user = refresh_record(user)
- assert user.info.banner == %{}
+ assert user.banner == %{}
- assert %{"url" => nil} = json_response(conn, 200)
+ assert %{"url" => nil} = json_response_and_validate_schema(conn, 200)
end
end
describe "PATCH /api/v1/pleroma/accounts/update_background" do
- test "background image can be set", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+ test "background image can be set", %{user: user, conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image})
user = refresh_record(user)
- assert user.info.background["type"] == "Image"
- assert %{"url" => _} = json_response(conn, 200)
+ assert user.background["type"] == "Image"
+ # assert %{"url" => _} = json_response(conn, 200)
+ assert %{"url" => _} = json_response_and_validate_schema(conn, 200)
end
- test "background image can be reset", %{conn: conn} do
- user = insert(:user)
-
+ test "background image can be reset", %{user: user, conn: conn} do
conn =
conn
- |> assign(:user, user)
+ |> put_req_header("content-type", "multipart/form-data")
|> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""})
user = refresh_record(user)
- assert user.info.background == %{}
- assert %{"url" => nil} = json_response(conn, 200)
+ assert user.background == %{}
+ assert %{"url" => nil} = json_response_and_validate_schema(conn, 200)
end
end
describe "getting favorites timeline of specified user" do
setup do
- [current_user, user] = insert_pair(:user, %{info: %{hide_favorites: false}})
- [current_user: current_user, user: user]
+ [current_user, user] = insert_pair(:user, hide_favorites: false)
+ %{user: current_user, conn: conn} = oauth_access(["read:favourites"], user: current_user)
+ [current_user: current_user, user: user, conn: conn]
end
test "returns list of statuses favorited by specified user", %{
conn: conn,
- current_user: current_user,
user: user
} do
[activity | _] = insert_pair(:note_activity)
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
response =
conn
- |> assign(:user, current_user)
|> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
[like] = response
@@ -179,83 +190,81 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
assert like["id"] == activity.id
end
- test "returns favorites for specified user_id when user is not logged in", %{
- conn: conn,
+ test "returns favorites for specified user_id when requester is not logged in", %{
user: user
} do
activity = insert(:note_activity)
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
response =
- conn
+ build_conn()
|> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(200)
assert length(response) == 1
end
test "returns favorited DM only when user is logged in and he is one of recipients", %{
- conn: conn,
current_user: current_user,
user: user
} do
{:ok, direct} =
CommonAPI.post(current_user, %{
- "status" => "Hi @#{user.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user.nickname}!",
+ visibility: "direct"
})
- CommonAPI.favorite(direct.id, user)
+ CommonAPI.favorite(user, direct.id)
- response =
- conn
- |> assign(:user, current_user)
- |> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ for u <- [user, current_user] do
+ response =
+ build_conn()
+ |> assign(:user, u)
+ |> assign(:token, insert(:oauth_token, user: u, scopes: ["read:favourites"]))
+ |> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
+ |> json_response_and_validate_schema(:ok)
- assert length(response) == 1
+ assert length(response) == 1
+ end
- anonymous_response =
- conn
+ response =
+ build_conn()
|> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(200)
- assert Enum.empty?(anonymous_response)
+ assert length(response) == 0
end
test "does not return others' favorited DM when user is not one of recipients", %{
conn: conn,
- current_user: current_user,
user: user
} do
user_two = insert(:user)
{:ok, direct} =
CommonAPI.post(user_two, %{
- "status" => "Hi @#{user.nickname}!",
- "visibility" => "direct"
+ status: "Hi @#{user.nickname}!",
+ visibility: "direct"
})
- CommonAPI.favorite(direct.id, user)
+ CommonAPI.favorite(user, direct.id)
response =
conn
- |> assign(:user, current_user)
|> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
test "paginates favorites using since_id and max_id", %{
conn: conn,
- current_user: current_user,
user: user
} do
activities = insert_list(10, :note_activity)
Enum.each(activities, fn activity ->
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
end)
third_activity = Enum.at(activities, 2)
@@ -263,12 +272,12 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
response =
conn
- |> assign(:user, current_user)
- |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{
- since_id: third_activity.id,
- max_id: seventh_activity.id
- })
- |> json_response(:ok)
+ |> get(
+ "/api/v1/pleroma/accounts/#{user.id}/favourites?since_id=#{third_activity.id}&max_id=#{
+ seventh_activity.id
+ }"
+ )
+ |> json_response_and_validate_schema(:ok)
assert length(response) == 3
refute third_activity in response
@@ -277,34 +286,30 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
test "limits favorites using limit parameter", %{
conn: conn,
- current_user: current_user,
user: user
} do
7
|> insert_list(:note_activity)
|> Enum.each(fn activity ->
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
end)
response =
conn
- |> assign(:user, current_user)
- |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{limit: "3"})
- |> json_response(:ok)
+ |> get("/api/v1/pleroma/accounts/#{user.id}/favourites?limit=3")
+ |> json_response_and_validate_schema(:ok)
assert length(response) == 3
end
test "returns empty response when user does not have any favorited statuses", %{
conn: conn,
- current_user: current_user,
user: user
} do
response =
conn
- |> assign(:user, current_user)
|> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
- |> json_response(:ok)
+ |> json_response_and_validate_schema(:ok)
assert Enum.empty?(response)
end
@@ -312,84 +317,67 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do
test "returns 404 error when specified user is not exist", %{conn: conn} do
conn = get(conn, "/api/v1/pleroma/accounts/test/favourites")
- assert json_response(conn, 404) == %{"error" => "Record not found"}
+ assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
- test "returns 403 error when user has hidden own favorites", %{
- conn: conn,
- current_user: current_user
- } do
- user = insert(:user, %{info: %{hide_favorites: true}})
+ test "returns 403 error when user has hidden own favorites", %{conn: conn} do
+ user = insert(:user, hide_favorites: true)
activity = insert(:note_activity)
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
- conn =
- conn
- |> assign(:user, current_user)
- |> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
+ conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites")
- assert json_response(conn, 403) == %{"error" => "Can't get favorites"}
+ assert json_response_and_validate_schema(conn, 403) == %{"error" => "Can't get favorites"}
end
- test "hides favorites for new users by default", %{conn: conn, current_user: current_user} do
+ test "hides favorites for new users by default", %{conn: conn} do
user = insert(:user)
activity = insert(:note_activity)
- CommonAPI.favorite(activity.id, user)
+ CommonAPI.favorite(user, activity.id)
- conn =
- conn
- |> assign(:user, current_user)
- |> get("/api/v1/pleroma/accounts/#{user.id}/favourites")
+ assert user.hide_favorites
+ conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites")
- assert user.info.hide_favorites
- assert json_response(conn, 403) == %{"error" => "Can't get favorites"}
+ assert json_response_and_validate_schema(conn, 403) == %{"error" => "Can't get favorites"}
end
end
describe "subscribing / unsubscribing" do
- test "subscribing / unsubscribing to a user", %{conn: conn} do
- user = insert(:user)
+ test "subscribing / unsubscribing to a user" do
+ %{user: user, conn: conn} = oauth_access(["follow"])
subscription_target = insert(:user)
- conn =
+ ret_conn =
conn
|> assign(:user, user)
|> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe")
- assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200)
+ assert %{"id" => _id, "subscribing" => true} =
+ json_response_and_validate_schema(ret_conn, 200)
- conn =
- build_conn()
- |> assign(:user, user)
- |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe")
+ conn = post(conn, "/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe")
- assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200)
+ assert %{"id" => _id, "subscribing" => false} = json_response_and_validate_schema(conn, 200)
end
end
describe "subscribing" do
- test "returns 404 when subscription_target not found", %{conn: conn} do
- user = insert(:user)
+ test "returns 404 when subscription_target not found" do
+ %{conn: conn} = oauth_access(["write:follows"])
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/pleroma/accounts/target_id/subscribe")
+ conn = post(conn, "/api/v1/pleroma/accounts/target_id/subscribe")
- assert %{"error" => "Record not found"} = json_response(conn, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404)
end
end
describe "unsubscribing" do
- test "returns 404 when subscription_target not found", %{conn: conn} do
- user = insert(:user)
+ test "returns 404 when subscription_target not found" do
+ %{conn: conn} = oauth_access(["follow"])
- conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/pleroma/accounts/target_id/unsubscribe")
+ conn = post(conn, "/api/v1/pleroma/accounts/target_id/unsubscribe")
- assert %{"error" => "Record not found"} = json_response(conn, 404)
+ assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404)
end
end
end
diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs
index 5f74460e8..d343256fe 100644
--- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs
+++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs
@@ -1,204 +1,298 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
use Pleroma.Web.ConnCase
import Tesla.Mock
-
import Pleroma.Factory
- @emoji_dir_path Path.join(
- Pleroma.Config.get!([:instance, :static_dir]),
- "emoji"
- )
-
- test "shared & non-shared pack information in list_packs is ok" do
- conn = build_conn()
- resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200)
+ @emoji_path Path.join(
+ Pleroma.Config.get!([:instance, :static_dir]),
+ "emoji"
+ )
+ setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false)
- assert Map.has_key?(resp, "test_pack")
+ setup do
+ admin = insert(:user, is_admin: true)
+ token = insert(:oauth_admin_token, user: admin)
- pack = resp["test_pack"]
+ admin_conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, token)
- assert Map.has_key?(pack["pack"], "download-sha256")
- assert pack["pack"]["can-download"]
+ Pleroma.Emoji.reload()
+ {:ok, %{admin_conn: admin_conn}}
+ end
- assert pack["files"] == %{"blank" => "blank.png"}
+ test "GET /api/pleroma/emoji/packs", %{conn: conn} do
+ resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200)
- # Non-shared pack
+ shared = resp["test_pack"]
+ assert shared["files"] == %{"blank" => "blank.png"}
+ assert Map.has_key?(shared["pack"], "download-sha256")
+ assert shared["pack"]["can-download"]
+ assert shared["pack"]["share-files"]
- assert Map.has_key?(resp, "test_pack_nonshared")
+ non_shared = resp["test_pack_nonshared"]
+ assert non_shared["pack"]["share-files"] == false
+ assert non_shared["pack"]["can-download"] == false
+ end
- pack = resp["test_pack_nonshared"]
+ describe "GET /api/pleroma/emoji/packs/remote" do
+ test "shareable instance", %{admin_conn: admin_conn, conn: conn} do
+ resp =
+ conn
+ |> get("/api/pleroma/emoji/packs")
+ |> json_response(200)
- refute pack["pack"]["shared"]
- refute pack["pack"]["can-download"]
- end
+ mock(fn
+ %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
- test "listing remote packs" do
- admin = insert(:user, info: %{is_admin: true})
- conn = build_conn() |> assign(:user, admin)
+ %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: ["shareable_emoji_packs"]}})
- resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200)
+ %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} ->
+ json(resp)
+ end)
- mock(fn
- %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
- json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
+ assert admin_conn
+ |> get("/api/pleroma/emoji/packs/remote", %{
+ url: "https://example.com"
+ })
+ |> json_response(200) == resp
+ end
- %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
- json(%{metadata: %{features: ["shareable_emoji_packs"]}})
+ test "non shareable instance", %{admin_conn: admin_conn} do
+ mock(fn
+ %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
- %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} ->
- json(resp)
- end)
+ %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: []}})
+ end)
- assert conn
- |> post(emoji_api_path(conn, :list_from), %{instance_address: "https://example.com"})
- |> json_response(200) == resp
+ assert admin_conn
+ |> get("/api/pleroma/emoji/packs/remote", %{url: "https://example.com"})
+ |> json_response(500) == %{
+ "error" => "The requested instance does not support sharing emoji packs"
+ }
+ end
end
- test "downloading a shared pack from download_shared" do
- conn = build_conn()
+ describe "GET /api/pleroma/emoji/packs/:name/archive" do
+ test "download shared pack", %{conn: conn} do
+ resp =
+ conn
+ |> get("/api/pleroma/emoji/packs/test_pack/archive")
+ |> response(200)
+
+ {:ok, arch} = :zip.unzip(resp, [:memory])
- resp =
- conn
- |> get(emoji_api_path(conn, :download_shared, "test_pack"))
- |> response(200)
+ assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end)
+ assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end)
+ end
- {:ok, arch} = :zip.unzip(resp, [:memory])
+ test "non existing pack", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/emoji/packs/test_pack_for_import/archive")
+ |> json_response(:not_found) == %{
+ "error" => "Pack test_pack_for_import does not exist"
+ }
+ end
- assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end)
- assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end)
+ test "non downloadable pack", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/emoji/packs/test_pack_nonshared/archive")
+ |> json_response(:forbidden) == %{
+ "error" =>
+ "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing"
+ }
+ end
end
- test "downloading shared & unshared packs from another instance via download_from, deleting them" do
- on_exit(fn ->
- File.rm_rf!("#{@emoji_dir_path}/test_pack2")
- File.rm_rf!("#{@emoji_dir_path}/test_pack_nonshared2")
- end)
+ describe "POST /api/pleroma/emoji/packs/download" do
+ test "shared pack from remote and non shared from fallback-src", %{
+ admin_conn: admin_conn,
+ conn: conn
+ } do
+ mock(fn
+ %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
- mock(fn
- %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} ->
- json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]})
+ %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: ["shareable_emoji_packs"]}})
- %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} ->
- json(%{metadata: %{features: []}})
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/test_pack"
+ } ->
+ conn
+ |> get("/api/pleroma/emoji/packs/test_pack")
+ |> json_response(200)
+ |> json()
- %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
- json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/test_pack/archive"
+ } ->
+ conn
+ |> get("/api/pleroma/emoji/packs/test_pack/archive")
+ |> response(200)
+ |> text()
- %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
- json(%{metadata: %{features: ["shareable_emoji_packs"]}})
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/test_pack_nonshared"
+ } ->
+ conn
+ |> get("/api/pleroma/emoji/packs/test_pack_nonshared")
+ |> json_response(200)
+ |> json()
- %{
- method: :get,
- url: "https://example.com/api/pleroma/emoji/packs/list"
- } ->
- conn = build_conn()
+ %{
+ method: :get,
+ url: "https://nonshared-pack"
+ } ->
+ text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip"))
+ end)
- conn
- |> get(emoji_api_path(conn, :list_packs))
- |> json_response(200)
- |> json()
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/download", %{
+ url: "https://example.com",
+ name: "test_pack",
+ as: "test_pack2"
+ })
+ |> json_response(200) == "ok"
- %{
- method: :get,
- url: "https://example.com/api/pleroma/emoji/packs/download_shared/test_pack"
- } ->
- conn = build_conn()
+ assert File.exists?("#{@emoji_path}/test_pack2/pack.json")
+ assert File.exists?("#{@emoji_path}/test_pack2/blank.png")
- conn
- |> get(emoji_api_path(conn, :download_shared, "test_pack"))
- |> response(200)
- |> text()
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/test_pack2")
+ |> json_response(200) == "ok"
- %{
- method: :get,
- url: "https://nonshared-pack"
- } ->
- text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip"))
- end)
+ refute File.exists?("#{@emoji_path}/test_pack2")
- admin = insert(:user, info: %{is_admin: true})
-
- conn = build_conn() |> assign(:user, admin)
-
- assert (conn
- |> put_req_header("content-type", "application/json")
- |> post(
- emoji_api_path(
- conn,
- :download_from
- ),
- %{
- instance_address: "https://old-instance",
- pack_name: "test_pack",
- as: "test_pack2"
- }
- |> Jason.encode!()
- )
- |> json_response(500))["error"] =~ "does not support"
-
- assert conn
- |> put_req_header("content-type", "application/json")
- |> post(
- emoji_api_path(
- conn,
- :download_from
- ),
- %{
- instance_address: "https://example.com",
- pack_name: "test_pack",
- as: "test_pack2"
+ assert admin_conn
+ |> post(
+ "/api/pleroma/emoji/packs/download",
+ %{
+ url: "https://example.com",
+ name: "test_pack_nonshared",
+ as: "test_pack_nonshared2"
+ }
+ )
+ |> json_response(200) == "ok"
+
+ assert File.exists?("#{@emoji_path}/test_pack_nonshared2/pack.json")
+ assert File.exists?("#{@emoji_path}/test_pack_nonshared2/blank.png")
+
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/test_pack_nonshared2")
+ |> json_response(200) == "ok"
+
+ refute File.exists?("#{@emoji_path}/test_pack_nonshared2")
+ end
+
+ test "nonshareable instance", %{admin_conn: admin_conn} do
+ mock(fn
+ %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]})
+
+ %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: []}})
+ end)
+
+ assert admin_conn
+ |> post(
+ "/api/pleroma/emoji/packs/download",
+ %{
+ url: "https://old-instance",
+ name: "test_pack",
+ as: "test_pack2"
+ }
+ )
+ |> json_response(500) == %{
+ "error" => "The requested instance does not support sharing emoji packs"
}
- |> Jason.encode!()
- )
- |> json_response(200) == "ok"
-
- assert File.exists?("#{@emoji_dir_path}/test_pack2/pack.json")
- assert File.exists?("#{@emoji_dir_path}/test_pack2/blank.png")
-
- assert conn
- |> delete(emoji_api_path(conn, :delete, "test_pack2"))
- |> json_response(200) == "ok"
-
- refute File.exists?("#{@emoji_dir_path}/test_pack2")
-
- # non-shared, downloaded from the fallback URL
-
- conn = build_conn() |> assign(:user, admin)
-
- assert conn
- |> put_req_header("content-type", "application/json")
- |> post(
- emoji_api_path(
- conn,
- :download_from
- ),
- %{
- instance_address: "https://example.com",
- pack_name: "test_pack_nonshared",
- as: "test_pack_nonshared2"
+ end
+
+ test "checksum fail", %{admin_conn: admin_conn} do
+ mock(fn
+ %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
+
+ %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: ["shareable_emoji_packs"]}})
+
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: Pleroma.Emoji.Pack.load_pack("pack_bad_sha") |> Jason.encode!()
+ }
+
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/archive"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip")
+ }
+ end)
+
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/download", %{
+ url: "https://example.com",
+ name: "pack_bad_sha",
+ as: "pack_bad_sha2"
+ })
+ |> json_response(:internal_server_error) == %{
+ "error" => "SHA256 for the pack doesn't match the one sent by the server"
}
- |> Jason.encode!()
- )
- |> json_response(200) == "ok"
+ end
+
+ test "other error", %{admin_conn: admin_conn} do
+ mock(fn
+ %{method: :get, url: "https://example.com/.well-known/nodeinfo"} ->
+ json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]})
- assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/pack.json")
- assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/blank.png")
+ %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} ->
+ json(%{metadata: %{features: ["shareable_emoji_packs"]}})
- assert conn
- |> delete(emoji_api_path(conn, :delete, "test_pack_nonshared2"))
- |> json_response(200) == "ok"
+ %{
+ method: :get,
+ url: "https://example.com/api/pleroma/emoji/packs/test_pack"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: Pleroma.Emoji.Pack.load_pack("test_pack") |> Jason.encode!()
+ }
+ end)
- refute File.exists?("#{@emoji_dir_path}/test_pack_nonshared2")
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/download", %{
+ url: "https://example.com",
+ name: "test_pack",
+ as: "test_pack2"
+ })
+ |> json_response(:internal_server_error) == %{
+ "error" =>
+ "The pack was not set as shared and there is no fallback src to download from"
+ }
+ end
end
- describe "updating pack metadata" do
+ describe "PATCH /api/pleroma/emoji/packs/:name" do
setup do
- pack_file = "#{@emoji_dir_path}/test_pack/pack.json"
+ pack_file = "#{@emoji_path}/test_pack/pack.json"
original_content = File.read!(pack_file)
on_exit(fn ->
@@ -206,7 +300,6 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
end)
{:ok,
- admin: insert(:user, info: %{is_admin: true}),
pack_file: pack_file,
new_data: %{
"license" => "Test license changed",
@@ -217,16 +310,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
end
test "for a pack without a fallback source", ctx do
- conn = build_conn()
-
- assert conn
- |> assign(:user, ctx[:admin])
- |> post(
- emoji_api_path(conn, :update_metadata, "test_pack"),
- %{
- "new_data" => ctx[:new_data]
- }
- )
+ assert ctx[:admin_conn]
+ |> patch("/api/pleroma/emoji/packs/test_pack", %{"metadata" => ctx[:new_data]})
|> json_response(200) == ctx[:new_data]
assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data]
@@ -238,7 +323,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
method: :get,
url: "https://nonshared-pack"
} ->
- text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip"))
+ text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip"))
end)
new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack")
@@ -250,16 +335,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
"74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF"
)
- conn = build_conn()
-
- assert conn
- |> assign(:user, ctx[:admin])
- |> post(
- emoji_api_path(conn, :update_metadata, "test_pack"),
- %{
- "new_data" => new_data
- }
- )
+ assert ctx[:admin_conn]
+ |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data})
|> json_response(200) == new_data_with_sha
assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha
@@ -277,187 +354,377 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do
new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack")
- conn = build_conn()
-
- assert (conn
- |> assign(:user, ctx[:admin])
- |> post(
- emoji_api_path(conn, :update_metadata, "test_pack"),
- %{
- "new_data" => new_data
- }
- )
- |> json_response(:bad_request))["error"] =~ "does not have all"
+ assert ctx[:admin_conn]
+ |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data})
+ |> json_response(:bad_request) == %{
+ "error" => "The fallback archive does not have all files specified in pack.json"
+ }
end
end
- test "updating pack files" do
- pack_file = "#{@emoji_dir_path}/test_pack/pack.json"
- original_content = File.read!(pack_file)
+ describe "POST/PATCH/DELETE /api/pleroma/emoji/packs/:name/files" do
+ setup do
+ pack_file = "#{@emoji_path}/test_pack/pack.json"
+ original_content = File.read!(pack_file)
- on_exit(fn ->
- File.write!(pack_file, original_content)
+ on_exit(fn ->
+ File.write!(pack_file, original_content)
+ end)
- File.rm_rf!("#{@emoji_dir_path}/test_pack/blank_url.png")
- File.rm_rf!("#{@emoji_dir_path}/test_pack/dir")
- File.rm_rf!("#{@emoji_dir_path}/test_pack/dir_2")
- end)
+ :ok
+ end
- admin = insert(:user, info: %{is_admin: true})
-
- conn = build_conn()
-
- same_name = %{
- "action" => "add",
- "shortcode" => "blank",
- "filename" => "dir/blank.png",
- "file" => %Plug.Upload{
- filename: "blank.png",
- path: "#{@emoji_dir_path}/test_pack/blank.png"
- }
- }
-
- different_name = %{same_name | "shortcode" => "blank_2"}
-
- conn = conn |> assign(:user, admin)
-
- assert (conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), same_name)
- |> json_response(:conflict))["error"] =~ "already exists"
-
- assert conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), different_name)
- |> json_response(200) == %{"blank" => "blank.png", "blank_2" => "dir/blank.png"}
-
- assert File.exists?("#{@emoji_dir_path}/test_pack/dir/blank.png")
-
- assert conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), %{
- "action" => "update",
- "shortcode" => "blank_2",
- "new_shortcode" => "blank_3",
- "new_filename" => "dir_2/blank_3.png"
- })
- |> json_response(200) == %{"blank" => "blank.png", "blank_3" => "dir_2/blank_3.png"}
-
- refute File.exists?("#{@emoji_dir_path}/test_pack/dir/")
- assert File.exists?("#{@emoji_dir_path}/test_pack/dir_2/blank_3.png")
-
- assert conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), %{
- "action" => "remove",
- "shortcode" => "blank_3"
- })
- |> json_response(200) == %{"blank" => "blank.png"}
-
- refute File.exists?("#{@emoji_dir_path}/test_pack/dir_2/")
-
- mock(fn
- %{
- method: :get,
- url: "https://test-blank/blank_url.png"
- } ->
- text(File.read!("#{@emoji_dir_path}/test_pack/blank.png"))
- end)
+ test "create shortcode exists", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank",
+ filename: "dir/blank.png",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(:conflict) == %{
+ "error" => "An emoji with the \"blank\" shortcode already exists"
+ }
+ end
- # The name should be inferred from the URL ending
- from_url = %{
- "action" => "add",
- "shortcode" => "blank_url",
- "file" => "https://test-blank/blank_url.png"
- }
+ test "don't rewrite old emoji", %{admin_conn: admin_conn} do
+ on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end)
- assert conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), from_url)
- |> json_response(200) == %{
- "blank" => "blank.png",
- "blank_url" => "blank_url.png"
- }
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank2",
+ filename: "dir/blank.png",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"}
+
+ assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png")
+
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank",
+ new_shortcode: "blank2",
+ new_filename: "dir_2/blank_3.png"
+ })
+ |> json_response(:conflict) == %{
+ "error" =>
+ "New shortcode \"blank2\" is already used. If you want to override emoji use 'force' option"
+ }
+ end
+
+ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do
+ on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end)
+
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank2",
+ filename: "dir/blank.png",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"}
+
+ assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png")
+
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank2",
+ new_shortcode: "blank3",
+ new_filename: "dir_2/blank_3.png",
+ force: true
+ })
+ |> json_response(200) == %{
+ "blank" => "blank.png",
+ "blank3" => "dir_2/blank_3.png"
+ }
+
+ assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png")
+ end
+
+ test "with empty filename", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank2",
+ filename: "",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(:bad_request) == %{
+ "error" => "pack name, shortcode or filename cannot be empty"
+ }
+ end
+
+ test "add file with not loaded pack", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/not_loaded/files", %{
+ shortcode: "blank2",
+ filename: "dir/blank.png",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(:bad_request) == %{
+ "error" => "pack \"not_loaded\" is not found"
+ }
+ end
+
+ test "remove file with not loaded pack", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: "blank3"})
+ |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"}
+ end
+
+ test "remove file with empty shortcode", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: ""})
+ |> json_response(:bad_request) == %{
+ "error" => "pack name or shortcode cannot be empty"
+ }
+ end
+
+ test "update file with not loaded pack", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/not_loaded/files", %{
+ shortcode: "blank4",
+ new_shortcode: "blank3",
+ new_filename: "dir_2/blank_3.png"
+ })
+ |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"}
+ end
+
+ test "new with shortcode as file with update", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank4",
+ filename: "dir/blank.png",
+ file: %Plug.Upload{
+ filename: "blank.png",
+ path: "#{@emoji_path}/test_pack/blank.png"
+ }
+ })
+ |> json_response(200) == %{"blank" => "blank.png", "blank4" => "dir/blank.png"}
+
+ assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png")
+
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank4",
+ new_shortcode: "blank3",
+ new_filename: "dir_2/blank_3.png"
+ })
+ |> json_response(200) == %{"blank3" => "dir_2/blank_3.png", "blank" => "blank.png"}
- assert File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png")
+ refute File.exists?("#{@emoji_path}/test_pack/dir/")
+ assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png")
- assert conn
- |> post(emoji_api_path(conn, :update_file, "test_pack"), %{
- "action" => "remove",
- "shortcode" => "blank_url"
- })
- |> json_response(200) == %{"blank" => "blank.png"}
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank3"})
+ |> json_response(200) == %{"blank" => "blank.png"}
- refute File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png")
+ refute File.exists?("#{@emoji_path}/test_pack/dir_2/")
+
+ on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir") end)
+ end
+
+ test "new with shortcode from url", %{admin_conn: admin_conn} do
+ mock(fn
+ %{
+ method: :get,
+ url: "https://test-blank/blank_url.png"
+ } ->
+ text(File.read!("#{@emoji_path}/test_pack/blank.png"))
+ end)
+
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank_url",
+ file: "https://test-blank/blank_url.png"
+ })
+ |> json_response(200) == %{
+ "blank_url" => "blank_url.png",
+ "blank" => "blank.png"
+ }
+
+ assert File.exists?("#{@emoji_path}/test_pack/blank_url.png")
+
+ on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/blank_url.png") end)
+ end
+
+ test "new without shortcode", %{admin_conn: admin_conn} do
+ on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end)
+
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_pack/files", %{
+ file: %Plug.Upload{
+ filename: "shortcode.png",
+ path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png"
+ }
+ })
+ |> json_response(200) == %{"shortcode" => "shortcode.png", "blank" => "blank.png"}
+ end
+
+ test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank2"})
+ |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"}
+ end
+
+ test "update non existing emoji", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank2",
+ new_shortcode: "blank3",
+ new_filename: "dir_2/blank_3.png"
+ })
+ |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"}
+ end
+
+ test "update with empty shortcode", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> patch("/api/pleroma/emoji/packs/test_pack/files", %{
+ shortcode: "blank",
+ new_filename: "dir_2/blank_3.png"
+ })
+ |> json_response(:bad_request) == %{
+ "error" => "new_shortcode or new_filename cannot be empty"
+ }
+ end
end
- test "creating and deleting a pack" do
- on_exit(fn ->
- File.rm_rf!("#{@emoji_dir_path}/test_created")
- end)
+ describe "POST/DELETE /api/pleroma/emoji/packs/:name" do
+ test "creating and deleting a pack", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_created")
+ |> json_response(200) == "ok"
- admin = insert(:user, info: %{is_admin: true})
+ assert File.exists?("#{@emoji_path}/test_created/pack.json")
- conn = build_conn() |> assign(:user, admin)
+ assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{
+ "pack" => %{},
+ "files" => %{}
+ }
- assert conn
- |> put_req_header("content-type", "application/json")
- |> put(
- emoji_api_path(
- conn,
- :create,
- "test_created"
- )
- )
- |> json_response(200) == "ok"
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/test_created")
+ |> json_response(200) == "ok"
- assert File.exists?("#{@emoji_dir_path}/test_created/pack.json")
+ refute File.exists?("#{@emoji_path}/test_created/pack.json")
+ end
- assert Jason.decode!(File.read!("#{@emoji_dir_path}/test_created/pack.json")) == %{
- "pack" => %{},
- "files" => %{}
- }
+ test "if pack exists", %{admin_conn: admin_conn} do
+ path = Path.join(@emoji_path, "test_created")
+ File.mkdir(path)
+ pack_file = Jason.encode!(%{files: %{}, pack: %{}})
+ File.write!(Path.join(path, "pack.json"), pack_file)
+
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/test_created")
+ |> json_response(:conflict) == %{
+ "error" => "A pack named \"test_created\" already exists"
+ }
+
+ on_exit(fn -> File.rm_rf(path) end)
+ end
+
+ test "with empty name", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> post("/api/pleroma/emoji/packs/ ")
+ |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"}
+ end
+ end
- assert conn
- |> delete(emoji_api_path(conn, :delete, "test_created"))
- |> json_response(200) == "ok"
+ test "deleting nonexisting pack", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/non_existing")
+ |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"}
+ end
- refute File.exists?("#{@emoji_dir_path}/test_created/pack.json")
+ test "deleting with empty name", %{admin_conn: admin_conn} do
+ assert admin_conn
+ |> delete("/api/pleroma/emoji/packs/ ")
+ |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"}
end
- test "filesystem import" do
+ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do
on_exit(fn ->
- File.rm!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt")
- File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json")
+ File.rm!("#{@emoji_path}/test_pack_for_import/emoji.txt")
+ File.rm!("#{@emoji_path}/test_pack_for_import/pack.json")
end)
- conn = build_conn()
- resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200)
+ resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200)
refute Map.has_key?(resp, "test_pack_for_import")
- admin = insert(:user, info: %{is_admin: true})
-
- assert conn
- |> assign(:user, admin)
- |> post(emoji_api_path(conn, :import_from_fs))
+ assert admin_conn
+ |> get("/api/pleroma/emoji/packs/import")
|> json_response(200) == ["test_pack_for_import"]
- resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200)
+ resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200)
assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"}
- File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json")
- refute File.exists?("#{@emoji_dir_path}/test_pack_for_import/pack.json")
+ File.rm!("#{@emoji_path}/test_pack_for_import/pack.json")
+ refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json")
- emoji_txt_content = "blank, blank.png, Fun\n\nblank2, blank.png"
+ emoji_txt_content = """
+ blank, blank.png, Fun
+ blank2, blank.png
+ foo, /emoji/test_pack_for_import/blank.png
+ bar
+ """
- File.write!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt", emoji_txt_content)
+ File.write!("#{@emoji_path}/test_pack_for_import/emoji.txt", emoji_txt_content)
- assert conn
- |> assign(:user, admin)
- |> post(emoji_api_path(conn, :import_from_fs))
+ assert admin_conn
+ |> get("/api/pleroma/emoji/packs/import")
|> json_response(200) == ["test_pack_for_import"]
- resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200)
+ resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200)
assert resp["test_pack_for_import"]["files"] == %{
"blank" => "blank.png",
- "blank2" => "blank.png"
+ "blank2" => "blank.png",
+ "foo" => "blank.png"
}
end
+
+ describe "GET /api/pleroma/emoji/packs/:name" do
+ test "shows pack.json", %{conn: conn} do
+ assert %{
+ "files" => %{"blank" => "blank.png"},
+ "pack" => %{
+ "can-download" => true,
+ "description" => "Test description",
+ "download-sha256" => _,
+ "homepage" => "https://pleroma.social",
+ "license" => "Test license",
+ "share-files" => true
+ }
+ } =
+ conn
+ |> get("/api/pleroma/emoji/packs/test_pack")
+ |> json_response(200)
+ end
+
+ test "non existing pack", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/emoji/packs/non_existing")
+ |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"}
+ end
+
+ test "error name", %{conn: conn} do
+ assert conn
+ |> get("/api/pleroma/emoji/packs/ ")
+ |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"}
+ end
+ end
end
diff --git a/test/web/pleroma_api/controllers/mascot_controller_test.exs b/test/web/pleroma_api/controllers/mascot_controller_test.exs
index ae9539b04..617831b02 100644
--- a/test/web/pleroma_api/controllers/mascot_controller_test.exs
+++ b/test/web/pleroma_api/controllers/mascot_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do
@@ -7,10 +7,8 @@ defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do
alias Pleroma.User
- import Pleroma.Factory
-
- test "mascot upload", %{conn: conn} do
- user = insert(:user)
+ test "mascot upload" do
+ %{conn: conn} = oauth_access(["write:accounts"])
non_image_file = %Plug.Upload{
content_type: "audio/mpeg",
@@ -18,12 +16,9 @@ defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do
filename: "sound.mp3"
}
- conn =
- conn
- |> assign(:user, user)
- |> put("/api/v1/pleroma/mascot", %{"file" => non_image_file})
+ ret_conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => non_image_file})
- assert json_response(conn, 415)
+ assert json_response(ret_conn, 415)
file = %Plug.Upload{
content_type: "image/jpg",
@@ -31,23 +26,18 @@ defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do
filename: "an_image.jpg"
}
- conn =
- build_conn()
- |> assign(:user, user)
- |> put("/api/v1/pleroma/mascot", %{"file" => file})
+ conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => file})
assert %{"id" => _, "type" => image} = json_response(conn, 200)
end
- test "mascot retrieving", %{conn: conn} do
- user = insert(:user)
+ test "mascot retrieving" do
+ %{user: user, conn: conn} = oauth_access(["read:accounts", "write:accounts"])
+
# When user hasn't set a mascot, we should just get pleroma tan back
- conn =
- conn
- |> assign(:user, user)
- |> get("/api/v1/pleroma/mascot")
+ ret_conn = get(conn, "/api/v1/pleroma/mascot")
- assert %{"url" => url} = json_response(conn, 200)
+ assert %{"url" => url} = json_response(ret_conn, 200)
assert url =~ "pleroma-fox-tan-smol"
# When a user sets their mascot, we should get that back
@@ -57,17 +47,14 @@ defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do
filename: "an_image.jpg"
}
- conn =
- build_conn()
- |> assign(:user, user)
- |> put("/api/v1/pleroma/mascot", %{"file" => file})
+ ret_conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => file})
- assert json_response(conn, 200)
+ assert json_response(ret_conn, 200)
user = User.get_cached_by_id(user.id)
conn =
- build_conn()
+ conn
|> assign(:user, user)
|> get("/api/v1/pleroma/mascot")
diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs
index 9cccc8c8a..cfd1dbd24 100644
--- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs
+++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs
@@ -1,59 +1,172 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
+ use Oban.Testing, repo: Pleroma.Repo
use Pleroma.Web.ConnCase
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
+ alias Pleroma.Object
alias Pleroma.Repo
+ alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
- test "/api/v1/pleroma/conversations/:id", %{conn: conn} do
+ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+
+ result =
+ conn
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+ |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕")
+ |> json_response(200)
+
+ # We return the status, but this our implementation detail.
+ assert %{"id" => id} = result
+ assert to_string(activity.id) == id
+
+ assert result["pleroma"]["emoji_reactions"] == [
+ %{"name" => "☕", "count" => 1, "me" => true}
+ ]
+ end
+
+ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+ {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+ ObanHelpers.perform_all()
+
+ result =
+ conn
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+ |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕")
+
+ assert %{"id" => id} = json_response(result, 200)
+ assert to_string(activity.id) == id
+
+ ObanHelpers.perform_all()
+
+ object = Object.get_by_ap_id(activity.data["object"])
+
+ assert object.data["reaction_count"] == 0
+ end
+
+ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do
+ user = insert(:user)
+ other_user = insert(:user)
+ doomed_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+
+ result =
+ conn
+ |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions")
+ |> json_response(200)
+
+ assert result == []
+
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, doomed_user, "🎅")
+
+ User.perform(:delete, doomed_user)
+
+ result =
+ conn
+ |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions")
+ |> json_response(200)
+
+ [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result
+
+ assert represented_user["id"] == other_user.id
+
+ result =
+ conn
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:statuses"]))
+ |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions")
+ |> json_response(200)
+
+ assert [%{"name" => "🎅", "count" => 1, "accounts" => [_represented_user], "me" => true}] =
+ result
+ end
+
+ test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do
+ user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+
+ result =
+ conn
+ |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅")
+ |> json_response(200)
+
+ assert result == []
+
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
+ {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+ result =
+ conn
+ |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅")
+ |> json_response(200)
+
+ [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result
+
+ assert represented_user["id"] == other_user.id
+ end
+
+ test "/api/v1/pleroma/conversations/:id" do
+ user = insert(:user)
+ %{user: other_user, conn: conn} = oauth_access(["read:statuses"])
+
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"})
[participation] = Participation.for_user(other_user)
result =
conn
- |> assign(:user, other_user)
|> get("/api/v1/pleroma/conversations/#{participation.id}")
|> json_response(200)
assert result["id"] == participation.id |> to_string()
end
- test "/api/v1/pleroma/conversations/:id/statuses", %{conn: conn} do
+ test "/api/v1/pleroma/conversations/:id/statuses" do
user = insert(:user)
- other_user = insert(:user)
+ %{user: other_user, conn: conn} = oauth_access(["read:statuses"])
third_user = insert(:user)
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "Hi @#{third_user.nickname}!", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hi @#{third_user.nickname}!", visibility: "direct"})
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"})
[participation] = Participation.for_user(other_user)
{:ok, activity_two} =
CommonAPI.post(other_user, %{
- "status" => "Hi!",
- "in_reply_to_status_id" => activity.id,
- "in_reply_to_conversation_id" => participation.id
+ status: "Hi!",
+ in_reply_to_status_id: activity.id,
+ in_reply_to_conversation_id: participation.id
})
result =
conn
- |> assign(:user, other_user)
|> get("/api/v1/pleroma/conversations/#{participation.id}/statuses")
|> json_response(200)
@@ -62,13 +175,30 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
id_one = activity.id
id_two = activity_two.id
assert [%{"id" => ^id_one}, %{"id" => ^id_two}] = result
+
+ {:ok, %{id: id_three}} =
+ CommonAPI.post(other_user, %{
+ status: "Bye!",
+ in_reply_to_status_id: activity.id,
+ in_reply_to_conversation_id: participation.id
+ })
+
+ assert [%{"id" => ^id_two}, %{"id" => ^id_three}] =
+ conn
+ |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?limit=2")
+ |> json_response(:ok)
+
+ assert [%{"id" => ^id_three}] =
+ conn
+ |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?min_id=#{id_two}")
+ |> json_response(:ok)
end
- test "PATCH /api/v1/pleroma/conversations/:id", %{conn: conn} do
- user = insert(:user)
+ test "PATCH /api/v1/pleroma/conversations/:id" do
+ %{user: user, conn: conn} = oauth_access(["write:conversations"])
other_user = insert(:user)
- {:ok, _activity} = CommonAPI.post(user, %{"status" => "Hi", "visibility" => "direct"})
+ {:ok, _activity} = CommonAPI.post(user, %{status: "Hi", visibility: "direct"})
[participation] = Participation.for_user(user)
@@ -80,7 +210,6 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
result =
conn
- |> assign(:user, user)
|> patch("/api/v1/pleroma/conversations/#{participation.id}", %{
"recipients" => [user.id, other_user.id]
})
@@ -95,45 +224,44 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
assert other_user in participation.recipients
end
- test "POST /api/v1/pleroma/conversations/read", %{conn: conn} do
+ test "POST /api/v1/pleroma/conversations/read" do
user = insert(:user)
- other_user = insert(:user)
+ %{user: other_user, conn: conn} = oauth_access(["write:conversations"])
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"})
{:ok, _activity} =
- CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"})
+ CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"})
[participation2, participation1] = Participation.for_user(other_user)
assert Participation.get(participation2.id).read == false
assert Participation.get(participation1.id).read == false
- assert User.get_cached_by_id(other_user.id).info.unread_conversation_count == 2
+ assert User.get_cached_by_id(other_user.id).unread_conversation_count == 2
[%{"unread" => false}, %{"unread" => false}] =
conn
- |> assign(:user, other_user)
|> post("/api/v1/pleroma/conversations/read", %{})
|> json_response(200)
[participation2, participation1] = Participation.for_user(other_user)
assert Participation.get(participation2.id).read == true
assert Participation.get(participation1.id).read == true
- assert User.get_cached_by_id(other_user.id).info.unread_conversation_count == 0
+ assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0
end
describe "POST /api/v1/pleroma/notifications/read" do
- test "it marks a single notification as read", %{conn: conn} do
- user1 = insert(:user)
+ setup do: oauth_access(["write:notifications"])
+
+ test "it marks a single notification as read", %{user: user1, conn: conn} do
user2 = insert(:user)
- {:ok, activity1} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"})
- {:ok, activity2} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"})
+ {:ok, activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"})
+ {:ok, activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"})
{:ok, [notification1]} = Notification.create_notifications(activity1)
{:ok, [notification2]} = Notification.create_notifications(activity2)
response =
conn
- |> assign(:user, user1)
|> post("/api/v1/pleroma/notifications/read", %{"id" => "#{notification1.id}"})
|> json_response(:ok)
@@ -142,18 +270,16 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
refute Repo.get(Notification, notification2.id).seen
end
- test "it marks multiple notifications as read", %{conn: conn} do
- user1 = insert(:user)
+ test "it marks multiple notifications as read", %{user: user1, conn: conn} do
user2 = insert(:user)
- {:ok, _activity1} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"})
- {:ok, _activity2} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"})
- {:ok, _activity3} = CommonAPI.post(user2, %{"status" => "HIE @#{user1.nickname}"})
+ {:ok, _activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"})
+ {:ok, _activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"})
+ {:ok, _activity3} = CommonAPI.post(user2, %{status: "HIE @#{user1.nickname}"})
[notification3, notification2, notification1] = Notification.for_user(user1, %{limit: 3})
[response1, response2] =
conn
- |> assign(:user, user1)
|> post("/api/v1/pleroma/notifications/read", %{"max_id" => "#{notification2.id}"})
|> json_response(:ok)
@@ -165,11 +291,8 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
end
test "it returns error when notification not found", %{conn: conn} do
- user1 = insert(:user)
-
response =
conn
- |> assign(:user, user1)
|> post("/api/v1/pleroma/notifications/read", %{"id" => "22222222222222"})
|> json_response(:bad_request)
diff --git a/test/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/web/pleroma_api/controllers/scrobble_controller_test.exs
index 881f8012c..1b945040c 100644
--- a/test/web/pleroma_api/controllers/scrobble_controller_test.exs
+++ b/test/web/pleroma_api/controllers/scrobble_controller_test.exs
@@ -1,21 +1,18 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Web.CommonAPI
- import Pleroma.Factory
describe "POST /api/v1/pleroma/scrobble" do
- test "works correctly", %{conn: conn} do
- user = insert(:user)
+ test "works correctly" do
+ %{conn: conn} = oauth_access(["write"])
conn =
- conn
- |> assign(:user, user)
- |> post("/api/v1/pleroma/scrobble", %{
+ post(conn, "/api/v1/pleroma/scrobble", %{
"title" => "lain radio episode 1",
"artist" => "lain",
"album" => "lain radio",
@@ -27,8 +24,8 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do
end
describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do
- test "works correctly", %{conn: conn} do
- user = insert(:user)
+ test "works correctly" do
+ %{user: user, conn: conn} = oauth_access(["read"])
{:ok, _activity} =
CommonAPI.listen(user, %{
@@ -51,9 +48,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do
"album" => "lain radio"
})
- conn =
- conn
- |> get("/api/v1/pleroma/accounts/#{user.id}/scrobbles")
+ conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/scrobbles")
result = json_response(conn, 200)
diff --git a/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs
new file mode 100644
index 000000000..d23d08a00
--- /dev/null
+++ b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs
@@ -0,0 +1,260 @@
+defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+ alias Pleroma.MFA.Settings
+ alias Pleroma.MFA.TOTP
+
+ describe "GET /api/pleroma/accounts/mfa/settings" do
+ test "returns user mfa settings for new user", %{conn: conn} do
+ token = insert(:oauth_token, scopes: ["read", "follow"])
+ token2 = insert(:oauth_token, scopes: ["write"])
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> get("/api/pleroma/accounts/mfa")
+ |> json_response(:ok) == %{
+ "settings" => %{"enabled" => false, "totp" => false}
+ }
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> get("/api/pleroma/accounts/mfa")
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: read:security."
+ }
+ end
+
+ test "returns user mfa settings with enabled totp", %{conn: conn} do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ enabled: true,
+ totp: %Settings.TOTP{secret: "XXX", delivery_type: "app", confirmed: true}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["read", "follow"], user: user)
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> get("/api/pleroma/accounts/mfa")
+ |> json_response(:ok) == %{
+ "settings" => %{"enabled" => true, "totp" => true}
+ }
+ end
+ end
+
+ describe "GET /api/pleroma/accounts/mfa/backup_codes" do
+ test "returns backup codes", %{conn: conn} do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ backup_codes: ["1", "2", "3"],
+ totp: %Settings.TOTP{secret: "secret"}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+ token2 = insert(:oauth_token, scopes: ["read"])
+
+ response =
+ conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> get("/api/pleroma/accounts/mfa/backup_codes")
+ |> json_response(:ok)
+
+ assert [<<_::bytes-size(6)>>, <<_::bytes-size(6)>>] = response["codes"]
+ user = refresh_record(user)
+ mfa_settings = user.multi_factor_authentication_settings
+ assert mfa_settings.totp.secret == "secret"
+ refute mfa_settings.backup_codes == ["1", "2", "3"]
+ refute mfa_settings.backup_codes == []
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> get("/api/pleroma/accounts/mfa/backup_codes")
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: write:security."
+ }
+ end
+ end
+
+ describe "GET /api/pleroma/accounts/mfa/setup/totp" do
+ test "return errors when method is invalid", %{conn: conn} do
+ user = insert(:user)
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+ response =
+ conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> get("/api/pleroma/accounts/mfa/setup/torf")
+ |> json_response(400)
+
+ assert response == %{"error" => "undefined method"}
+ end
+
+ test "returns key and provisioning_uri", %{conn: conn} do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{backup_codes: ["1", "2", "3"]}
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+ token2 = insert(:oauth_token, scopes: ["read"])
+
+ response =
+ conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> get("/api/pleroma/accounts/mfa/setup/totp")
+ |> json_response(:ok)
+
+ user = refresh_record(user)
+ mfa_settings = user.multi_factor_authentication_settings
+ secret = mfa_settings.totp.secret
+ refute mfa_settings.enabled
+ assert mfa_settings.backup_codes == ["1", "2", "3"]
+
+ assert response == %{
+ "key" => secret,
+ "provisioning_uri" => TOTP.provisioning_uri(secret, "#{user.email}")
+ }
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> get("/api/pleroma/accounts/mfa/setup/totp")
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: write:security."
+ }
+ end
+ end
+
+ describe "GET /api/pleroma/accounts/mfa/confirm/totp" do
+ test "returns success result", %{conn: conn} do
+ secret = TOTP.generate_secret()
+ code = TOTP.generate_token(secret)
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ backup_codes: ["1", "2", "3"],
+ totp: %Settings.TOTP{secret: secret}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+ token2 = insert(:oauth_token, scopes: ["read"])
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+ |> json_response(:ok)
+
+ settings = refresh_record(user).multi_factor_authentication_settings
+ assert settings.enabled
+ assert settings.totp.secret == secret
+ assert settings.totp.confirmed
+ assert settings.backup_codes == ["1", "2", "3"]
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: write:security."
+ }
+ end
+
+ test "returns error if password incorrect", %{conn: conn} do
+ secret = TOTP.generate_secret()
+ code = TOTP.generate_token(secret)
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ backup_codes: ["1", "2", "3"],
+ totp: %Settings.TOTP{secret: secret}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+
+ response =
+ conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "xxx", code: code})
+ |> json_response(422)
+
+ settings = refresh_record(user).multi_factor_authentication_settings
+ refute settings.enabled
+ refute settings.totp.confirmed
+ assert settings.backup_codes == ["1", "2", "3"]
+ assert response == %{"error" => "Invalid password."}
+ end
+
+ test "returns error if code incorrect", %{conn: conn} do
+ secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ backup_codes: ["1", "2", "3"],
+ totp: %Settings.TOTP{secret: secret}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+ token2 = insert(:oauth_token, scopes: ["read"])
+
+ response =
+ conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+ |> json_response(422)
+
+ settings = refresh_record(user).multi_factor_authentication_settings
+ refute settings.enabled
+ refute settings.totp.confirmed
+ assert settings.backup_codes == ["1", "2", "3"]
+ assert response == %{"error" => "invalid_token"}
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: write:security."
+ }
+ end
+ end
+
+ describe "DELETE /api/pleroma/accounts/mfa/totp" do
+ test "returns success result", %{conn: conn} do
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %Settings{
+ backup_codes: ["1", "2", "3"],
+ totp: %Settings.TOTP{secret: "secret"}
+ }
+ )
+
+ token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
+ token2 = insert(:oauth_token, scopes: ["read"])
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token.token}")
+ |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+ |> json_response(:ok)
+
+ settings = refresh_record(user).multi_factor_authentication_settings
+ refute settings.enabled
+ assert settings.totp.secret == nil
+ refute settings.totp.confirmed
+
+ assert conn
+ |> put_req_header("authorization", "Bearer #{token2.token}")
+ |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
+ |> json_response(403) == %{
+ "error" => "Insufficient permissions: write:security."
+ }
+ end
+ end
+end
diff --git a/test/web/plugs/federating_plug_test.exs b/test/web/plugs/federating_plug_test.exs
index 9dcab93da..2f8aadadc 100644
--- a/test/web/plugs/federating_plug_test.exs
+++ b/test/web/plugs/federating_plug_test.exs
@@ -1,10 +1,11 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.FederatingPlugTest do
use Pleroma.Web.ConnCase
- clear_config_all([:instance, :federating])
+
+ setup do: clear_config([:instance, :federating])
test "returns and halt the conn when federating is disabled" do
Pleroma.Config.put([:instance, :federating], false)
diff --git a/test/web/plugs/plug_test.exs b/test/web/plugs/plug_test.exs
new file mode 100644
index 000000000..943e484e7
--- /dev/null
+++ b/test/web/plugs/plug_test.exs
@@ -0,0 +1,91 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PlugTest do
+ @moduledoc "Tests for the functionality added via `use Pleroma.Web, :plug`"
+
+ alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug
+ alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug
+ alias Pleroma.Plugs.PlugHelper
+
+ import Mock
+
+ use Pleroma.Web.ConnCase
+
+ describe "when plug is skipped, " do
+ setup_with_mocks(
+ [
+ {ExpectPublicOrAuthenticatedCheckPlug, [:passthrough], []}
+ ],
+ %{conn: conn}
+ ) do
+ conn = ExpectPublicOrAuthenticatedCheckPlug.skip_plug(conn)
+ %{conn: conn}
+ end
+
+ test "it neither adds plug to called plugs list nor calls `perform/2`, " <>
+ "regardless of :if_func / :unless_func options",
+ %{conn: conn} do
+ for opts <- [%{}, %{if_func: fn _ -> true end}, %{unless_func: fn _ -> false end}] do
+ ret_conn = ExpectPublicOrAuthenticatedCheckPlug.call(conn, opts)
+
+ refute called(ExpectPublicOrAuthenticatedCheckPlug.perform(:_, :_))
+ refute PlugHelper.plug_called?(ret_conn, ExpectPublicOrAuthenticatedCheckPlug)
+ end
+ end
+ end
+
+ describe "when plug is NOT skipped, " do
+ setup_with_mocks([{ExpectAuthenticatedCheckPlug, [:passthrough], []}]) do
+ :ok
+ end
+
+ test "with no pre-run checks, adds plug to called plugs list and calls `perform/2`", %{
+ conn: conn
+ } do
+ ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{})
+
+ assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+ assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+ end
+
+ test "when :if_func option is given, calls the plug only if provided function evals tru-ish",
+ %{conn: conn} do
+ ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> false end})
+
+ refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+ refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+ ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> true end})
+
+ assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+ assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+ end
+
+ test "if :unless_func option is given, calls the plug only if provided function evals falsy",
+ %{conn: conn} do
+ ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> true end})
+
+ refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_))
+ refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+
+ ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> false end})
+
+ assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_))
+ assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug)
+ end
+
+ test "allows a plug to be called multiple times (even if it's in called plugs list)", %{
+ conn: conn
+ } do
+ conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value1})
+ assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value1}))
+
+ assert PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug)
+
+ conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value2})
+ assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value2}))
+ end
+ end
+end
diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs
index 9b554601d..2acd0939f 100644
--- a/test/web/push/impl_test.exs
+++ b/test/web/push/impl_test.exs
@@ -1,19 +1,20 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Push.ImplTest do
use Pleroma.DataCase
alias Pleroma.Object
+ alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Push.Impl
alias Pleroma.Web.Push.Subscription
import Pleroma.Factory
- setup_all do
- Tesla.Mock.mock_global(fn
+ setup do
+ Tesla.Mock.mock(fn
%{method: :post, url: "https://example.com/example/1234"} ->
%Tesla.Env{status: 200}
@@ -54,7 +55,7 @@ defmodule Pleroma.Web.Push.ImplTest do
data: %{alerts: %{"follow" => true, "mention" => false}}
)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "<Lorem ipsum dolor sit amet."})
+ {:ok, activity} = CommonAPI.post(user, %{status: "<Lorem ipsum dolor sit amet."})
notif =
insert(:notification,
@@ -62,12 +63,12 @@ defmodule Pleroma.Web.Push.ImplTest do
activity: activity
)
- assert Impl.perform(notif) == [:ok, :ok]
+ assert Impl.perform(notif) == {:ok, [:ok, :ok]}
end
@tag capture_log: true
test "returns error if notif does not match " do
- assert Impl.perform(%{}) == :error
+ assert Impl.perform(%{}) == {:error, :unknown_type}
end
test "successful message sending" do
@@ -97,12 +98,20 @@ defmodule Pleroma.Web.Push.ImplTest do
refute Pleroma.Repo.get(Subscription, subscription.id)
end
+ test "deletes subscription when token has been deleted" do
+ subscription = insert(:push_subscription)
+
+ Pleroma.Repo.delete(subscription.token)
+
+ refute Pleroma.Repo.get(Subscription, subscription.id)
+ end
+
test "renders title and body for create activity" do
user = insert(:user, nickname: "Bob")
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."
})
@@ -125,7 +134,7 @@ defmodule Pleroma.Web.Push.ImplTest do
user = insert(:user, nickname: "Bob")
other_user = insert(:user)
{:ok, _, _, activity} = CommonAPI.follow(user, other_user)
- object = Object.normalize(activity)
+ object = Object.normalize(activity, false)
assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you"
@@ -138,7 +147,7 @@ defmodule Pleroma.Web.Push.ImplTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."
})
@@ -157,11 +166,11 @@ defmodule Pleroma.Web.Push.ImplTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" =>
+ status:
"<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."
})
- {:ok, activity, _} = CommonAPI.favorite(activity.id, user)
+ {:ok, activity} = CommonAPI.favorite(user, activity.id)
object = Object.normalize(activity)
assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post"
@@ -175,11 +184,112 @@ defmodule Pleroma.Web.Push.ImplTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "visibility" => "direct",
- "status" => "This is just between you and me, pal"
+ visibility: "direct",
+ status: "This is just between you and me, pal"
})
assert Impl.format_title(%{activity: activity}) ==
"New Direct Message"
end
+
+ describe "build_content/3" do
+ test "hides details for notifications when privacy option enabled" do
+ user = insert(:user, nickname: "Bob")
+ user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true})
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ visibility: "direct",
+ status: "<Lorem ipsum dolor sit amet."
+ })
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body: "New Direct Message"
+ }
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ visibility: "public",
+ status: "<Lorem ipsum dolor sit amet."
+ })
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body: "New Mention"
+ }
+
+ {:ok, activity} = CommonAPI.favorite(user, activity.id)
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body: "New Favorite"
+ }
+ end
+
+ test "returns regular content for notifications with privacy option disabled" do
+ user = insert(:user, nickname: "Bob")
+ user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: false})
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ visibility: "direct",
+ status:
+ "<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."
+ })
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body:
+ "@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini...",
+ title: "New Direct Message"
+ }
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ visibility: "public",
+ status:
+ "<span>Lorem ipsum dolor sit amet</span>, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis."
+ })
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body:
+ "@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini...",
+ title: "New Mention"
+ }
+
+ {:ok, activity} = CommonAPI.favorite(user, activity.id)
+
+ notif = insert(:notification, user: user2, activity: activity)
+
+ actor = User.get_cached_by_ap_id(notif.activity.data["actor"])
+ object = Object.normalize(activity)
+
+ assert Impl.build_content(notif, actor, object) == %{
+ body: "@Bob has favorited your post",
+ title: "New Favorite"
+ }
+ end
+ end
end
diff --git a/test/web/rel_me_test.exs b/test/web/rel_me_test.exs
index 2251fed16..65255916d 100644
--- a/test/web/rel_me_test.exs
+++ b/test/web/rel_me_test.exs
@@ -1,9 +1,9 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RelMeTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -14,7 +14,9 @@ defmodule Pleroma.Web.RelMeTest do
hrefs = ["https://social.example.org/users/lain"]
assert Pleroma.Web.RelMe.parse("http://example.com/rel_me/null") == {:ok, []}
- assert {:error, _} = Pleroma.Web.RelMe.parse("http://example.com/rel_me/error")
+
+ assert {:ok, %Tesla.Env{status: 404}} =
+ Pleroma.Web.RelMe.parse("http://example.com/rel_me/error")
assert Pleroma.Web.RelMe.parse("http://example.com/rel_me/link") == {:ok, hrefs}
assert Pleroma.Web.RelMe.parse("http://example.com/rel_me/anchor") == {:ok, hrefs}
diff --git a/test/web/rich_media/aws_signed_url_test.exs b/test/web/rich_media/aws_signed_url_test.exs
index a3a50cbb1..b30f4400e 100644
--- a/test/web/rich_media/aws_signed_url_test.exs
+++ b/test/web/rich_media/aws_signed_url_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.TTL.AwsSignedUrlTest do
diff --git a/test/web/rich_media/helpers_test.exs b/test/web/rich_media/helpers_test.exs
index 48884319d..8264a9c41 100644
--- a/test/web/rich_media/helpers_test.exs
+++ b/test/web/rich_media/helpers_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.HelpersTest do
@@ -19,15 +19,15 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
:ok
end
- clear_config([:rich_media, :enabled])
+ setup do: clear_config([:rich_media, :enabled])
test "refuses to crawl incomplete URLs" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "[test](example.com/ogp)",
- "content_type" => "text/markdown"
+ status: "[test](example.com/ogp)",
+ content_type: "text/markdown"
})
Config.put([:rich_media, :enabled], true)
@@ -40,8 +40,8 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "[test](example.com[]/ogp)",
- "content_type" => "text/markdown"
+ status: "[test](example.com[]/ogp)",
+ content_type: "text/markdown"
})
Config.put([:rich_media, :enabled], true)
@@ -54,8 +54,8 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "[test](https://example.com/ogp)",
- "content_type" => "text/markdown"
+ status: "[test](https://example.com/ogp)",
+ content_type: "text/markdown"
})
Config.put([:rich_media, :enabled], true)
@@ -69,8 +69,8 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "http://example.com/ogp",
- "sensitive" => true
+ status: "http://example.com/ogp",
+ sensitive: true
})
%Object{} = object = Object.normalize(activity)
@@ -87,7 +87,7 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
{:ok, activity} =
CommonAPI.post(user, %{
- "status" => "http://example.com/ogp #nsfw"
+ status: "http://example.com/ogp #nsfw"
})
%Object{} = object = Object.normalize(activity)
@@ -103,12 +103,12 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do
user = insert(:user)
{:ok, activity} =
- CommonAPI.post(user, %{"status" => "http://127.0.0.1:4000/notice/9kCP7VNyPJXFOXDrgO"})
+ CommonAPI.post(user, %{status: "http://127.0.0.1:4000/notice/9kCP7VNyPJXFOXDrgO"})
- {:ok, activity2} = CommonAPI.post(user, %{"status" => "https://10.111.10.1/notice/9kCP7V"})
- {:ok, activity3} = CommonAPI.post(user, %{"status" => "https://172.16.32.40/notice/9kCP7V"})
- {:ok, activity4} = CommonAPI.post(user, %{"status" => "https://192.168.10.40/notice/9kCP7V"})
- {:ok, activity5} = CommonAPI.post(user, %{"status" => "https://pleroma.local/notice/9kCP7V"})
+ {:ok, activity2} = CommonAPI.post(user, %{status: "https://10.111.10.1/notice/9kCP7V"})
+ {:ok, activity3} = CommonAPI.post(user, %{status: "https://172.16.32.40/notice/9kCP7V"})
+ {:ok, activity4} = CommonAPI.post(user, %{status: "https://192.168.10.40/notice/9kCP7V"})
+ {:ok, activity5} = CommonAPI.post(user, %{status: "https://pleroma.local/notice/9kCP7V"})
Config.put([:rich_media, :enabled], true)
diff --git a/test/web/rich_media/parser_test.exs b/test/web/rich_media/parser_test.exs
index b75bdf96f..e54a13bc8 100644
--- a/test/web/rich_media/parser_test.exs
+++ b/test/web/rich_media/parser_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.ParserTest do
diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs
index f8e1c9b40..87c767c15 100644
--- a/test/web/rich_media/parsers/twitter_card_test.exs
+++ b/test/web/rich_media/parsers/twitter_card_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
@@ -7,11 +7,14 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
alias Pleroma.Web.RichMedia.Parsers.TwitterCard
test "returns error when html not contains twitter card" do
- assert TwitterCard.parse("", %{}) == {:error, "No twitter card metadata found"}
+ assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) ==
+ {:error, "No twitter card metadata found"}
end
test "parses twitter card with only name attributes" do
- html = File.read!("test/fixtures/nypd-facial-recognition-children-teenagers3.html")
+ html =
+ File.read!("test/fixtures/nypd-facial-recognition-children-teenagers3.html")
+ |> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
{:ok,
@@ -26,7 +29,9 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
end
test "parses twitter card with only property attributes" do
- html = File.read!("test/fixtures/nypd-facial-recognition-children-teenagers2.html")
+ html =
+ File.read!("test/fixtures/nypd-facial-recognition-children-teenagers2.html")
+ |> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
{:ok,
@@ -45,7 +50,9 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
end
test "parses twitter card with name & property attributes" do
- html = File.read!("test/fixtures/nypd-facial-recognition-children-teenagers.html")
+ html =
+ File.read!("test/fixtures/nypd-facial-recognition-children-teenagers.html")
+ |> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
{:ok,
@@ -66,4 +73,41 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
"https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"
}}
end
+
+ test "respect only first title tag on the page" do
+ image_path =
+ "https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzkwYzgyMzI4LThlMDUtNGRiNS05MDg3LTUzMGUxZTM5N2RmMmVkOTM5ZDM4MGM4OTIx" <>
+ "YTQ5MF9EQVIgZXhodW1hdGlvbiBvZiBNYXJnYXJldCBDb3JiaW4gZ3JhdmUgMTkyNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9" <>
+ "yaWVudCJdLFsicCIsInRodW1iIiwiNjAweD4iXV0/DAR%20exhumation%20of%20Margaret%20Corbin%20grave%201926.jpg"
+
+ html =
+ File.read!("test/fixtures/margaret-corbin-grave-west-point.html") |> Floki.parse_document!()
+
+ assert TwitterCard.parse(html, %{}) ==
+ {:ok,
+ %{
+ site: "@atlasobscura",
+ title:
+ "The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura",
+ card: "summary_large_image",
+ image: image_path
+ }}
+ end
+
+ test "takes first founded title in html head if there is html markup error" do
+ html =
+ File.read!("test/fixtures/nypd-facial-recognition-children-teenagers4.html")
+ |> Floki.parse_document!()
+
+ assert TwitterCard.parse(html, %{}) ==
+ {:ok,
+ %{
+ site: nil,
+ title:
+ "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times",
+ "app:id:googleplay": "com.nytimes.android",
+ "app:name:googleplay": "NYTimes",
+ "app:url:googleplay": "nytimes://reader/id/100000006583622"
+ }}
+ end
end
diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs
new file mode 100644
index 000000000..a49ab002f
--- /dev/null
+++ b/test/web/static_fe/static_fe_controller_test.exs
@@ -0,0 +1,178 @@
+defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Activity
+ alias Pleroma.Config
+ alias Pleroma.Web.ActivityPub.Transmogrifier
+ alias Pleroma.Web.CommonAPI
+
+ import Pleroma.Factory
+
+ setup_all do: clear_config([:static_fe, :enabled], true)
+ setup do: clear_config([:instance, :federating], true)
+
+ setup %{conn: conn} do
+ conn = put_req_header(conn, "accept", "text/html")
+ user = insert(:user)
+
+ %{conn: conn, user: user}
+ end
+
+ describe "user profile html" do
+ test "just the profile as HTML", %{conn: conn, user: user} do
+ conn = get(conn, "/users/#{user.nickname}")
+
+ assert html_response(conn, 200) =~ user.nickname
+ end
+
+ test "404 when user not found", %{conn: conn} do
+ conn = get(conn, "/users/limpopo")
+
+ assert html_response(conn, 404) =~ "not found"
+ end
+
+ test "profile does not include private messages", %{conn: conn, user: user} do
+ CommonAPI.post(user, %{status: "public"})
+ CommonAPI.post(user, %{status: "private", visibility: "private"})
+
+ conn = get(conn, "/users/#{user.nickname}")
+
+ html = html_response(conn, 200)
+
+ assert html =~ ">public<"
+ refute html =~ ">private<"
+ end
+
+ test "pagination", %{conn: conn, user: user} do
+ Enum.map(1..30, fn i -> CommonAPI.post(user, %{status: "test#{i}"}) end)
+
+ conn = get(conn, "/users/#{user.nickname}")
+
+ html = html_response(conn, 200)
+
+ assert html =~ ">test30<"
+ assert html =~ ">test11<"
+ refute html =~ ">test10<"
+ refute html =~ ">test1<"
+ end
+
+ test "pagination, page 2", %{conn: conn, user: user} do
+ activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{status: "test#{i}"}) end)
+ {:ok, a11} = Enum.at(activities, 11)
+
+ conn = get(conn, "/users/#{user.nickname}?max_id=#{a11.id}")
+
+ html = html_response(conn, 200)
+
+ assert html =~ ">test1<"
+ assert html =~ ">test10<"
+ refute html =~ ">test20<"
+ refute html =~ ">test29<"
+ end
+
+ test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do
+ ensure_federating_or_authenticated(conn, "/users/#{user.nickname}", user)
+ end
+ end
+
+ describe "notice html" do
+ test "single notice page", %{conn: conn, user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"})
+
+ conn = get(conn, "/notice/#{activity.id}")
+
+ html = html_response(conn, 200)
+ assert html =~ "<header>"
+ assert html =~ user.nickname
+ assert html =~ "testing a thing!"
+ end
+
+ test "filters HTML tags", %{conn: conn} do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "<script>alert('xss')</script>"})
+
+ conn =
+ conn
+ |> put_req_header("accept", "text/html")
+ |> get("/notice/#{activity.id}")
+
+ html = html_response(conn, 200)
+ assert html =~ ~s[&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;]
+ end
+
+ test "shows the whole thread", %{conn: conn, user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "space: the final frontier"})
+
+ CommonAPI.post(user, %{
+ status: "these are the voyages or something",
+ in_reply_to_status_id: activity.id
+ })
+
+ conn = get(conn, "/notice/#{activity.id}")
+
+ html = html_response(conn, 200)
+ assert html =~ "the final frontier"
+ assert html =~ "voyages"
+ end
+
+ test "redirect by AP object ID", %{conn: conn, user: user} do
+ {:ok, %Activity{data: %{"object" => object_url}}} =
+ CommonAPI.post(user, %{status: "beam me up"})
+
+ conn = get(conn, URI.parse(object_url).path)
+
+ assert html_response(conn, 302) =~ "redirected"
+ end
+
+ test "redirect by activity ID", %{conn: conn, user: user} do
+ {:ok, %Activity{data: %{"id" => id}}} =
+ CommonAPI.post(user, %{status: "I'm a doctor, not a devops!"})
+
+ conn = get(conn, URI.parse(id).path)
+
+ assert html_response(conn, 302) =~ "redirected"
+ end
+
+ test "404 when notice not found", %{conn: conn} do
+ conn = get(conn, "/notice/88c9c317")
+
+ assert html_response(conn, 404) =~ "not found"
+ end
+
+ test "404 for private status", %{conn: conn, user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "don't show me!", visibility: "private"})
+
+ conn = get(conn, "/notice/#{activity.id}")
+
+ assert html_response(conn, 404) =~ "not found"
+ end
+
+ test "302 for remote cached status", %{conn: conn, user: user} do
+ message = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "to" => user.follower_address,
+ "cc" => "https://www.w3.org/ns/activitystreams#Public",
+ "type" => "Create",
+ "object" => %{
+ "content" => "blah blah blah",
+ "type" => "Note",
+ "attributedTo" => user.ap_id,
+ "inReplyTo" => nil
+ },
+ "actor" => user.ap_id
+ }
+
+ assert {:ok, activity} = Transmogrifier.handle_incoming(message)
+
+ conn = get(conn, "/notice/#{activity.id}")
+
+ assert html_response(conn, 302) =~ "redirected"
+ end
+
+ test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"})
+
+ ensure_federating_or_authenticated(conn, "/notice/#{activity.id}", user)
+ end
+ end
+end
diff --git a/test/web/streamer/ping_test.exs b/test/web/streamer/ping_test.exs
deleted file mode 100644
index 3d52c00e4..000000000
--- a/test/web/streamer/ping_test.exs
+++ /dev/null
@@ -1,36 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.PingTest do
- use Pleroma.DataCase
-
- import Pleroma.Factory
- alias Pleroma.Web.Streamer
-
- setup do
- start_supervised({Streamer.supervisor(), [ping_interval: 30]})
-
- :ok
- end
-
- describe "sockets" do
- setup do
- user = insert(:user)
- {:ok, %{user: user}}
- end
-
- test "it sends pings", %{user: user} do
- task =
- Task.async(fn ->
- assert_receive {:text, received_event}, 40
- assert_receive {:text, received_event}, 40
- assert_receive {:text, received_event}, 40
- end)
-
- Streamer.add_socket("public", %{transport_pid: task.pid, assigns: %{user: user}})
-
- Task.await(task)
- end
- end
-end
diff --git a/test/web/streamer/state_test.exs b/test/web/streamer/state_test.exs
deleted file mode 100644
index d1aeac541..000000000
--- a/test/web/streamer/state_test.exs
+++ /dev/null
@@ -1,54 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.StateTest do
- use Pleroma.DataCase
-
- import Pleroma.Factory
- alias Pleroma.Web.Streamer
- alias Pleroma.Web.Streamer.StreamerSocket
-
- @moduletag needs_streamer: true
-
- describe "sockets" do
- setup do
- user = insert(:user)
- user2 = insert(:user)
- {:ok, %{user: user, user2: user2}}
- end
-
- test "it can add a socket", %{user: user} do
- Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
-
- assert(%{"public" => [%StreamerSocket{transport_pid: 1}]} = Streamer.get_sockets())
- end
-
- test "it can add multiple sockets per user", %{user: user} do
- Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
- Streamer.add_socket("public", %{transport_pid: 2, assigns: %{user: user}})
-
- assert(
- %{
- "public" => [
- %StreamerSocket{transport_pid: 2},
- %StreamerSocket{transport_pid: 1}
- ]
- } = Streamer.get_sockets()
- )
- end
-
- test "it will not add a duplicate socket", %{user: user} do
- Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
- Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
-
- assert(
- %{
- "activity" => [
- %StreamerSocket{transport_pid: 1}
- ]
- } = Streamer.get_sockets()
- )
- end
- end
-end
diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs
index d33eb1e42..95b7d1420 100644
--- a/test/web/streamer/streamer_test.exs
+++ b/test/web/streamer/streamer_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.StreamerTest do
@@ -7,15 +7,85 @@ defmodule Pleroma.Web.StreamerTest do
import Pleroma.Factory
+ alias Pleroma.Conversation.Participation
alias Pleroma.List
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Streamer
- alias Pleroma.Web.Streamer.StreamerSocket
- alias Pleroma.Web.Streamer.Worker
- @moduletag needs_streamer: true
- clear_config_all([:instance, :skip_thread_containment])
+ @moduletag needs_streamer: true, capture_log: true
+
+ setup do: clear_config([:instance, :skip_thread_containment])
+
+ describe "get_topic without an user" do
+ test "allows public" do
+ assert {:ok, "public"} = Streamer.get_topic("public", nil)
+ assert {:ok, "public:local"} = Streamer.get_topic("public:local", nil)
+ assert {:ok, "public:media"} = Streamer.get_topic("public:media", nil)
+ assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", nil)
+ end
+
+ test "allows hashtag streams" do
+ assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", nil, %{"tag" => "cofe"})
+ end
+
+ test "disallows user streams" do
+ assert {:error, _} = Streamer.get_topic("user", nil)
+ assert {:error, _} = Streamer.get_topic("user:notification", nil)
+ assert {:error, _} = Streamer.get_topic("direct", nil)
+ end
+
+ test "disallows list streams" do
+ assert {:error, _} = Streamer.get_topic("list", nil, %{"list" => 42})
+ end
+ end
+
+ describe "get_topic with an user" do
+ setup do
+ user = insert(:user)
+ {:ok, %{user: user}}
+ end
+
+ test "allows public streams", %{user: user} do
+ assert {:ok, "public"} = Streamer.get_topic("public", user)
+ assert {:ok, "public:local"} = Streamer.get_topic("public:local", user)
+ assert {:ok, "public:media"} = Streamer.get_topic("public:media", user)
+ assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", user)
+ end
+
+ test "allows user streams", %{user: user} do
+ expected_user_topic = "user:#{user.id}"
+ expected_notif_topic = "user:notification:#{user.id}"
+ expected_direct_topic = "direct:#{user.id}"
+ assert {:ok, ^expected_user_topic} = Streamer.get_topic("user", user)
+ assert {:ok, ^expected_notif_topic} = Streamer.get_topic("user:notification", user)
+ assert {:ok, ^expected_direct_topic} = Streamer.get_topic("direct", user)
+ end
+
+ test "allows hashtag streams", %{user: user} do
+ assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", user, %{"tag" => "cofe"})
+ end
+
+ test "disallows registering to an user stream", %{user: user} do
+ another_user = insert(:user)
+ assert {:error, _} = Streamer.get_topic("user:#{another_user.id}", user)
+ assert {:error, _} = Streamer.get_topic("user:notification:#{another_user.id}", user)
+ assert {:error, _} = Streamer.get_topic("direct:#{another_user.id}", user)
+ end
+
+ test "allows list stream that are owned by the user", %{user: user} do
+ {:ok, list} = List.create("Test", user)
+ assert {:error, _} = Streamer.get_topic("list:#{list.id}", user)
+ assert {:ok, _} = Streamer.get_topic("list", user, %{"list" => list.id})
+ end
+
+ test "disallows list stream that are not owned by the user", %{user: user} do
+ another_user = insert(:user)
+ {:ok, list} = List.create("Test", another_user)
+ assert {:error, _} = Streamer.get_topic("list:#{list.id}", user)
+ assert {:error, _} = Streamer.get_topic("list", user, %{"list" => list.id})
+ end
+ end
describe "user streams" do
setup do
@@ -24,152 +94,167 @@ defmodule Pleroma.Web.StreamerTest do
{:ok, %{user: user, notify: notify}}
end
- test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do
- task =
- Task.async(fn ->
- assert_receive {:text, _}, 4_000
- end)
+ test "it streams the user's post in the 'user' stream", %{user: user} do
+ Streamer.get_topic_and_add_socket("user", user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
+ assert_receive {:render_with_user, _, _, ^activity}
+ refute Streamer.filtered_by_user?(user, activity)
+ end
+
+ test "it streams boosts of the user in the 'user' stream", %{user: user} do
+ Streamer.get_topic_and_add_socket("user", user)
- Streamer.add_socket(
- "user",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
+ {:ok, announce, _} = CommonAPI.repeat(activity.id, user)
+
+ assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+ refute Streamer.filtered_by_user?(user, announce)
+ end
+ test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do
+ Streamer.get_topic_and_add_socket("user", user)
Streamer.stream("user", notify)
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^notify}
+ refute Streamer.filtered_by_user?(user, notify)
end
test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do
- task =
- Task.async(fn ->
- assert_receive {:text, _}, 4_000
- end)
-
- Streamer.add_socket(
- "user:notification",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
-
+ Streamer.get_topic_and_add_socket("user:notification", user)
Streamer.stream("user:notification", notify)
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^notify}
+ refute Streamer.filtered_by_user?(user, notify)
end
test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{
user: user
} do
blocked = insert(:user)
- {:ok, user} = User.block(user, blocked)
-
- task = Task.async(fn -> refute_receive {:text, _}, 4_000 end)
+ {:ok, _user_relationship} = User.block(user, blocked)
- Streamer.add_socket(
- "user:notification",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ Streamer.get_topic_and_add_socket("user:notification", user)
- {:ok, activity} = CommonAPI.post(user, %{"status" => ":("})
- {:ok, notif, _} = CommonAPI.favorite(activity.id, blocked)
+ {:ok, activity} = CommonAPI.post(user, %{status: ":("})
+ {:ok, _} = CommonAPI.favorite(blocked, activity.id)
- Streamer.stream("user:notification", notif)
- Task.await(task)
+ refute_receive _
end
test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{
user: user
} do
user2 = insert(:user)
- task = Task.async(fn -> refute_receive {:text, _}, 4_000 end)
- Streamer.add_socket(
- "user:notification",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
+ {:ok, _} = CommonAPI.add_mute(user, activity)
- {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
- {:ok, activity} = CommonAPI.add_mute(user, activity)
- {:ok, notif, _} = CommonAPI.favorite(activity.id, user2)
- Streamer.stream("user:notification", notif)
- Task.await(task)
+ Streamer.get_topic_and_add_socket("user:notification", user)
+
+ {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
+
+ refute_receive _
+ assert Streamer.filtered_by_user?(user, favorite_activity)
end
- test "it doesn't send notify to the 'user:notification' stream' when a domain is blocked", %{
+ test "it sends favorite to 'user:notification' stream'", %{
user: user
} do
user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
- task = Task.async(fn -> refute_receive {:text, _}, 4_000 end)
- Streamer.add_socket(
- "user:notification",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
+ Streamer.get_topic_and_add_socket("user:notification", user)
+ {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
+
+ assert_receive {:render_with_user, _, "notification.json", notif}
+ assert notif.activity.id == favorite_activity.id
+ refute Streamer.filtered_by_user?(user, notif)
+ end
+
+ test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{
+ user: user
+ } do
+ user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
{:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
- {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
- {:ok, notif, _} = CommonAPI.favorite(activity.id, user2)
+ {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
+ Streamer.get_topic_and_add_socket("user:notification", user)
+ {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
- Streamer.stream("user:notification", notif)
- Task.await(task)
+ refute_receive _
+ assert Streamer.filtered_by_user?(user, favorite_activity)
end
- end
- test "it sends to public" do
- user = insert(:user)
- other_user = insert(:user)
+ test "it sends follow activities to the 'user:notification' stream", %{
+ user: user
+ } do
+ user_url = user.ap_id
+ user2 = insert(:user)
- task =
- Task.async(fn ->
- assert_receive {:text, _}, 4_000
+ body =
+ File.read!("test/fixtures/users_mock/localhost.json")
+ |> String.replace("{{nickname}}", user.nickname)
+ |> Jason.encode!()
+
+ Tesla.Mock.mock_global(fn
+ %{method: :get, url: ^user_url} ->
+ %Tesla.Env{status: 200, body: body}
end)
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user
- }
+ Streamer.get_topic_and_add_socket("user:notification", user)
+ {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
- {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
+ assert_receive {:render_with_user, _, "notification.json", notif}
+ assert notif.activity.id == follow_activity.id
+ refute Streamer.filtered_by_user?(user, notif)
+ end
+ end
- topics = %{
- "public" => [fake_socket]
- }
+ test "it sends to public authenticated" do
+ user = insert(:user)
+ other_user = insert(:user)
- Worker.push_to_socket(topics, "public", activity)
+ Streamer.get_topic_and_add_socket("public", other_user)
- Task.await(task)
+ {:ok, activity} = CommonAPI.post(user, %{status: "Test"})
+ assert_receive {:render_with_user, _, _, ^activity}
+ refute Streamer.filtered_by_user?(user, activity)
+ end
- task =
- Task.async(fn ->
- expected_event =
- %{
- "event" => "delete",
- "payload" => activity.id
- }
- |> Jason.encode!()
+ test "works for deletions" do
+ user = insert(:user)
+ other_user = insert(:user)
+ {:ok, activity} = CommonAPI.post(other_user, %{status: "Test"})
- assert_receive {:text, received_event}, 4_000
- assert received_event == expected_event
- end)
+ Streamer.get_topic_and_add_socket("public", user)
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user
- }
+ {:ok, _} = CommonAPI.delete(activity.id, other_user)
+ activity_id = activity.id
+ assert_receive {:text, event}
+ assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
+ end
- {:ok, activity} = CommonAPI.delete(activity.id, other_user)
+ test "it sends to public unauthenticated" do
+ user = insert(:user)
- topics = %{
- "public" => [fake_socket]
- }
+ Streamer.get_topic_and_add_socket("public", nil)
- Worker.push_to_socket(topics, "public", activity)
+ {:ok, activity} = CommonAPI.post(user, %{status: "Test"})
+ activity_id = activity.id
+ assert_receive {:text, event}
+ assert %{"event" => "update", "payload" => payload} = Jason.decode!(event)
+ assert %{"id" => ^activity_id} = Jason.decode!(payload)
- Task.await(task)
+ {:ok, _} = CommonAPI.delete(activity.id, user)
+ assert_receive {:text, event}
+ assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
describe "thread_containment" do
- test "it doesn't send to user if recipients invalid and thread containment is enabled" do
+ test "it filters to user if recipients invalid and thread containment is enabled" do
Pleroma.Config.put([:instance, :skip_thread_containment], false)
author = insert(:user)
- user = insert(:user, following: [author.ap_id])
+ user = insert(:user)
+ User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
@@ -180,18 +265,17 @@ defmodule Pleroma.Web.StreamerTest do
)
)
- task = Task.async(fn -> refute_receive {:text, _}, 1_000 end)
- fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
- topics = %{"public" => [fake_socket]}
- Worker.push_to_socket(topics, "public", activity)
-
- Task.await(task)
+ Streamer.get_topic_and_add_socket("public", user)
+ Streamer.stream("public", activity)
+ assert_receive {:render_with_user, _, _, ^activity}
+ assert Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is disabled" do
Pleroma.Config.put([:instance, :skip_thread_containment], true)
author = insert(:user)
- user = insert(:user, following: [author.ap_id])
+ user = insert(:user)
+ User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
@@ -202,18 +286,18 @@ defmodule Pleroma.Web.StreamerTest do
)
)
- task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
- fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
- topics = %{"public" => [fake_socket]}
- Worker.push_to_socket(topics, "public", activity)
+ Streamer.get_topic_and_add_socket("public", user)
+ Streamer.stream("public", activity)
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^activity}
+ refute Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do
Pleroma.Config.put([:instance, :skip_thread_containment], false)
author = insert(:user)
- user = insert(:user, following: [author.ap_id], info: %{skip_thread_containment: true})
+ user = insert(:user, skip_thread_containment: true)
+ User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
@@ -224,229 +308,168 @@ defmodule Pleroma.Web.StreamerTest do
)
)
- task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
- fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
- topics = %{"public" => [fake_socket]}
- Worker.push_to_socket(topics, "public", activity)
+ Streamer.get_topic_and_add_socket("public", user)
+ Streamer.stream("public", activity)
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^activity}
+ refute Streamer.filtered_by_user?(user, activity)
end
end
describe "blocks" do
- test "it doesn't send messages involving blocked users" do
+ test "it filters messages involving blocked users" do
user = insert(:user)
blocked_user = insert(:user)
- {:ok, user} = User.block(user, blocked_user)
-
- task =
- Task.async(fn ->
- refute_receive {:text, _}, 1_000
- end)
-
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user
- }
-
- {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"})
+ {:ok, _user_relationship} = User.block(user, blocked_user)
- topics = %{
- "public" => [fake_socket]
- }
-
- Worker.push_to_socket(topics, "public", activity)
-
- Task.await(task)
+ Streamer.get_topic_and_add_socket("public", user)
+ {:ok, activity} = CommonAPI.post(blocked_user, %{status: "Test"})
+ assert_receive {:render_with_user, _, _, ^activity}
+ assert Streamer.filtered_by_user?(user, activity)
end
- test "it doesn't send messages transitively involving blocked users" do
+ test "it filters messages transitively involving blocked users" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
- task =
- Task.async(fn ->
- refute_receive {:text, _}, 1_000
- end)
-
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: blocker
- }
+ Streamer.get_topic_and_add_socket("public", blocker)
- topics = %{
- "public" => [fake_socket]
- }
+ {:ok, _user_relationship} = User.block(blocker, blockee)
- {:ok, blocker} = User.block(blocker, blockee)
+ {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
- {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"})
+ assert_receive {:render_with_user, _, _, ^activity_one}
+ assert Streamer.filtered_by_user?(blocker, activity_one)
- Worker.push_to_socket(topics, "public", activity_one)
+ {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
- {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
+ assert_receive {:render_with_user, _, _, ^activity_two}
+ assert Streamer.filtered_by_user?(blocker, activity_two)
- Worker.push_to_socket(topics, "public", activity_two)
+ {:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
- {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"})
-
- Worker.push_to_socket(topics, "public", activity_three)
-
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^activity_three}
+ assert Streamer.filtered_by_user?(blocker, activity_three)
end
end
- test "it doesn't send unwanted DMs to list" do
- user_a = insert(:user)
- user_b = insert(:user)
- user_c = insert(:user)
+ describe "lists" do
+ test "it doesn't send unwanted DMs to list" do
+ user_a = insert(:user)
+ user_b = insert(:user)
+ user_c = insert(:user)
- {:ok, user_a} = User.follow(user_a, user_b)
+ {:ok, user_a} = User.follow(user_a, user_b)
- {:ok, list} = List.create("Test", user_a)
- {:ok, list} = List.follow(list, user_b)
+ {:ok, list} = List.create("Test", user_a)
+ {:ok, list} = List.follow(list, user_b)
- task =
- Task.async(fn ->
- refute_receive {:text, _}, 1_000
- end)
+ Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id})
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user_a
- }
+ {:ok, _activity} =
+ CommonAPI.post(user_b, %{
+ status: "@#{user_c.nickname} Test",
+ visibility: "direct"
+ })
- {:ok, activity} =
- CommonAPI.post(user_b, %{
- "status" => "@#{user_c.nickname} Test",
- "visibility" => "direct"
- })
+ refute_receive _
+ end
- topics = %{
- "list:#{list.id}" => [fake_socket]
- }
+ test "it doesn't send unwanted private posts to list" do
+ user_a = insert(:user)
+ user_b = insert(:user)
- Worker.handle_call({:stream, "list", activity}, self(), topics)
+ {:ok, list} = List.create("Test", user_a)
+ {:ok, list} = List.follow(list, user_b)
- Task.await(task)
- end
+ Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id})
- test "it doesn't send unwanted private posts to list" do
- user_a = insert(:user)
- user_b = insert(:user)
+ {:ok, _activity} =
+ CommonAPI.post(user_b, %{
+ status: "Test",
+ visibility: "private"
+ })
- {:ok, list} = List.create("Test", user_a)
- {:ok, list} = List.follow(list, user_b)
+ refute_receive _
+ end
- task =
- Task.async(fn ->
- refute_receive {:text, _}, 1_000
- end)
+ test "it sends wanted private posts to list" do
+ user_a = insert(:user)
+ user_b = insert(:user)
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user_a
- }
+ {:ok, user_a} = User.follow(user_a, user_b)
- {:ok, activity} =
- CommonAPI.post(user_b, %{
- "status" => "Test",
- "visibility" => "private"
- })
+ {:ok, list} = List.create("Test", user_a)
+ {:ok, list} = List.follow(list, user_b)
- topics = %{
- "list:#{list.id}" => [fake_socket]
- }
+ Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id})
- Worker.handle_call({:stream, "list", activity}, self(), topics)
+ {:ok, activity} =
+ CommonAPI.post(user_b, %{
+ status: "Test",
+ visibility: "private"
+ })
- Task.await(task)
+ assert_receive {:render_with_user, _, _, ^activity}
+ refute Streamer.filtered_by_user?(user_a, activity)
+ end
end
- test "it sends wanted private posts to list" do
- user_a = insert(:user)
- user_b = insert(:user)
-
- {:ok, user_a} = User.follow(user_a, user_b)
-
- {:ok, list} = List.create("Test", user_a)
- {:ok, list} = List.follow(list, user_b)
-
- task =
- Task.async(fn ->
- assert_receive {:text, _}, 1_000
- end)
-
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user_a
- }
-
- {:ok, activity} =
- CommonAPI.post(user_b, %{
- "status" => "Test",
- "visibility" => "private"
- })
-
- Streamer.add_socket(
- "list:#{list.id}",
- fake_socket
- )
-
- Worker.handle_call({:stream, "list", activity}, self(), %{})
+ describe "muted reblogs" do
+ test "it filters muted reblogs" do
+ user1 = insert(:user)
+ user2 = insert(:user)
+ user3 = insert(:user)
+ CommonAPI.follow(user1, user2)
+ CommonAPI.hide_reblogs(user1, user2)
- Task.await(task)
- end
+ {:ok, create_activity} = CommonAPI.post(user3, %{status: "I'm kawen"})
- test "it doesn't send muted reblogs" do
- user1 = insert(:user)
- user2 = insert(:user)
- user3 = insert(:user)
- CommonAPI.hide_reblogs(user1, user2)
+ Streamer.get_topic_and_add_socket("user", user1)
+ {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
+ assert_receive {:render_with_user, _, _, ^announce_activity}
+ assert Streamer.filtered_by_user?(user1, announce_activity)
+ end
- task =
- Task.async(fn ->
- refute_receive {:text, _}, 1_000
- end)
+ test "it filters reblog notification for reblog-muted actors" do
+ user1 = insert(:user)
+ user2 = insert(:user)
+ CommonAPI.follow(user1, user2)
+ CommonAPI.hide_reblogs(user1, user2)
- fake_socket = %StreamerSocket{
- transport_pid: task.pid,
- user: user1
- }
+ {:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"})
+ Streamer.get_topic_and_add_socket("user", user1)
+ {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2)
- {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
- {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
+ assert_receive {:render_with_user, _, "notification.json", notif}
+ assert Streamer.filtered_by_user?(user1, notif)
+ end
- topics = %{
- "public" => [fake_socket]
- }
+ test "it send non-reblog notification for reblog-muted actors" do
+ user1 = insert(:user)
+ user2 = insert(:user)
+ CommonAPI.follow(user1, user2)
+ CommonAPI.hide_reblogs(user1, user2)
- Worker.push_to_socket(topics, "public", announce_activity)
+ {:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"})
+ Streamer.get_topic_and_add_socket("user", user1)
+ {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
- Task.await(task)
+ assert_receive {:render_with_user, _, "notification.json", notif}
+ refute Streamer.filtered_by_user?(user1, notif)
+ end
end
- test "it doesn't send posts from muted threads" do
+ test "it filters posts from muted threads" do
user = insert(:user)
user2 = insert(:user)
+ Streamer.get_topic_and_add_socket("user", user2)
{:ok, user2, user, _activity} = CommonAPI.follow(user2, user)
-
- {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
-
- {:ok, activity} = CommonAPI.add_mute(user2, activity)
-
- task = Task.async(fn -> refute_receive {:text, _}, 4_000 end)
-
- Process.sleep(4000)
-
- Streamer.add_socket(
- "user",
- %{transport_pid: task.pid, assigns: %{user: user2}}
- )
-
- Streamer.stream("user", activity)
- Task.await(task)
+ {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
+ {:ok, _} = CommonAPI.add_mute(user2, activity)
+ assert_receive {:render_with_user, _, _, ^activity}
+ assert Streamer.filtered_by_user?(user2, activity)
end
describe "direct streams" do
@@ -458,96 +481,88 @@ defmodule Pleroma.Web.StreamerTest do
user = insert(:user)
another_user = insert(:user)
- task =
- Task.async(fn ->
- assert_receive {:text, _received_event}, 4_000
- end)
-
- Streamer.add_socket(
- "direct",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ Streamer.get_topic_and_add_socket("direct", user)
{:ok, _create_activity} =
CommonAPI.post(another_user, %{
- "status" => "hey @#{user.nickname}",
- "visibility" => "direct"
+ status: "hey @#{user.nickname}",
+ visibility: "direct"
})
- Task.await(task)
+ assert_receive {:text, received_event}
+
+ assert %{"event" => "conversation", "payload" => received_payload} =
+ Jason.decode!(received_event)
+
+ assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+ [participation] = Participation.for_user(user)
+ assert last_status["pleroma"]["direct_conversation_id"] == participation.id
end
- test "it doesn't send conversation update to the 'direct' streamj when the last message in the conversation is deleted" do
+ test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted" do
user = insert(:user)
another_user = insert(:user)
+ Streamer.get_topic_and_add_socket("direct", user)
+
{:ok, create_activity} =
CommonAPI.post(another_user, %{
- "status" => "hi @#{user.nickname}",
- "visibility" => "direct"
+ status: "hi @#{user.nickname}",
+ visibility: "direct"
})
- task =
- Task.async(fn ->
- assert_receive {:text, received_event}, 4_000
- assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
+ create_activity_id = create_activity.id
+ assert_receive {:render_with_user, _, _, ^create_activity}
+ assert_receive {:text, received_conversation1}
+ assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
- refute_receive {:text, _}, 4_000
- end)
+ {:ok, _} = CommonAPI.delete(create_activity_id, another_user)
- Process.sleep(1000)
+ assert_receive {:text, received_event}
- Streamer.add_socket(
- "direct",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ assert %{"event" => "delete", "payload" => ^create_activity_id} =
+ Jason.decode!(received_event)
- {:ok, _} = CommonAPI.delete(create_activity.id, another_user)
-
- Task.await(task)
+ refute_receive _
end
test "it sends conversation update to the 'direct' stream when a message is deleted" do
user = insert(:user)
another_user = insert(:user)
+ Streamer.get_topic_and_add_socket("direct", user)
{:ok, create_activity} =
CommonAPI.post(another_user, %{
- "status" => "hi @#{user.nickname}",
- "visibility" => "direct"
+ status: "hi @#{user.nickname}",
+ visibility: "direct"
})
{:ok, create_activity2} =
CommonAPI.post(another_user, %{
- "status" => "hi @#{user.nickname}",
- "in_reply_to_status_id" => create_activity.id,
- "visibility" => "direct"
+ status: "hi @#{user.nickname} 2",
+ in_reply_to_status_id: create_activity.id,
+ visibility: "direct"
})
- task =
- Task.async(fn ->
- assert_receive {:text, received_event}, 4_000
- assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
-
- assert_receive {:text, received_event}, 4_000
+ assert_receive {:render_with_user, _, _, ^create_activity}
+ assert_receive {:render_with_user, _, _, ^create_activity2}
+ assert_receive {:text, received_conversation1}
+ assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
+ assert_receive {:text, received_conversation1}
+ assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
- assert %{"event" => "conversation", "payload" => received_payload} =
- Jason.decode!(received_event)
-
- assert %{"last_status" => last_status} = Jason.decode!(received_payload)
- assert last_status["id"] == to_string(create_activity.id)
- end)
+ {:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
- Process.sleep(1000)
+ assert_receive {:text, received_event}
+ assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
- Streamer.add_socket(
- "direct",
- %{transport_pid: task.pid, assigns: %{user: user}}
- )
+ assert_receive {:text, received_event}
- {:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
+ assert %{"event" => "conversation", "payload" => received_payload} =
+ Jason.decode!(received_event)
- Task.await(task)
+ assert %{"last_status" => last_status} = Jason.decode!(received_payload)
+ assert last_status["id"] == to_string(create_activity.id)
end
end
end
diff --git a/test/web/twitter_api/password_controller_test.exs b/test/web/twitter_api/password_controller_test.exs
index dc6d4e3e3..231a46c67 100644
--- a/test/web/twitter_api/password_controller_test.exs
+++ b/test/web/twitter_api/password_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do
@@ -54,12 +54,12 @@ defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do
assert response =~ "<h2>Password changed!</h2>"
user = refresh_record(user)
- assert Comeonin.Pbkdf2.checkpw("test", user.password_hash)
- assert length(Token.get_user_tokens(user)) == 0
+ assert Pbkdf2.verify_pass("test", user.password_hash)
+ assert Enum.empty?(Token.get_user_tokens(user))
end
test "it sets password_reset_pending to false", %{conn: conn} do
- user = insert(:user, info: %{password_reset_pending: true})
+ user = insert(:user, password_reset_pending: true)
{:ok, token} = PasswordResetToken.create_token(user)
{:ok, _access_token} = Token.create_token(insert(:oauth_app), user, %{})
@@ -75,7 +75,7 @@ defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do
|> post("/api/pleroma/password_reset", %{data: params})
|> html_response(:ok)
- assert User.get_by_id(user.id).info.password_reset_pending == false
+ assert User.get_by_id(user.id).password_reset_pending == false
end
end
end
diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs
new file mode 100644
index 000000000..f7e54c26a
--- /dev/null
+++ b/test/web/twitter_api/remote_follow_controller_test.exs
@@ -0,0 +1,350 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Config
+ alias Pleroma.MFA
+ alias Pleroma.MFA.TOTP
+ alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
+
+ import ExUnit.CaptureLog
+ import Pleroma.Factory
+ import Ecto.Query
+
+ setup do
+ Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
+ setup_all do: clear_config([:instance, :federating], true)
+ setup do: clear_config([:instance])
+ setup do: clear_config([:frontend_configurations, :pleroma_fe])
+ setup do: clear_config([:user, :deny_follow_blocked])
+
+ describe "GET /ostatus_subscribe - remote_follow/2" do
+ test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} do
+ assert conn
+ |> get(
+ remote_follow_path(conn, :follow, %{
+ acct: "https://mastodon.social/users/emelie/statuses/101849165031453009"
+ })
+ )
+ |> redirected_to() =~ "/notice/"
+ end
+
+ test "show follow account page if the `acct` is a account link", %{conn: conn} do
+ response =
+ conn
+ |> get(remote_follow_path(conn, :follow, %{acct: "https://mastodon.social/users/emelie"}))
+ |> html_response(200)
+
+ assert response =~ "Log in to follow"
+ end
+
+ test "show follow page if the `acct` is a account link", %{conn: conn} do
+ user = insert(:user)
+
+ response =
+ conn
+ |> assign(:user, user)
+ |> get(remote_follow_path(conn, :follow, %{acct: "https://mastodon.social/users/emelie"}))
+ |> html_response(200)
+
+ assert response =~ "Remote follow"
+ end
+
+ test "show follow page with error when user cannot fecth by `acct` link", %{conn: conn} do
+ user = insert(:user)
+
+ assert capture_log(fn ->
+ response =
+ conn
+ |> assign(:user, user)
+ |> get(
+ remote_follow_path(conn, :follow, %{
+ acct: "https://mastodon.social/users/not_found"
+ })
+ )
+ |> html_response(200)
+
+ assert response =~ "Error fetching user"
+ end) =~ "Object has been deleted"
+ end
+ end
+
+ describe "POST /ostatus_subscribe - do_follow/2 with assigned user " do
+ test "required `follow | write:follows` scope", %{conn: conn} do
+ user = insert(:user)
+ user2 = insert(:user)
+ read_token = insert(:oauth_token, user: user, scopes: ["read"])
+
+ assert capture_log(fn ->
+ response =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, read_token)
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => user2.id}})
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end) =~ "Insufficient permissions: follow | write:follows."
+ end
+
+ test "follows user", %{conn: conn} do
+ user = insert(:user)
+ user2 = insert(:user)
+
+ conn =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:follows"]))
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => user2.id}})
+
+ assert redirected_to(conn) == "/users/#{user2.id}"
+ end
+
+ test "returns error when user is deactivated", %{conn: conn} do
+ user = insert(:user, deactivated: true)
+ user2 = insert(:user)
+
+ response =
+ conn
+ |> assign(:user, user)
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => user2.id}})
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end
+
+ test "returns error when user is blocked", %{conn: conn} do
+ Pleroma.Config.put([:user, :deny_follow_blocked], true)
+ user = insert(:user)
+ user2 = insert(:user)
+
+ {:ok, _user_block} = Pleroma.User.block(user2, user)
+
+ response =
+ conn
+ |> assign(:user, user)
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => user2.id}})
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end
+
+ test "returns error when followee not found", %{conn: conn} do
+ user = insert(:user)
+
+ response =
+ conn
+ |> assign(:user, user)
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => "jimm"}})
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end
+
+ test "returns success result when user already in followers", %{conn: conn} do
+ user = insert(:user)
+ user2 = insert(:user)
+ {:ok, _, _, _} = CommonAPI.follow(user, user2)
+
+ conn =
+ conn
+ |> assign(:user, refresh_record(user))
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:follows"]))
+ |> post(remote_follow_path(conn, :do_follow), %{"user" => %{"id" => user2.id}})
+
+ assert redirected_to(conn) == "/users/#{user2.id}"
+ end
+ end
+
+ describe "POST /ostatus_subscribe - follow/2 with enabled Two-Factor Auth " do
+ test "render the MFA login form", %{conn: conn} do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ user2 = insert(:user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
+ })
+ |> response(200)
+
+ mfa_token = Pleroma.Repo.one(from(q in Pleroma.MFA.Token, where: q.user_id == ^user.id))
+
+ assert response =~ "Two-factor authentication"
+ assert response =~ "Authentication code"
+ assert response =~ mfa_token.token
+ refute user2.follower_address in User.following(user)
+ end
+
+ test "returns error when password is incorrect", %{conn: conn} do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ user2 = insert(:user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "test1", "id" => user2.id}
+ })
+ |> response(200)
+
+ assert response =~ "Wrong username or password"
+ refute user2.follower_address in User.following(user)
+ end
+
+ test "follows", %{conn: conn} do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+ user2 = insert(:user)
+ otp_token = TOTP.generate_token(otp_secret)
+
+ conn =
+ conn
+ |> post(
+ remote_follow_path(conn, :do_follow),
+ %{
+ "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+ }
+ )
+
+ assert redirected_to(conn) == "/users/#{user2.id}"
+ assert user2.follower_address in User.following(user)
+ end
+
+ test "returns error when auth code is incorrect", %{conn: conn} do
+ otp_secret = TOTP.generate_secret()
+
+ user =
+ insert(:user,
+ multi_factor_authentication_settings: %MFA.Settings{
+ enabled: true,
+ totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
+ }
+ )
+
+ {:ok, %{token: token}} = MFA.Token.create_token(user)
+
+ user2 = insert(:user)
+ otp_token = TOTP.generate_token(TOTP.generate_secret())
+
+ response =
+ conn
+ |> post(
+ remote_follow_path(conn, :do_follow),
+ %{
+ "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
+ }
+ )
+ |> response(200)
+
+ assert response =~ "Wrong authentication code"
+ refute user2.follower_address in User.following(user)
+ end
+ end
+
+ describe "POST /ostatus_subscribe - follow/2 without assigned user " do
+ test "follows", %{conn: conn} do
+ user = insert(:user)
+ user2 = insert(:user)
+
+ conn =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
+ })
+
+ assert redirected_to(conn) == "/users/#{user2.id}"
+ assert user2.follower_address in User.following(user)
+ end
+
+ test "returns error when followee not found", %{conn: conn} do
+ user = insert(:user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "test", "id" => "jimm"}
+ })
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end
+
+ test "returns error when login invalid", %{conn: conn} do
+ user = insert(:user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => "jimm", "password" => "test", "id" => user.id}
+ })
+ |> response(200)
+
+ assert response =~ "Wrong username or password"
+ end
+
+ test "returns error when password invalid", %{conn: conn} do
+ user = insert(:user)
+ user2 = insert(:user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "42", "id" => user2.id}
+ })
+ |> response(200)
+
+ assert response =~ "Wrong username or password"
+ end
+
+ test "returns error when user is blocked", %{conn: conn} do
+ Pleroma.Config.put([:user, :deny_follow_blocked], true)
+ user = insert(:user)
+ user2 = insert(:user)
+ {:ok, _user_block} = Pleroma.User.block(user2, user)
+
+ response =
+ conn
+ |> post(remote_follow_path(conn, :do_follow), %{
+ "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
+ })
+ |> response(200)
+
+ assert response =~ "Error following account"
+ end
+ end
+end
diff --git a/test/web/twitter_api/twitter_api_controller_test.exs b/test/web/twitter_api/twitter_api_controller_test.exs
new file mode 100644
index 000000000..464d0ea2e
--- /dev/null
+++ b/test/web/twitter_api/twitter_api_controller_test.exs
@@ -0,0 +1,138 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.TwitterAPI.ControllerTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Builders.ActivityBuilder
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.Web.OAuth.Token
+
+ import Pleroma.Factory
+
+ describe "POST /api/qvitter/statuses/notifications/read" do
+ test "without valid credentials", %{conn: conn} do
+ conn = post(conn, "/api/qvitter/statuses/notifications/read", %{"latest_id" => 1_234_567})
+ assert json_response(conn, 403) == %{"error" => "Invalid credentials."}
+ end
+
+ test "with credentials, without any params" do
+ %{conn: conn} = oauth_access(["write:notifications"])
+
+ conn = post(conn, "/api/qvitter/statuses/notifications/read")
+
+ assert json_response(conn, 400) == %{
+ "error" => "You need to specify latest_id",
+ "request" => "/api/qvitter/statuses/notifications/read"
+ }
+ end
+
+ test "with credentials, with params" do
+ %{user: current_user, conn: conn} =
+ oauth_access(["read:notifications", "write:notifications"])
+
+ other_user = insert(:user)
+
+ {:ok, _activity} =
+ ActivityBuilder.insert(%{"to" => [current_user.ap_id]}, %{user: other_user})
+
+ response_conn =
+ conn
+ |> assign(:user, current_user)
+ |> get("/api/v1/notifications")
+
+ [notification] = response = json_response(response_conn, 200)
+
+ assert length(response) == 1
+
+ assert notification["pleroma"]["is_seen"] == false
+
+ response_conn =
+ conn
+ |> assign(:user, current_user)
+ |> post("/api/qvitter/statuses/notifications/read", %{"latest_id" => notification["id"]})
+
+ [notification] = response = json_response(response_conn, 200)
+
+ assert length(response) == 1
+
+ assert notification["pleroma"]["is_seen"] == true
+ end
+ end
+
+ describe "GET /api/account/confirm_email/:id/:token" do
+ setup do
+ {:ok, user} =
+ insert(:user)
+ |> User.confirmation_changeset(need_confirmation: true)
+ |> Repo.update()
+
+ assert user.confirmation_pending
+
+ [user: user]
+ end
+
+ test "it redirects to root url", %{conn: conn, user: user} do
+ conn = get(conn, "/api/account/confirm_email/#{user.id}/#{user.confirmation_token}")
+
+ assert 302 == conn.status
+ end
+
+ test "it confirms the user account", %{conn: conn, user: user} do
+ get(conn, "/api/account/confirm_email/#{user.id}/#{user.confirmation_token}")
+
+ user = User.get_cached_by_id(user.id)
+
+ refute user.confirmation_pending
+ refute user.confirmation_token
+ end
+
+ test "it returns 500 if user cannot be found by id", %{conn: conn, user: user} do
+ conn = get(conn, "/api/account/confirm_email/0/#{user.confirmation_token}")
+
+ assert 500 == conn.status
+ end
+
+ test "it returns 500 if token is invalid", %{conn: conn, user: user} do
+ conn = get(conn, "/api/account/confirm_email/#{user.id}/wrong_token")
+
+ assert 500 == conn.status
+ end
+ end
+
+ describe "GET /api/oauth_tokens" do
+ setup do
+ token = insert(:oauth_token) |> Repo.preload(:user)
+
+ %{token: token}
+ end
+
+ test "renders list", %{token: token} do
+ response =
+ build_conn()
+ |> assign(:user, token.user)
+ |> get("/api/oauth_tokens")
+
+ keys =
+ json_response(response, 200)
+ |> hd()
+ |> Map.keys()
+
+ assert keys -- ["id", "app_name", "valid_until"] == []
+ end
+
+ test "revoke token", %{token: token} do
+ response =
+ build_conn()
+ |> assign(:user, token.user)
+ |> delete("/api/oauth_tokens/#{token.id}")
+
+ tokens = Token.get_user_tokens(token.user)
+
+ assert tokens == []
+ assert response.status == 201
+ end
+ end
+end
diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs
index d1d61d11a..368533292 100644
--- a/test/web/twitter_api/twitter_api_test.exs
+++ b/test/web/twitter_api/twitter_api_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
@@ -18,11 +18,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "it registers a new user and returns the user." do
data = %{
- "nickname" => "lain",
- "email" => "lain@wired.jp",
- "fullname" => "lain iwakura",
- "password" => "bear",
- "confirm" => "bear"
+ :username => "lain",
+ :email => "lain@wired.jp",
+ :fullname => "lain iwakura",
+ :password => "bear",
+ :confirm => "bear"
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -35,12 +35,12 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "it registers a new user with empty string in bio and returns the user." do
data = %{
- "nickname" => "lain",
- "email" => "lain@wired.jp",
- "fullname" => "lain iwakura",
- "bio" => "",
- "password" => "bear",
- "confirm" => "bear"
+ :username => "lain",
+ :email => "lain@wired.jp",
+ :fullname => "lain iwakura",
+ :bio => "",
+ :password => "bear",
+ :confirm => "bear"
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -60,18 +60,18 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
end
data = %{
- "nickname" => "lain",
- "email" => "lain@wired.jp",
- "fullname" => "lain iwakura",
- "bio" => "",
- "password" => "bear",
- "confirm" => "bear"
+ :username => "lain",
+ :email => "lain@wired.jp",
+ :fullname => "lain iwakura",
+ :bio => "",
+ :password => "bear",
+ :confirm => "bear"
}
{:ok, user} = TwitterAPI.register_user(data)
ObanHelpers.perform_all()
- assert user.info.confirmation_pending
+ assert user.confirmation_pending
email = Pleroma.Emails.UserEmail.account_confirmation_email(user)
@@ -87,29 +87,29 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "it registers a new user and parses mentions in the bio" do
data1 = %{
- "nickname" => "john",
- "email" => "john@gmail.com",
- "fullname" => "John Doe",
- "bio" => "test",
- "password" => "bear",
- "confirm" => "bear"
+ :username => "john",
+ :email => "john@gmail.com",
+ :fullname => "John Doe",
+ :bio => "test",
+ :password => "bear",
+ :confirm => "bear"
}
{:ok, user1} = TwitterAPI.register_user(data1)
data2 = %{
- "nickname" => "lain",
- "email" => "lain@wired.jp",
- "fullname" => "lain iwakura",
- "bio" => "@john test",
- "password" => "bear",
- "confirm" => "bear"
+ :username => "lain",
+ :email => "lain@wired.jp",
+ :fullname => "lain iwakura",
+ :bio => "@john test",
+ :password => "bear",
+ :confirm => "bear"
}
{:ok, user2} = TwitterAPI.register_user(data2)
expected_text =
- ~s(<span class="h-card"><a data-user="#{user1.id}" class="u-url mention" href="#{
+ ~s(<span class="h-card"><a class="u-url mention" data-user="#{user1.id}" href="#{
user1.ap_id
}" rel="ugc">@<span>john</span></a></span> test)
@@ -117,28 +117,19 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
end
describe "register with one time token" do
- setup do
- setting = Pleroma.Config.get([:instance, :registrations_open])
-
- if setting do
- Pleroma.Config.put([:instance, :registrations_open], false)
- on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
- end
-
- :ok
- end
+ setup do: clear_config([:instance, :registrations_open], false)
test "returns user on success" do
{:ok, invite} = UserInviteToken.create_invite()
data = %{
- "nickname" => "vinny",
- "email" => "pasta@pizza.vs",
- "fullname" => "Vinny Vinesauce",
- "bio" => "streamer",
- "password" => "hiptofbees",
- "confirm" => "hiptofbees",
- "token" => invite.token
+ :username => "vinny",
+ :email => "pasta@pizza.vs",
+ :fullname => "Vinny Vinesauce",
+ :bio => "streamer",
+ :password => "hiptofbees",
+ :confirm => "hiptofbees",
+ :token => invite.token
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -154,13 +145,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "returns error on invalid token" do
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => "DudeLetMeInImAFairy"
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => "DudeLetMeInImAFairy"
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -174,13 +165,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
UserInviteToken.update_invite!(invite, used: true)
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => invite.token
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => invite.token
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -191,25 +182,20 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
end
describe "registers with date limited token" do
- setup do
- setting = Pleroma.Config.get([:instance, :registrations_open])
-
- if setting do
- Pleroma.Config.put([:instance, :registrations_open], false)
- on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
- end
+ setup do: clear_config([:instance, :registrations_open], false)
+ setup do
data = %{
- "nickname" => "vinny",
- "email" => "pasta@pizza.vs",
- "fullname" => "Vinny Vinesauce",
- "bio" => "streamer",
- "password" => "hiptofbees",
- "confirm" => "hiptofbees"
+ :username => "vinny",
+ :email => "pasta@pizza.vs",
+ :fullname => "Vinny Vinesauce",
+ :bio => "streamer",
+ :password => "hiptofbees",
+ :confirm => "hiptofbees"
}
check_fn = fn invite ->
- data = Map.put(data, "token", invite.token)
+ data = Map.put(data, :token, invite.token)
{:ok, user} = TwitterAPI.register_user(data)
fetched_user = User.get_cached_by_nickname("vinny")
@@ -256,16 +242,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
end
describe "registers with reusable token" do
- setup do
- setting = Pleroma.Config.get([:instance, :registrations_open])
-
- if setting do
- Pleroma.Config.put([:instance, :registrations_open], false)
- on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
- end
-
- :ok
- end
+ setup do: clear_config([:instance, :registrations_open], false)
test "returns user on success, after him registration fails" do
{:ok, invite} = UserInviteToken.create_invite(%{max_use: 100})
@@ -273,13 +250,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
UserInviteToken.update_invite!(invite, uses: 99)
data = %{
- "nickname" => "vinny",
- "email" => "pasta@pizza.vs",
- "fullname" => "Vinny Vinesauce",
- "bio" => "streamer",
- "password" => "hiptofbees",
- "confirm" => "hiptofbees",
- "token" => invite.token
+ :username => "vinny",
+ :email => "pasta@pizza.vs",
+ :fullname => "Vinny Vinesauce",
+ :bio => "streamer",
+ :password => "hiptofbees",
+ :confirm => "hiptofbees",
+ :token => invite.token
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -292,13 +269,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
AccountView.render("show.json", %{user: fetched_user})
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => invite.token
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => invite.token
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -309,28 +286,19 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
end
describe "registers with reusable date limited token" do
- setup do
- setting = Pleroma.Config.get([:instance, :registrations_open])
-
- if setting do
- Pleroma.Config.put([:instance, :registrations_open], false)
- on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
- end
-
- :ok
- end
+ setup do: clear_config([:instance, :registrations_open], false)
test "returns user on success" do
{:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100})
data = %{
- "nickname" => "vinny",
- "email" => "pasta@pizza.vs",
- "fullname" => "Vinny Vinesauce",
- "bio" => "streamer",
- "password" => "hiptofbees",
- "confirm" => "hiptofbees",
- "token" => invite.token
+ :username => "vinny",
+ :email => "pasta@pizza.vs",
+ :fullname => "Vinny Vinesauce",
+ :bio => "streamer",
+ :password => "hiptofbees",
+ :confirm => "hiptofbees",
+ :token => invite.token
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -349,13 +317,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
UserInviteToken.update_invite!(invite, uses: 99)
data = %{
- "nickname" => "vinny",
- "email" => "pasta@pizza.vs",
- "fullname" => "Vinny Vinesauce",
- "bio" => "streamer",
- "password" => "hiptofbees",
- "confirm" => "hiptofbees",
- "token" => invite.token
+ :username => "vinny",
+ :email => "pasta@pizza.vs",
+ :fullname => "Vinny Vinesauce",
+ :bio => "streamer",
+ :password => "hiptofbees",
+ :confirm => "hiptofbees",
+ :token => invite.token
}
{:ok, user} = TwitterAPI.register_user(data)
@@ -367,13 +335,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
AccountView.render("show.json", %{user: fetched_user})
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => invite.token
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => invite.token
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -387,13 +355,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100})
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => invite.token
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => invite.token
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -409,13 +377,13 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
UserInviteToken.update_invite!(invite, uses: 100)
data = %{
- "nickname" => "GrimReaper",
- "email" => "death@reapers.afterlife",
- "fullname" => "Reaper Grim",
- "bio" => "Your time has come",
- "password" => "scythe",
- "confirm" => "scythe",
- "token" => invite.token
+ :username => "GrimReaper",
+ :email => "death@reapers.afterlife",
+ :fullname => "Reaper Grim",
+ :bio => "Your time has come",
+ :password => "scythe",
+ :confirm => "scythe",
+ :token => invite.token
}
{:error, msg} = TwitterAPI.register_user(data)
@@ -427,16 +395,15 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "it returns the error on registration problems" do
data = %{
- "nickname" => "lain",
- "email" => "lain@wired.jp",
- "fullname" => "lain iwakura",
- "bio" => "close the world.",
- "password" => "bear"
+ :username => "lain",
+ :email => "lain@wired.jp",
+ :fullname => "lain iwakura",
+ :bio => "close the world."
}
- {:error, error_object} = TwitterAPI.register_user(data)
+ {:error, error} = TwitterAPI.register_user(data)
- assert is_binary(error_object[:error])
+ assert is_binary(error)
refute User.get_cached_by_nickname("lain")
end
diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs
index 9d4cb70f0..ad919d341 100644
--- a/test/web/twitter_api/util_controller_test.exs
+++ b/test/web/twitter_api/util_controller_test.exs
@@ -1,16 +1,15 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
use Pleroma.Web.ConnCase
use Oban.Testing, repo: Pleroma.Repo
- alias Pleroma.Repo
+ alias Pleroma.Config
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
- alias Pleroma.Web.CommonAPI
- import ExUnit.CaptureLog
+
import Pleroma.Factory
import Mock
@@ -19,26 +18,24 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
:ok
end
- clear_config([:instance])
- clear_config([:frontend_configurations, :pleroma_fe])
- clear_config([:user, :deny_follow_blocked])
+ setup do: clear_config([:instance])
+ setup do: clear_config([:frontend_configurations, :pleroma_fe])
describe "POST /api/pleroma/follow_import" do
+ setup do: oauth_access(["follow"])
+
test "it returns HTTP 200", %{conn: conn} do
- user1 = insert(:user)
user2 = insert(:user)
response =
conn
- |> assign(:user, user1)
|> post("/api/pleroma/follow_import", %{"list" => "#{user2.ap_id}"})
|> json_response(:ok)
assert response == "job started"
end
- test "it imports follow lists from file", %{conn: conn} do
- user1 = insert(:user)
+ test "it imports follow lists from file", %{user: user1, conn: conn} do
user2 = insert(:user)
with_mocks([
@@ -49,7 +46,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
]) do
response =
conn
- |> assign(:user, user1)
|> post("/api/pleroma/follow_import", %{"list" => %Plug.Upload{path: "follow_list.txt"}})
|> json_response(:ok)
@@ -67,12 +63,10 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
test "it imports new-style mastodon follow lists", %{conn: conn} do
- user1 = insert(:user)
user2 = insert(:user)
response =
conn
- |> assign(:user, user1)
|> post("/api/pleroma/follow_import", %{
"list" => "Account address,Show boosts\n#{user2.ap_id},true"
})
@@ -81,7 +75,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert response == "job started"
end
- test "requires 'follow' or 'write:follows' permissions", %{conn: conn} do
+ test "requires 'follow' or 'write:follows' permissions" do
token1 = insert(:oauth_token, scopes: ["read", "write"])
token2 = insert(:oauth_token, scopes: ["follow"])
token3 = insert(:oauth_token, scopes: ["something"])
@@ -89,7 +83,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
for token <- [token1, token2, token3] do
conn =
- conn
+ build_conn()
|> put_req_header("authorization", "Bearer #{token.token}")
|> post("/api/pleroma/follow_import", %{"list" => "#{another_user.ap_id}"})
@@ -101,24 +95,48 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
end
end
+
+ test "it imports follows with different nickname variations", %{conn: conn} do
+ [user2, user3, user4, user5, user6] = insert_list(5, :user)
+
+ identifiers =
+ [
+ user2.ap_id,
+ user3.nickname,
+ " ",
+ "@" <> user4.nickname,
+ user5.nickname <> "@localhost",
+ "@" <> user6.nickname <> "@localhost"
+ ]
+ |> Enum.join("\n")
+
+ response =
+ conn
+ |> post("/api/pleroma/follow_import", %{"list" => identifiers})
+ |> json_response(:ok)
+
+ assert response == "job started"
+ assert [{:ok, job_result}] = ObanHelpers.perform_all()
+ assert job_result == [user2, user3, user4, user5, user6]
+ end
end
describe "POST /api/pleroma/blocks_import" do
+ # Note: "follow" or "write:blocks" permission is required
+ setup do: oauth_access(["write:blocks"])
+
test "it returns HTTP 200", %{conn: conn} do
- user1 = insert(:user)
user2 = insert(:user)
response =
conn
- |> assign(:user, user1)
|> post("/api/pleroma/blocks_import", %{"list" => "#{user2.ap_id}"})
|> json_response(:ok)
assert response == "job started"
end
- test "it imports blocks users from file", %{conn: conn} do
- user1 = insert(:user)
+ test "it imports blocks users from file", %{user: user1, conn: conn} do
user2 = insert(:user)
user3 = insert(:user)
@@ -127,7 +145,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
]) do
response =
conn
- |> assign(:user, user1)
|> post("/api/pleroma/blocks_import", %{"list" => %Plug.Upload{path: "blocks_list.txt"}})
|> json_response(:ok)
@@ -143,34 +160,73 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
)
end
end
+
+ test "it imports blocks with different nickname variations", %{conn: conn} do
+ [user2, user3, user4, user5, user6] = insert_list(5, :user)
+
+ identifiers =
+ [
+ user2.ap_id,
+ user3.nickname,
+ "@" <> user4.nickname,
+ user5.nickname <> "@localhost",
+ "@" <> user6.nickname <> "@localhost"
+ ]
+ |> Enum.join(" ")
+
+ response =
+ conn
+ |> post("/api/pleroma/blocks_import", %{"list" => identifiers})
+ |> json_response(:ok)
+
+ assert response == "job started"
+ assert [{:ok, job_result}] = ObanHelpers.perform_all()
+ assert job_result == [user2, user3, user4, user5, user6]
+ end
end
describe "PUT /api/pleroma/notification_settings" do
- test "it updates notification settings", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+ test "it updates notification settings", %{user: user, conn: conn} do
conn
- |> assign(:user, user)
|> put("/api/pleroma/notification_settings", %{
"followers" => false,
"bar" => 1
})
|> json_response(:ok)
- user = Repo.get(User, user.id)
+ user = refresh_record(user)
- assert %{
- "followers" => false,
- "follows" => true,
- "non_follows" => true,
- "non_followers" => true
- } == user.info.notification_settings
+ assert %Pleroma.User.NotificationSetting{
+ followers: false,
+ follows: true,
+ non_follows: true,
+ non_followers: true,
+ privacy_option: false
+ } == user.notification_settings
+ end
+
+ test "it updates notification privacy option", %{user: user, conn: conn} do
+ conn
+ |> put("/api/pleroma/notification_settings", %{"privacy_option" => "1"})
+ |> json_response(:ok)
+
+ user = refresh_record(user)
+
+ assert %Pleroma.User.NotificationSetting{
+ followers: true,
+ follows: true,
+ non_follows: true,
+ non_followers: true,
+ privacy_option: true
+ } == user.notification_settings
end
end
describe "GET /api/statusnet/config" do
test "it returns config in xml format", %{conn: conn} do
- instance = Pleroma.Config.get(:instance)
+ instance = Config.get(:instance)
response =
conn
@@ -187,12 +243,12 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
test "it returns config in json format", %{conn: conn} do
- instance = Pleroma.Config.get(:instance)
- Pleroma.Config.put([:instance, :managed_config], true)
- Pleroma.Config.put([:instance, :registrations_open], false)
- Pleroma.Config.put([:instance, :invites_enabled], true)
- Pleroma.Config.put([:instance, :public], false)
- Pleroma.Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"})
+ instance = Config.get(:instance)
+ Config.put([:instance, :managed_config], true)
+ Config.put([:instance, :registrations_open], false)
+ Config.put([:instance, :invites_enabled], true)
+ Config.put([:instance, :public], false)
+ Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"})
response =
conn
@@ -226,7 +282,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
test "returns the state of safe_dm_mentions flag", %{conn: conn} do
- Pleroma.Config.put([:instance, :safe_dm_mentions], true)
+ Config.put([:instance, :safe_dm_mentions], true)
response =
conn
@@ -235,7 +291,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert response["site"]["safeDMMentionsEnabled"] == "1"
- Pleroma.Config.put([:instance, :safe_dm_mentions], false)
+ Config.put([:instance, :safe_dm_mentions], false)
response =
conn
@@ -246,8 +302,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
test "it returns the managed config", %{conn: conn} do
- Pleroma.Config.put([:instance, :managed_config], false)
- Pleroma.Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"})
+ Config.put([:instance, :managed_config], false)
+ Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"})
response =
conn
@@ -256,7 +312,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
refute response["site"]["pleromafe"]
- Pleroma.Config.put([:instance, :managed_config], true)
+ Config.put([:instance, :managed_config], true)
response =
conn
@@ -279,7 +335,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
}
]
- Pleroma.Config.put(:frontend_configurations, config)
+ Config.put(:frontend_configurations, config)
response =
conn
@@ -308,201 +364,11 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
end
- describe "GET /ostatus_subscribe - remote_follow/2" do
- test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} do
- conn =
- get(
- conn,
- "/ostatus_subscribe?acct=https://mastodon.social/users/emelie/statuses/101849165031453009"
- )
-
- assert redirected_to(conn) =~ "/notice/"
- end
-
- test "show follow account page if the `acct` is a account link", %{conn: conn} do
- response =
- get(
- conn,
- "/ostatus_subscribe?acct=https://mastodon.social/users/emelie"
- )
-
- assert html_response(response, 200) =~ "Log in to follow"
- end
-
- test "show follow page if the `acct` is a account link", %{conn: conn} do
- user = insert(:user)
-
- response =
- conn
- |> assign(:user, user)
- |> get("/ostatus_subscribe?acct=https://mastodon.social/users/emelie")
-
- assert html_response(response, 200) =~ "Remote follow"
- end
-
- test "show follow page with error when user cannot fecth by `acct` link", %{conn: conn} do
- user = insert(:user)
-
- assert capture_log(fn ->
- response =
- conn
- |> assign(:user, user)
- |> get("/ostatus_subscribe?acct=https://mastodon.social/users/not_found")
-
- assert html_response(response, 200) =~ "Error fetching user"
- end) =~ "Object has been deleted"
- end
- end
-
- describe "POST /ostatus_subscribe - do_remote_follow/2 with assigned user " do
- test "follows user", %{conn: conn} do
- user = insert(:user)
- user2 = insert(:user)
-
- response =
- conn
- |> assign(:user, user)
- |> post("/ostatus_subscribe", %{"user" => %{"id" => user2.id}})
- |> response(200)
-
- assert response =~ "Account followed!"
- assert user2.follower_address in refresh_record(user).following
- end
-
- test "returns error when user is deactivated", %{conn: conn} do
- user = insert(:user, info: %{deactivated: true})
- user2 = insert(:user)
-
- response =
- conn
- |> assign(:user, user)
- |> post("/ostatus_subscribe", %{"user" => %{"id" => user2.id}})
- |> response(200)
-
- assert response =~ "Error following account"
- end
-
- test "returns error when user is blocked", %{conn: conn} do
- Pleroma.Config.put([:user, :deny_follow_blocked], true)
- user = insert(:user)
- user2 = insert(:user)
-
- {:ok, _user} = Pleroma.User.block(user2, user)
-
- response =
- conn
- |> assign(:user, user)
- |> post("/ostatus_subscribe", %{"user" => %{"id" => user2.id}})
- |> response(200)
-
- assert response =~ "Error following account"
- end
-
- test "returns error when followee not found", %{conn: conn} do
- user = insert(:user)
-
- response =
- conn
- |> assign(:user, user)
- |> post("/ostatus_subscribe", %{"user" => %{"id" => "jimm"}})
- |> response(200)
-
- assert response =~ "Error following account"
- end
-
- test "returns success result when user already in followers", %{conn: conn} do
- user = insert(:user)
- user2 = insert(:user)
- {:ok, _, _, _} = CommonAPI.follow(user, user2)
-
- response =
- conn
- |> assign(:user, refresh_record(user))
- |> post("/ostatus_subscribe", %{"user" => %{"id" => user2.id}})
- |> response(200)
-
- assert response =~ "Account followed!"
- end
- end
-
- describe "POST /ostatus_subscribe - do_remote_follow/2 without assigned user " do
- test "follows", %{conn: conn} do
- user = insert(:user)
- user2 = insert(:user)
-
- response =
- conn
- |> post("/ostatus_subscribe", %{
- "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
- })
- |> response(200)
-
- assert response =~ "Account followed!"
- assert user2.follower_address in refresh_record(user).following
- end
-
- test "returns error when followee not found", %{conn: conn} do
- user = insert(:user)
-
- response =
- conn
- |> post("/ostatus_subscribe", %{
- "authorization" => %{"name" => user.nickname, "password" => "test", "id" => "jimm"}
- })
- |> response(200)
-
- assert response =~ "Error following account"
- end
-
- test "returns error when login invalid", %{conn: conn} do
- user = insert(:user)
-
- response =
- conn
- |> post("/ostatus_subscribe", %{
- "authorization" => %{"name" => "jimm", "password" => "test", "id" => user.id}
- })
- |> response(200)
-
- assert response =~ "Wrong username or password"
- end
-
- test "returns error when password invalid", %{conn: conn} do
- user = insert(:user)
- user2 = insert(:user)
-
- response =
- conn
- |> post("/ostatus_subscribe", %{
- "authorization" => %{"name" => user.nickname, "password" => "42", "id" => user2.id}
- })
- |> response(200)
-
- assert response =~ "Wrong username or password"
- end
-
- test "returns error when user is blocked", %{conn: conn} do
- Pleroma.Config.put([:user, :deny_follow_blocked], true)
- user = insert(:user)
- user2 = insert(:user)
- {:ok, _user} = Pleroma.User.block(user2, user)
-
- response =
- conn
- |> post("/ostatus_subscribe", %{
- "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
- })
- |> response(200)
-
- assert response =~ "Error following account"
- end
- end
-
describe "GET /api/pleroma/healthcheck" do
- clear_config([:instance, :healthcheck])
+ setup do: clear_config([:instance, :healthcheck])
test "returns 503 when healthcheck disabled", %{conn: conn} do
- Pleroma.Config.put([:instance, :healthcheck], false)
+ Config.put([:instance, :healthcheck], false)
response =
conn
@@ -513,7 +379,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
test "returns 200 when healthcheck enabled and all ok", %{conn: conn} do
- Pleroma.Config.put([:instance, :healthcheck], true)
+ Config.put([:instance, :healthcheck], true)
with_mock Pleroma.Healthcheck,
system_info: fn -> %Pleroma.Healthcheck{healthy: true} end do
@@ -532,8 +398,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
end
- test "returns 503 when healthcheck enabled and health is false", %{conn: conn} do
- Pleroma.Config.put([:instance, :healthcheck], true)
+ test "returns 503 when healthcheck enabled and health is false", %{conn: conn} do
+ Config.put([:instance, :healthcheck], true)
with_mock Pleroma.Healthcheck,
system_info: fn -> %Pleroma.Healthcheck{healthy: false} end do
@@ -554,12 +420,11 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
describe "POST /api/pleroma/disable_account" do
- test "it returns HTTP 200", %{conn: conn} do
- user = insert(:user)
+ setup do: oauth_access(["write:accounts"])
+ test "with valid permissions and password, it disables the account", %{conn: conn, user: user} do
response =
conn
- |> assign(:user, user)
|> post("/api/pleroma/disable_account", %{"password" => "test"})
|> json_response(:ok)
@@ -568,22 +433,21 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
user = User.get_cached_by_id(user.id)
- assert user.info.deactivated == true
+ assert user.deactivated == true
end
- test "it returns returns when password invalid", %{conn: conn} do
+ test "with valid permissions and invalid password, it returns an error", %{conn: conn} do
user = insert(:user)
response =
conn
- |> assign(:user, user)
|> post("/api/pleroma/disable_account", %{"password" => "test1"})
|> json_response(:ok)
assert response == %{"error" => "Invalid password."}
user = User.get_cached_by_id(user.id)
- refute user.info.deactivated
+ refute user.deactivated
end
end
@@ -610,6 +474,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
describe "POST /main/ostatus - remote_subscribe/2" do
+ setup do: clear_config([:instance, :federating], true)
+
test "renders subscribe form", %{conn: conn} do
user = insert(:user)
@@ -646,7 +512,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
"https://social.heldscal.la/main/ostatussub?profile=#{user.ap_id}"
end
- test "it renders form with error when use not found", %{conn: conn} do
+ test "it renders form with error when user not found", %{conn: conn} do
user2 = insert(:user, ap_id: "shp@social.heldscal.la")
response =
@@ -671,29 +537,21 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
end
- defp with_credentials(conn, username, password) do
- header_content = "Basic " <> Base.encode64("#{username}:#{password}")
- put_req_header(conn, "authorization", header_content)
- end
-
- defp valid_user(_context) do
- user = insert(:user)
- [user: user]
- end
-
describe "POST /api/pleroma/change_email" do
- setup [:valid_user]
+ setup do: oauth_access(["write:accounts"])
+
+ test "without permissions", %{conn: conn} do
+ conn =
+ conn
+ |> assign(:token, nil)
+ |> post("/api/pleroma/change_email")
- test "without credentials", %{conn: conn} do
- conn = post(conn, "/api/pleroma/change_email")
- assert json_response(conn, 403) == %{"error" => "Invalid credentials."}
+ assert json_response(conn, 403) == %{"error" => "Insufficient permissions: write:accounts."}
end
- test "with credentials and invalid password", %{conn: conn, user: current_user} do
+ test "with proper permissions and invalid password", %{conn: conn} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "hi",
"email" => "test@test.com"
})
@@ -701,14 +559,11 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert json_response(conn, 200) == %{"error" => "Invalid password."}
end
- test "with credentials, valid password and invalid email", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and invalid email", %{
+ conn: conn
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "test",
"email" => "foobar"
})
@@ -716,28 +571,22 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert json_response(conn, 200) == %{"error" => "Email has invalid format."}
end
- test "with credentials, valid password and no email", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and no email", %{
+ conn: conn
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "test"
})
assert json_response(conn, 200) == %{"error" => "Email can't be blank."}
end
- test "with credentials, valid password and blank email", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and blank email", %{
+ conn: conn
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "test",
"email" => ""
})
@@ -745,16 +594,13 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert json_response(conn, 200) == %{"error" => "Email can't be blank."}
end
- test "with credentials, valid password and non unique email", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and non unique email", %{
+ conn: conn
} do
user = insert(:user)
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "test",
"email" => user.email
})
@@ -762,14 +608,11 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert json_response(conn, 200) == %{"error" => "Email has already been taken."}
end
- test "with credentials, valid password and valid email", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and valid email", %{
+ conn: conn
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_email", %{
+ post(conn, "/api/pleroma/change_email", %{
"password" => "test",
"email" => "cofe@foobar.com"
})
@@ -779,18 +622,20 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
end
describe "POST /api/pleroma/change_password" do
- setup [:valid_user]
+ setup do: oauth_access(["write:accounts"])
+
+ test "without permissions", %{conn: conn} do
+ conn =
+ conn
+ |> assign(:token, nil)
+ |> post("/api/pleroma/change_password")
- test "without credentials", %{conn: conn} do
- conn = post(conn, "/api/pleroma/change_password")
- assert json_response(conn, 403) == %{"error" => "Invalid credentials."}
+ assert json_response(conn, 403) == %{"error" => "Insufficient permissions: write:accounts."}
end
- test "with credentials and invalid password", %{conn: conn, user: current_user} do
+ test "with proper permissions and invalid password", %{conn: conn} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_password", %{
+ post(conn, "/api/pleroma/change_password", %{
"password" => "hi",
"new_password" => "newpass",
"new_password_confirmation" => "newpass"
@@ -799,14 +644,12 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
assert json_response(conn, 200) == %{"error" => "Invalid password."}
end
- test "with credentials, valid password and new password and confirmation not matching", %{
- conn: conn,
- user: current_user
- } do
+ test "with proper permissions, valid password and new password and confirmation not matching",
+ %{
+ conn: conn
+ } do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_password", %{
+ post(conn, "/api/pleroma/change_password", %{
"password" => "test",
"new_password" => "newpass",
"new_password_confirmation" => "notnewpass"
@@ -817,14 +660,11 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
}
end
- test "with credentials, valid password and invalid new password", %{
- conn: conn,
- user: current_user
+ test "with proper permissions, valid password and invalid new password", %{
+ conn: conn
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_password", %{
+ post(conn, "/api/pleroma/change_password", %{
"password" => "test",
"new_password" => "",
"new_password_confirmation" => ""
@@ -835,51 +675,48 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
}
end
- test "with credentials, valid password and matching new password and confirmation", %{
+ test "with proper permissions, valid password and matching new password and confirmation", %{
conn: conn,
- user: current_user
+ user: user
} do
conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/change_password", %{
+ post(conn, "/api/pleroma/change_password", %{
"password" => "test",
"new_password" => "newpass",
"new_password_confirmation" => "newpass"
})
assert json_response(conn, 200) == %{"status" => "success"}
- fetched_user = User.get_cached_by_id(current_user.id)
- assert Comeonin.Pbkdf2.checkpw("newpass", fetched_user.password_hash) == true
+ fetched_user = User.get_cached_by_id(user.id)
+ assert Pbkdf2.verify_pass("newpass", fetched_user.password_hash) == true
end
end
describe "POST /api/pleroma/delete_account" do
- setup [:valid_user]
-
- test "without credentials", %{conn: conn} do
- conn = post(conn, "/api/pleroma/delete_account")
- assert json_response(conn, 403) == %{"error" => "Invalid credentials."}
- end
+ setup do: oauth_access(["write:accounts"])
- test "with credentials and invalid password", %{conn: conn, user: current_user} do
+ test "without permissions", %{conn: conn} do
conn =
conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/delete_account", %{"password" => "hi"})
+ |> assign(:token, nil)
+ |> post("/api/pleroma/delete_account")
- assert json_response(conn, 200) == %{"error" => "Invalid password."}
+ assert json_response(conn, 403) ==
+ %{"error" => "Insufficient permissions: write:accounts."}
end
- test "with credentials and valid password", %{conn: conn, user: current_user} do
- conn =
- conn
- |> with_credentials(current_user.nickname, "test")
- |> post("/api/pleroma/delete_account", %{"password" => "test"})
+ test "with proper permissions and wrong or missing password", %{conn: conn} do
+ for params <- [%{"password" => "hi"}, %{}] do
+ ret_conn = post(conn, "/api/pleroma/delete_account", params)
+
+ assert json_response(ret_conn, 200) == %{"error" => "Invalid password."}
+ end
+ end
+
+ test "with proper permissions and valid password", %{conn: conn} do
+ conn = post(conn, "/api/pleroma/delete_account", %{"password" => "test"})
assert json_response(conn, 200) == %{"status" => "success"}
- # Wait a second for the started task to end
- :timer.sleep(1000)
end
end
end
diff --git a/test/web/uploader_controller_test.exs b/test/web/uploader_controller_test.exs
index 7c7f9a6ea..21e518236 100644
--- a/test/web/uploader_controller_test.exs
+++ b/test/web/uploader_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.UploaderControllerTest do
diff --git a/test/web/views/error_view_test.exs b/test/web/views/error_view_test.exs
index 4e5398c83..8dbbd18b4 100644
--- a/test/web/views/error_view_test.exs
+++ b/test/web/views/error_view_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ErrorViewTest do
diff --git a/test/web/web_finger/web_finger_controller_test.exs b/test/web/web_finger/web_finger_controller_test.exs
index 49cd1460b..0023f1e81 100644
--- a/test/web/web_finger/web_finger_controller_test.exs
+++ b/test/web/web_finger/web_finger_controller_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do
@@ -14,9 +14,7 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do
:ok
end
- clear_config_all([:instance, :federating]) do
- Pleroma.Config.put([:instance, :federating], true)
- end
+ setup_all do: clear_config([:instance, :federating], true)
test "GET host-meta" do
response =
diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs
index 5aa8c73cf..f4884e0a2 100644
--- a/test/web/web_finger/web_finger_test.exs
+++ b/test/web/web_finger/web_finger_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.WebFingerTest do
@@ -67,7 +67,7 @@ defmodule Pleroma.Web.WebFingerTest do
assert data["magic_key"] == nil
assert data["salmon"] == nil
- assert data["topic"] == "https://mstdn.jp/users/kPherox.atom"
+ assert data["topic"] == nil
assert data["subject"] == "acct:kPherox@mstdn.jp"
assert data["ap_id"] == "https://mstdn.jp/users/kPherox"
assert data["subscribe_address"] == "https://mstdn.jp/authorize_interaction?acct={uri}"
diff --git a/test/workers/cron/clear_oauth_token_worker_test.exs b/test/workers/cron/clear_oauth_token_worker_test.exs
new file mode 100644
index 000000000..df82dc75d
--- /dev/null
+++ b/test/workers/cron/clear_oauth_token_worker_test.exs
@@ -0,0 +1,22 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.Cron.ClearOauthTokenWorkerTest do
+ use Pleroma.DataCase
+
+ import Pleroma.Factory
+ alias Pleroma.Workers.Cron.ClearOauthTokenWorker
+
+ setup do: clear_config([:oauth2, :clean_expired_tokens])
+
+ test "deletes expired tokens" do
+ insert(:oauth_token,
+ valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -60 * 10)
+ )
+
+ Pleroma.Config.put([:oauth2, :clean_expired_tokens], true)
+ ClearOauthTokenWorker.perform(:opts, :job)
+ assert Pleroma.Repo.all(Pleroma.Web.OAuth.Token) == []
+ end
+end
diff --git a/test/workers/cron/digest_emails_worker_test.exs b/test/workers/cron/digest_emails_worker_test.exs
new file mode 100644
index 000000000..f9bc50db5
--- /dev/null
+++ b/test/workers/cron/digest_emails_worker_test.exs
@@ -0,0 +1,54 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.Cron.DigestEmailsWorkerTest do
+ use Pleroma.DataCase
+
+ import Pleroma.Factory
+
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
+
+ setup do: clear_config([:email_notifications, :digest])
+
+ setup do
+ Pleroma.Config.put([:email_notifications, :digest], %{
+ active: true,
+ inactivity_threshold: 7,
+ interval: 7
+ })
+
+ user = insert(:user)
+
+ date =
+ Timex.now()
+ |> Timex.shift(days: -10)
+ |> Timex.to_naive_datetime()
+
+ user2 = insert(:user, last_digest_emailed_at: date)
+ {:ok, _} = User.switch_email_notifications(user2, "digest", true)
+ CommonAPI.post(user, %{status: "hey @#{user2.nickname}!"})
+
+ {:ok, user2: user2}
+ end
+
+ test "it sends digest emails", %{user2: user2} do
+ Pleroma.Workers.Cron.DigestEmailsWorker.perform(:opts, :pid)
+ # Performing job(s) enqueued at previous step
+ ObanHelpers.perform_all()
+
+ assert_received {:email, email}
+ assert email.to == [{user2.name, user2.email}]
+ assert email.subject == "Your digest from #{Pleroma.Config.get(:instance)[:name]}"
+ end
+
+ test "it doesn't fail when a user has no email", %{user2: user2} do
+ {:ok, _} = user2 |> Ecto.Changeset.change(%{email: nil}) |> Pleroma.Repo.update()
+
+ Pleroma.Workers.Cron.DigestEmailsWorker.perform(:opts, :pid)
+ # Performing job(s) enqueued at previous step
+ ObanHelpers.perform_all()
+ end
+end
diff --git a/test/workers/cron/new_users_digest_worker_test.exs b/test/workers/cron/new_users_digest_worker_test.exs
new file mode 100644
index 000000000..54cf0ca46
--- /dev/null
+++ b/test/workers/cron/new_users_digest_worker_test.exs
@@ -0,0 +1,44 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.Cron.NewUsersDigestWorkerTest do
+ use Pleroma.DataCase
+ import Pleroma.Factory
+
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Workers.Cron.NewUsersDigestWorker
+
+ test "it sends new users digest emails" do
+ yesterday = NaiveDateTime.utc_now() |> Timex.shift(days: -1)
+ admin = insert(:user, %{is_admin: true})
+ user = insert(:user, %{inserted_at: yesterday})
+ user2 = insert(:user, %{inserted_at: yesterday})
+ CommonAPI.post(user, %{status: "cofe"})
+
+ NewUsersDigestWorker.perform(nil, nil)
+ ObanHelpers.perform_all()
+
+ assert_received {:email, email}
+ assert email.to == [{admin.name, admin.email}]
+ assert email.subject == "#{Pleroma.Config.get([:instance, :name])} New Users"
+
+ refute email.html_body =~ admin.nickname
+ assert email.html_body =~ user.nickname
+ assert email.html_body =~ user2.nickname
+ assert email.html_body =~ "cofe"
+ end
+
+ test "it doesn't fail when admin has no email" do
+ yesterday = NaiveDateTime.utc_now() |> Timex.shift(days: -1)
+ insert(:user, %{is_admin: true, email: nil})
+ insert(:user, %{inserted_at: yesterday})
+ user = insert(:user, %{inserted_at: yesterday})
+
+ CommonAPI.post(user, %{status: "cofe"})
+
+ NewUsersDigestWorker.perform(nil, nil)
+ ObanHelpers.perform_all()
+ end
+end
diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs
new file mode 100644
index 000000000..5864f9e5f
--- /dev/null
+++ b/test/workers/cron/purge_expired_activities_worker_test.exs
@@ -0,0 +1,56 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.ActivityExpiration
+ alias Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker
+
+ import Pleroma.Factory
+ import ExUnit.CaptureLog
+
+ setup do: clear_config([ActivityExpiration, :enabled])
+
+ test "deletes an expiration activity" do
+ Pleroma.Config.put([ActivityExpiration, :enabled], true)
+ activity = insert(:note_activity)
+
+ naive_datetime =
+ NaiveDateTime.add(
+ NaiveDateTime.utc_now(),
+ -:timer.minutes(2),
+ :millisecond
+ )
+
+ expiration =
+ insert(
+ :expiration_in_the_past,
+ %{activity_id: activity.id, scheduled_at: naive_datetime}
+ )
+
+ Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid)
+
+ refute Pleroma.Repo.get(Pleroma.Activity, activity.id)
+ refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id)
+ end
+
+ describe "delete_activity/1" do
+ test "adds log message if activity isn't find" do
+ assert capture_log([level: :error], fn ->
+ PurgeExpiredActivitiesWorker.delete_activity(%ActivityExpiration{
+ activity_id: "test-activity"
+ })
+ end) =~ "Couldn't delete expired activity: not found activity"
+ end
+
+ test "adds log message if actor isn't find" do
+ assert capture_log([level: :error], fn ->
+ PurgeExpiredActivitiesWorker.delete_activity(%ActivityExpiration{
+ activity_id: "test-activity"
+ })
+ end) =~ "Couldn't delete expired activity: not found activity"
+ end
+ end
+end
diff --git a/test/workers/scheduled_activity_worker_test.exs b/test/workers/scheduled_activity_worker_test.exs
new file mode 100644
index 000000000..b312d975b
--- /dev/null
+++ b/test/workers/scheduled_activity_worker_test.exs
@@ -0,0 +1,52 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.ScheduledActivityWorkerTest do
+ use Pleroma.DataCase
+
+ alias Pleroma.ScheduledActivity
+ alias Pleroma.Workers.ScheduledActivityWorker
+
+ import Pleroma.Factory
+ import ExUnit.CaptureLog
+
+ setup do: clear_config([ScheduledActivity, :enabled])
+
+ test "creates a status from the scheduled activity" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], true)
+ user = insert(:user)
+
+ naive_datetime =
+ NaiveDateTime.add(
+ NaiveDateTime.utc_now(),
+ -:timer.minutes(2),
+ :millisecond
+ )
+
+ scheduled_activity =
+ insert(
+ :scheduled_activity,
+ scheduled_at: naive_datetime,
+ user: user,
+ params: %{status: "hi"}
+ )
+
+ ScheduledActivityWorker.perform(
+ %{"activity_id" => scheduled_activity.id},
+ :pid
+ )
+
+ refute Repo.get(ScheduledActivity, scheduled_activity.id)
+ activity = Repo.all(Pleroma.Activity) |> Enum.find(&(&1.actor == user.ap_id))
+ assert Pleroma.Object.normalize(activity).data["content"] == "hi"
+ end
+
+ test "adds log message if ScheduledActivity isn't find" do
+ Pleroma.Config.put([ScheduledActivity, :enabled], true)
+
+ assert capture_log([level: :error], fn ->
+ ScheduledActivityWorker.perform(%{"activity_id" => 42}, :pid)
+ end) =~ "Couldn't find scheduled activity"
+ end
+end
diff --git a/test/xml_builder_test.exs b/test/xml_builder_test.exs
index a7742f339..059384c34 100644
--- a/test/xml_builder_test.exs
+++ b/test/xml_builder_test.exs
@@ -1,5 +1,5 @@
# Pleroma: A lightweight social networking server
-# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.XmlBuilderTest do