diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4964cf856..8cfdbc8a1 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.50.2. +# using RuboCop version 1.52.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -48,17 +48,14 @@ Layout/SpaceInLambdaLiteral: - 'config/environments/production.rb' - 'config/initializers/content_security_policy.rb' +# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedMethods, AllowedPatterns. Lint/AmbiguousBlockAssociation: Exclude: - - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb' - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - 'spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - 'spec/services/activitypub/process_status_update_service_spec.rb' - 'spec/services/post_status_service_spec.rb' - - 'spec/services/suspend_account_service_spec.rb' - - 'spec/services/unsuspend_account_service_spec.rb' - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' # Configuration parameters: AllowComments, AllowEmptyLambdas. @@ -124,6 +121,7 @@ Lint/UnusedBlockArgument: - 'config/initializers/paperclip.rb' - 'config/initializers/simple_form.rb' +# This cop supports unsafe autocorrection (--autocorrect-all). Lint/UselessAssignment: Exclude: - 'app/services/activitypub/process_status_update_service.rb' @@ -145,6 +143,7 @@ Lint/UselessAssignment: - 'spec/services/resolve_url_service_spec.rb' - 'spec/views/statuses/show.html.haml_spec.rb' +# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: CheckForMethodsWithNoSideEffects. Lint/Void: Exclude: @@ -167,7 +166,7 @@ Metrics/CyclomaticComplexity: # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: - Max: 28 + Max: 27 Naming/AccessorMethodName: Exclude: @@ -180,6 +179,7 @@ Naming/FileName: Exclude: - 'config/locales/sr-Latn.rb' +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyleForLeadingUnderscores. # SupportedStylesForLeadingUnderscores: disallowed, required, optional Naming/MemoizedInstanceVariableName: @@ -322,8 +322,6 @@ RSpec/LetSetup: - 'spec/controllers/admin/statuses_controller_spec.rb' - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - 'spec/controllers/api/v1/tags_controller_spec.rb' @@ -396,7 +394,7 @@ RSpec/MessageSpies: - 'spec/validators/status_length_validator_spec.rb' RSpec/MultipleExpectations: - Max: 19 + Max: 8 # Configuration parameters: AllowSubject. RSpec/MultipleMemoizedHelpers: @@ -424,7 +422,6 @@ RSpec/StubbedMock: RSpec/SubjectDeclaration: Exclude: - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - 'spec/models/account_migration_spec.rb' - 'spec/models/account_spec.rb' - 'spec/models/relationship_filter_spec.rb' @@ -599,7 +596,6 @@ Rails/NegateInclude: - 'app/models/concerns/attachmentable.rb' - 'app/models/concerns/remotable.rb' - 'app/models/custom_filter.rb' - - 'app/models/webhook.rb' - 'app/services/activitypub/process_status_update_service.rb' - 'app/services/fetch_link_card_service.rb' - 'app/services/search_service.rb' @@ -770,11 +766,9 @@ Rails/WhereExists: - 'app/workers/move_worker.rb' - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - 'lib/tasks/tests.rake' - - 'spec/controllers/api/v1/accounts/notes_controller_spec.rb' - 'spec/controllers/api/v1/tags_controller_spec.rb' - 'spec/models/account_spec.rb' - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - 'spec/services/purge_domain_service_spec.rb' - 'spec/services/unallow_domain_service_spec.rb' @@ -796,6 +790,7 @@ Style/ClassVars: Exclude: - 'config/initializers/devise.rb' +# This cop supports unsafe autocorrection (--autocorrect-all). Style/CombinableLoops: Exclude: - 'app/models/form/custom_emoji_batch.rb' diff --git a/Gemfile.lock b/Gemfile.lock index c4fa1822e..ad789db1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -470,8 +470,9 @@ GEM orm_adapter (0.5.0) ox (2.14.16) parallel (1.23.0) - parser (3.2.2.1) + parser (3.2.2.3) ast (~> 2.4.1) + racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) @@ -495,7 +496,7 @@ GEM pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.6.2) + racc (1.7.0) rack (2.2.7) rack-attack (6.6.1) rack (>= 1.0, < 3) @@ -557,7 +558,7 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.8.0) + regexp_parser (2.8.1) request_store (1.5.1) rack (>= 1.4) responders (3.1.0) @@ -591,17 +592,17 @@ GEM sidekiq (>= 2.4.0) rspec-support (3.12.0) rspec_chunked (0.6) - rubocop (1.51.0) + rubocop (1.52.1) json (~> 2.3) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) + rubocop-ast (1.29.0) parser (>= 3.2.1.0) rubocop-capybara (2.18.0) rubocop (~> 1.41) diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb index 538c1ffb1..576304d1c 100644 --- a/app/controllers/concerns/captcha_concern.rb +++ b/app/controllers/concerns/captcha_concern.rb @@ -2,6 +2,7 @@ module CaptchaConcern extend ActiveSupport::Concern + include Hcaptcha::Adapters::ViewMethods included do @@ -35,18 +36,22 @@ module CaptchaConcern flash.delete(:hcaptcha_error) yield message end + false end end def extend_csp_for_captcha! policy = request.content_security_policy + return unless captcha_required? && policy.present? %w(script_src frame_src style_src connect_src).each do |directive| values = policy.send(directive) + values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:') values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:') + policy.send(directive, *values) end end diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb new file mode 100644 index 000000000..b071a8060 --- /dev/null +++ b/app/controllers/mail_subscriptions_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class MailSubscriptionsController < ApplicationController + layout 'auth' + + skip_before_action :require_functional! + + before_action :set_body_classes + before_action :set_user + before_action :set_type + + def show; end + + def create + @user.settings[email_type_from_param] = false + @user.save! + end + + private + + def set_user + @user = GlobalID::Locator.locate_signed(params[:token], for: 'unsubscribe') + end + + def set_body_classes + @body_classes = 'lighter' + end + + def set_type + @type = email_type_from_param + end + + def email_type_from_param + case params[:type] + when 'follow', 'reblog', 'favourite', 'mention', 'follow_request' + "notification_emails.#{params[:type]}" + else + raise ArgumentError + end + end +end diff --git a/app/javascript/mastodon/components/circular_progress.tsx b/app/javascript/mastodon/components/circular_progress.tsx new file mode 100644 index 000000000..850eb93e4 --- /dev/null +++ b/app/javascript/mastodon/components/circular_progress.tsx @@ -0,0 +1,27 @@ +interface Props { + size: number; + strokeWidth: number; +} + +export const CircularProgress: React.FC = ({ size, strokeWidth }) => { + const viewBox = `0 0 ${size} ${size}`; + const radius = (size - strokeWidth) / 2; + + return ( + + + + ); +}; diff --git a/app/javascript/mastodon/components/dropdown_menu.jsx b/app/javascript/mastodon/components/dropdown_menu.jsx index 3cfa0ee12..0416df5d4 100644 --- a/app/javascript/mastodon/components/dropdown_menu.jsx +++ b/app/javascript/mastodon/components/dropdown_menu.jsx @@ -8,8 +8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { supportsPassiveEvents } from 'detect-passive-events'; import Overlay from 'react-overlays/Overlay'; -import { CircularProgress } from 'mastodon/components/loading_indicator'; - +import { CircularProgress } from "./circular_progress"; import { IconButton } from './icon_button'; const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; diff --git a/app/javascript/mastodon/components/load_pending.jsx b/app/javascript/mastodon/components/load_pending.jsx deleted file mode 100644 index e9c1a9783..000000000 --- a/app/javascript/mastodon/components/load_pending.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -export default class LoadPending extends PureComponent { - - static propTypes = { - onClick: PropTypes.func, - count: PropTypes.number, - }; - - render() { - const { count } = this.props; - - return ( - - ); - } - -} diff --git a/app/javascript/mastodon/components/load_pending.tsx b/app/javascript/mastodon/components/load_pending.tsx new file mode 100644 index 000000000..f7589622e --- /dev/null +++ b/app/javascript/mastodon/components/load_pending.tsx @@ -0,0 +1,18 @@ +import { FormattedMessage } from 'react-intl'; + +interface Props { + onClick: (event: React.MouseEvent) => void; + count: number; +} + +export const LoadPending: React.FC = ({ onClick, count }) => { + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/loading_indicator.jsx b/app/javascript/mastodon/components/loading_indicator.jsx deleted file mode 100644 index c3f7a4e9e..000000000 --- a/app/javascript/mastodon/components/loading_indicator.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; - -export const CircularProgress = ({ size, strokeWidth }) => { - const viewBox = `0 0 ${size} ${size}`; - const radius = (size - strokeWidth) / 2; - - return ( - - - - ); -}; - -CircularProgress.propTypes = { - size: PropTypes.number.isRequired, - strokeWidth: PropTypes.number.isRequired, -}; - -const LoadingIndicator = () => ( -
- -
-); - -export default LoadingIndicator; diff --git a/app/javascript/mastodon/components/loading_indicator.tsx b/app/javascript/mastodon/components/loading_indicator.tsx new file mode 100644 index 000000000..6bc24a0d6 --- /dev/null +++ b/app/javascript/mastodon/components/loading_indicator.tsx @@ -0,0 +1,7 @@ +import { CircularProgress } from './circular_progress'; + +export const LoadingIndicator: React.FC = () => ( +
+ +
+); diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 53a84ecb5..ce0b579f5 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -16,8 +16,8 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import { LoadMore } from './load_more'; -import LoadPending from './load_pending'; -import LoadingIndicator from './loading_indicator'; +import { LoadPending } from './load_pending'; +import { LoadingIndicator } from './loading_indicator'; const MOUSE_IDLE_DELAY = 300; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index a206bcc3b..5eea1abf0 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -374,7 +374,7 @@ class Header extends ImmutablePureComponent { let badge; if (account.get('bot')) { - badge = (
); + badge = (
); } else if (account.get('group')) { badge = (
); } else { diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx index 653a25866..19c10fa9f 100644 --- a/app/javascript/mastodon/features/account_gallery/index.jsx +++ b/app/javascript/mastodon/features/account_gallery/index.jsx @@ -10,7 +10,7 @@ import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import ColumnBackButton from 'mastodon/components/column_back_button'; import { LoadMore } from 'mastodon/components/load_more'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollContainer from 'mastodon/containers/scroll_container'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx index 7bb0152ce..0f18c043b 100644 --- a/app/javascript/mastodon/features/account_timeline/index.jsx +++ b/app/javascript/mastodon/features/account_timeline/index.jsx @@ -17,7 +17,7 @@ import { lookupAccount, fetchAccount } from '../../actions/accounts'; import { fetchFeaturedTags } from '../../actions/featured_tags'; import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines'; import ColumnBackButton from '../../components/column_back_button'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import StatusList from '../../components/status_list'; import Column from '../ui/components/column'; diff --git a/app/javascript/mastodon/features/blocks/index.jsx b/app/javascript/mastodon/features/blocks/index.jsx index 66e42dc41..d976174ce 100644 --- a/app/javascript/mastodon/features/blocks/index.jsx +++ b/app/javascript/mastodon/features/blocks/index.jsx @@ -10,7 +10,7 @@ import { debounce } from 'lodash'; import { fetchBlocks, expandBlocks } from '../../actions/blocks'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/mastodon/features/directory/index.jsx b/app/javascript/mastodon/features/directory/index.jsx index 635b6f411..df2533196 100644 --- a/app/javascript/mastodon/features/directory/index.jsx +++ b/app/javascript/mastodon/features/directory/index.jsx @@ -14,7 +14,7 @@ import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; import { LoadMore } from 'mastodon/components/load_more'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RadioButton } from 'mastodon/components/radio_button'; import ScrollContainer from 'mastodon/containers/scroll_container'; diff --git a/app/javascript/mastodon/features/domain_blocks/index.jsx b/app/javascript/mastodon/features/domain_blocks/index.jsx index 34f0afa2b..9e63b2f81 100644 --- a/app/javascript/mastodon/features/domain_blocks/index.jsx +++ b/app/javascript/mastodon/features/domain_blocks/index.jsx @@ -12,7 +12,7 @@ import { debounce } from 'lodash'; import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import DomainContainer from '../../containers/domain_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.d.ts b/app/javascript/mastodon/features/emoji/emoji_compressed.d.ts new file mode 100644 index 000000000..2408942ed --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.d.ts @@ -0,0 +1,51 @@ +import type { BaseEmoji, EmojiData, NimbleEmojiIndex } from 'emoji-mart'; +import type { Category, Data, Emoji } from 'emoji-mart/dist-es/utils/data'; + +/* + * The 'search' property, although not defined in the [`Emoji`]{@link node_modules/@types/emoji-mart/dist-es/utils/data.d.ts#Emoji} type, + * is used in the application. + * This could be due to an oversight by the library maintainer. + * The `search` property is defined and used [here]{@link node_modules/emoji-mart/dist/utils/data.js#uncompress}. + */ +export type Search = string; +/* + * The 'skins' property does not exist in the application data. + * This could be a potential area of refactoring or error handling. + * The non-existence of 'skins' property is evident at [this location]{@link app/javascript/mastodon/features/emoji/emoji_compressed.js:121}. + */ +export type Skins = null; + +export type FilenameData = string[] | string[][]; +export type ShortCodesToEmojiDataKey = + | EmojiData['id'] + | BaseEmoji['native'] + | keyof NimbleEmojiIndex['emojis']; + +export type SearchData = [ + BaseEmoji['native'], + Emoji['short_names'], + Search, + Emoji['unified'] +]; + +export interface ShortCodesToEmojiData { + [key: ShortCodesToEmojiDataKey]: [FilenameData, SearchData]; +} +export type EmojisWithoutShortCodes = FilenameData[]; + +export type EmojiCompressed = [ + ShortCodesToEmojiData, + Skins, + Category[], + Data['aliases'], + EmojisWithoutShortCodes +]; + +/* + * `emoji_compressed.js` uses `babel-plugin-preval`, which makes it difficult to convert to TypeScript. + * As a temporary solution, we are allowing a default export here to apply the TypeScript type `EmojiCompressed` to the JS file export. + * - {@link app/javascript/mastodon/features/emoji/emoji_compressed.js} + */ +declare const emojiCompressed: EmojiCompressed; + +export default emojiCompressed; // eslint-disable-line import/no-default-export diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js index 3d577e50f..883bf21a4 100644 --- a/app/javascript/mastodon/features/emoji/emoji_compressed.js +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -118,6 +118,16 @@ Object.keys(emojiIndex.emojis).forEach(key => { // inconsistent behavior in dev mode module.exports = JSON.parse(JSON.stringify([ shortCodesToEmojiData, + /* + * The property `skins` is not found in the current context. + * This could potentially lead to issues when interacting with modules or data structures + * that expect the presence of `skins` property. + * Currently, no definitions or references to `skins` property can be found in: + * - {@link node_modules/emoji-mart/dist/utils/data.js} + * - {@link node_modules/emoji-mart/data/all.json} + * - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins} + * Future refactorings or updates should consider adding definitions or handling for `skins` property. + */ emojiMartData.skins, emojiMartData.categories, emojiMartData.aliases, diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js deleted file mode 100644 index 11698937c..000000000 --- a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js +++ /dev/null @@ -1,43 +0,0 @@ -// The output of this module is designed to mimic emoji-mart's -// "data" object, such that we can use it for a light version of emoji-mart's -// emojiIndex.search functionality. -import emojiCompressed from './emoji_compressed'; -import { unicodeToUnifiedName } from './unicode_to_unified_name'; - -const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed; - -const emojis = {}; - -// decompress -Object.keys(shortCodesToEmojiData).forEach((shortCode) => { - let [ - filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars - searchData, - ] = shortCodesToEmojiData[shortCode]; - let [ - native, - short_names, - search, - unified, - ] = searchData; - - if (!unified) { - // unified name can be derived from unicodeToUnifiedName - unified = unicodeToUnifiedName(native); - } - - short_names = [shortCode].concat(short_names); - emojis[shortCode] = { - native, - search, - short_names, - unified, - }; -}); - -export { - emojis, - skins, - categories, - short_names, -}; diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.ts b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.ts new file mode 100644 index 000000000..62cb84baf --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.ts @@ -0,0 +1,52 @@ +// The output of this module is designed to mimic emoji-mart's +// "data" object, such that we can use it for a light version of emoji-mart's +// emojiIndex.search functionality. +import type { BaseEmoji } from 'emoji-mart'; +import type { Emoji } from 'emoji-mart/dist-es/utils/data'; + +import type { Search, ShortCodesToEmojiData } from './emoji_compressed'; +import emojiCompressed from './emoji_compressed'; +import { unicodeToUnifiedName } from './unicode_to_unified_name'; + +type Emojis = { + [key in keyof ShortCodesToEmojiData]: { + native: BaseEmoji['native']; + search: Search; + short_names: Emoji['short_names']; + unified: Emoji['unified']; + }; +}; + +const [ + shortCodesToEmojiData, + skins, + categories, + short_names, + _emojisWithoutShortCodes, +] = emojiCompressed; + +const emojis: Emojis = {}; + +// decompress +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + const [_filenameData, searchData] = shortCodesToEmojiData[shortCode]; + const native = searchData[0]; + let short_names = searchData[1]; + const search = searchData[2]; + let unified = searchData[3]; + + if (!unified) { + // unified name can be derived from unicodeToUnifiedName + unified = unicodeToUnifiedName(native); + } + + if (short_names) short_names = [shortCode].concat(short_names); + emojis[shortCode] = { + native, + search, + short_names, + unified, + }; +}); + +export { emojis, skins, categories, short_names }; diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index fba958993..df91337fd 100644 --- a/app/javascript/mastodon/features/explore/links.jsx +++ b/app/javascript/mastodon/features/explore/links.jsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { fetchTrendingLinks } from 'mastodon/actions/trends'; import DismissableBanner from 'mastodon/components/dismissable_banner'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import Story from './components/story'; diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx index dc1f72022..bf2f0b95a 100644 --- a/app/javascript/mastodon/features/explore/results.jsx +++ b/app/javascript/mastodon/features/explore/results.jsx @@ -12,7 +12,7 @@ import { connect } from 'react-redux'; import { expandSearch } from 'mastodon/actions/search'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { LoadMore } from 'mastodon/components/load_more'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import Account from 'mastodon/containers/account_container'; import Status from 'mastodon/containers/status_container'; diff --git a/app/javascript/mastodon/features/explore/suggestions.jsx b/app/javascript/mastodon/features/explore/suggestions.jsx index bcb710f3e..f2907cdb2 100644 --- a/app/javascript/mastodon/features/explore/suggestions.jsx +++ b/app/javascript/mastodon/features/explore/suggestions.jsx @@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { fetchSuggestions } from 'mastodon/actions/suggestions'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import AccountCard from 'mastodon/features/directory/components/account_card'; const mapStateToProps = state => ({ diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx index 1859c6279..ba6f31cd0 100644 --- a/app/javascript/mastodon/features/explore/tags.jsx +++ b/app/javascript/mastodon/features/explore/tags.jsx @@ -9,7 +9,7 @@ import { connect } from 'react-redux'; import { fetchTrendingHashtags } from 'mastodon/actions/trends'; import DismissableBanner from 'mastodon/components/dismissable_banner'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; const mapStateToProps = state => ({ hashtags: state.getIn(['trends', 'tags', 'items']), diff --git a/app/javascript/mastodon/features/favourites/index.jsx b/app/javascript/mastodon/features/favourites/index.jsx index 8ea137fd2..57911c3a1 100644 --- a/app/javascript/mastodon/features/favourites/index.jsx +++ b/app/javascript/mastodon/features/favourites/index.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import { fetchFavourites } from 'mastodon/actions/interactions'; import ColumnHeader from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; import AccountContainer from 'mastodon/containers/account_container'; import Column from 'mastodon/features/ui/components/column'; diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx index c9a474ea2..ec3939551 100644 --- a/app/javascript/mastodon/features/followers/index.jsx +++ b/app/javascript/mastodon/features/followers/index.jsx @@ -20,7 +20,7 @@ import { expandFollowers, } from '../../actions/accounts'; import ColumnBackButton from '../../components/column_back_button'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx index 7b203341e..c28c3a3b2 100644 --- a/app/javascript/mastodon/features/following/index.jsx +++ b/app/javascript/mastodon/features/following/index.jsx @@ -20,7 +20,7 @@ import { expandFollowing, } from '../../actions/accounts'; import ColumnBackButton from '../../components/column_back_button'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx index f9f3a7c31..a5acc416e 100644 --- a/app/javascript/mastodon/features/list_timeline/index.jsx +++ b/app/javascript/mastodon/features/list_timeline/index.jsx @@ -18,7 +18,7 @@ import { expandListTimeline } from 'mastodon/actions/timelines'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RadioButton } from 'mastodon/components/radio_button'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import StatusListContainer from 'mastodon/features/ui/containers/status_list_container'; diff --git a/app/javascript/mastodon/features/lists/index.jsx b/app/javascript/mastodon/features/lists/index.jsx index 3bc2cd3a9..ecec4b68d 100644 --- a/app/javascript/mastodon/features/lists/index.jsx +++ b/app/javascript/mastodon/features/lists/index.jsx @@ -12,7 +12,7 @@ import { createSelector } from 'reselect'; import { fetchLists } from 'mastodon/actions/lists'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; import ColumnLink from 'mastodon/features/ui/components/column_link'; import ColumnSubheading from 'mastodon/features/ui/components/column_subheading'; diff --git a/app/javascript/mastodon/features/mutes/index.jsx b/app/javascript/mastodon/features/mutes/index.jsx index 1c74f34ea..947fe4c9b 100644 --- a/app/javascript/mastodon/features/mutes/index.jsx +++ b/app/javascript/mastodon/features/mutes/index.jsx @@ -12,7 +12,7 @@ import { debounce } from 'lodash'; import { fetchMutes, expandMutes } from '../../actions/mutes'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx index 90412aa3b..cb50b62cd 100644 --- a/app/javascript/mastodon/features/notifications/components/report.jsx +++ b/app/javascript/mastodon/features/notifications/components/report.jsx @@ -8,10 +8,12 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { AvatarOverlay } from 'mastodon/components/avatar_overlay'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +// This needs to be kept in sync with app/models/report.rb const messages = defineMessages({ openReport: { id: 'report_notification.open', defaultMessage: 'Open report' }, other: { id: 'report_notification.categories.other', defaultMessage: 'Other' }, spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' }, + legal: { id: 'report_notification.categories.legal', defaultMessage: 'Legal' }, violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' }, }); diff --git a/app/javascript/mastodon/features/reblogs/index.jsx b/app/javascript/mastodon/features/reblogs/index.jsx index 31048eba0..8bcef863f 100644 --- a/app/javascript/mastodon/features/reblogs/index.jsx +++ b/app/javascript/mastodon/features/reblogs/index.jsx @@ -12,7 +12,7 @@ import { Icon } from 'mastodon/components/icon'; import { fetchReblogs } from '../../actions/interactions'; import ColumnHeader from '../../components/column_header'; -import LoadingIndicator from '../../components/loading_indicator'; +import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/mastodon/features/report/statuses.jsx b/app/javascript/mastodon/features/report/statuses.jsx index 78408c96b..4ba8d6065 100644 --- a/app/javascript/mastodon/features/report/statuses.jsx +++ b/app/javascript/mastodon/features/report/statuses.jsx @@ -8,7 +8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import Button from 'mastodon/components/button'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container'; const mapStateToProps = (state, { accountId }) => ({ diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index c31607c8e..892cfbb7b 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -14,7 +14,7 @@ import { createSelector } from 'reselect'; import { HotKeys } from 'react-hotkeys'; import { Icon } from 'mastodon/components/icon'; -import LoadingIndicator from 'mastodon/components/loading_indicator'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollContainer from 'mastodon/containers/scroll_container'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.jsx b/app/javascript/mastodon/features/ui/components/modal_loading.jsx index 7a6bbf5fa..7d19e7351 100644 --- a/app/javascript/mastodon/features/ui/components/modal_loading.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_loading.jsx @@ -1,4 +1,4 @@ -import LoadingIndicator from '../../../components/loading_indicator'; +import { LoadingIndicator } from '../../../components/loading_indicator'; // Keep the markup in sync with // (make sure they have the same dimensions) diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ef0964b19..9f7ffad66 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -13,7 +13,7 @@ "about.rules": "Server rules", "account.account_note_header": "Note", "account.add_or_remove_from_list": "Add or Remove from lists", - "account.badges.bot": "Bot", + "account.badges.bot": "Automated", "account.badges.group": "Group", "account.block": "Block @{name}", "account.block_domain": "Block domain {domain}", @@ -553,6 +553,7 @@ "report.unfollow": "Unfollow @{name}", "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.", "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached", + "report_notification.categories.legal": "Legal", "report_notification.categories.other": "Other", "report_notification.categories.spam": "Spam", "report_notification.categories.violation": "Rule violation", diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index d63a42557..81a656a60 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1048,7 +1048,9 @@ code { } .simple_form .h-captcha { - text-align: center; + display: flex; + justify-content: center; + margin-bottom: 30px; } .permissions-list { diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index c428fd30d..7cd3bab1a 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -8,61 +8,71 @@ class NotificationMailer < ApplicationMailer def mention(recipient, notification) @me = recipient + @user = recipient.user + @type = 'mention' @status = notification.target_status - return unless @me.user.functional? && @status.present? + return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) + mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) end end def follow(recipient, notification) @me = recipient + @user = recipient.user + @type = 'follow' @account = notification.from_account - return unless @me.user.functional? + return unless @user.functional? locale_for_account(@me) do - mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) + mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) end end def favourite(recipient, notification) @me = recipient + @user = recipient.user + @type = 'favourite' @account = notification.from_account @status = notification.target_status - return unless @me.user.functional? && @status.present? + return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) + mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) end end def reblog(recipient, notification) @me = recipient + @user = recipient.user + @type = 'reblog' @account = notification.from_account @status = notification.target_status - return unless @me.user.functional? && @status.present? + return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) + mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) end end def follow_request(recipient, notification) @me = recipient + @user = recipient.user + @type = 'follow_request' @account = notification.from_account - return unless @me.user.functional? + return unless @user.functional? locale_for_account(@me) do - mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) + mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) end end diff --git a/app/models/report.rb b/app/models/report.rb index 674cc9a27..533e3f72a 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -48,6 +48,7 @@ class Report < ApplicationRecord validate :validate_rule_ids + # entries here needs to be kept in sync with app/javascript/mastodon/features/notifications/components/report.jsx enum category: { other: 0, spam: 1_000, diff --git a/app/views/auth/confirmations/captcha.html.haml b/app/views/auth/confirmations/captcha.html.haml index 77f4b35b4..964d0e63e 100644 --- a/app/views/auth/confirmations/captcha.html.haml +++ b/app/views/auth/confirmations/captcha.html.haml @@ -7,10 +7,12 @@ = hidden_field_tag :confirmation_token, params[:confirmation_token] = hidden_field_tag :redirect_to_app, params[:redirect_to_app] + %h1.title= t('auth.captcha_confirmation.title') %p.lead= t('auth.captcha_confirmation.hint_html') - .field-group - = render_captcha + = render_captcha + + %p.lead= t('auth.captcha_confirmation.help_html', email: mail_to(Setting.site_contact_email, nil)) .actions - %button.button= t('challenge.confirm') + = button_tag t('challenge.confirm'), class: 'button', type: :submit diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml index 288c473d2..40fea2cf8 100644 --- a/app/views/layouts/mailer.html.haml +++ b/app/views/layouts/mailer.html.haml @@ -44,7 +44,11 @@ %tbody %td.column-cell %p= t 'about.hosted_on', domain: site_hostname - %p= link_to t('application_mailer.notification_preferences'), settings_preferences_notifications_url + %p + = link_to t('application_mailer.notification_preferences'), settings_preferences_notifications_url + - if defined?(@type) + ยท + = link_to t('application_mailer.unsubscribe'), unsubscribe_url(token: @user.to_sgid(for: 'unsubscribe').to_s, type: @type) %td.column-cell.text-right = link_to root_url do = image_tag full_pack_url('media/images/mailer/logo.png'), alt: 'Mastodon', height: 24 diff --git a/app/views/mail_subscriptions/create.html.haml b/app/views/mail_subscriptions/create.html.haml new file mode 100644 index 000000000..16ee486b0 --- /dev/null +++ b/app/views/mail_subscriptions/create.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('mail_subscriptions.unsubscribe.title') + +.simple_form + %h1.title= t('mail_subscriptions.unsubscribe.complete') + %p.lead + = t('mail_subscriptions.unsubscribe.success_html', domain: content_tag(:strong, site_hostname), type: content_tag(:strong, I18n.t(@type, scope: 'mail_subscriptions.unsubscribe.emails')), email: content_tag(:strong, @user.email)) + %p.lead + = t('mail_subscriptions.unsubscribe.resubscribe_html', settings_path: settings_preferences_notifications_path) diff --git a/app/views/mail_subscriptions/show.html.haml b/app/views/mail_subscriptions/show.html.haml new file mode 100644 index 000000000..afa2ab6ed --- /dev/null +++ b/app/views/mail_subscriptions/show.html.haml @@ -0,0 +1,12 @@ +- content_for :page_title do + = t('mail_subscriptions.unsubscribe.title') + +.simple_form + %h1.title= t('mail_subscriptions.unsubscribe.title') + %p.lead + = t('mail_subscriptions.unsubscribe.confirmation_html', domain: content_tag(:strong, site_hostname), type: content_tag(:strong, I18n.t(@type, scope: 'mail_subscriptions.unsubscribe.emails')), email: content_tag(:strong, @user.email), settings_path: settings_preferences_notifications_path) + + = form_tag unsubscribe_path, method: :post do + = hidden_field_tag :token, params[:token] + = hidden_field_tag :type, params[:type] + = button_tag t('mail_subscriptions.unsubscribe.action'), type: :submit diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 92e1c8c7b..fabb10801 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -74,6 +74,7 @@ ignore_unused: - 'notification_mailer.*' - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' + - 'mail_subscriptions.unsubscribe.emails.*' ignore_inconsistent_interpolations: - '*.one' diff --git a/config/locales/en.yml b/config/locales/en.yml index 2c292c42d..f4944cca2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -978,6 +978,7 @@ en: notification_preferences: Change e-mail preferences salutation: "%{name}," settings: 'Change e-mail preferences: %{link}' + unsubscribe: Unsubscribe view: 'View:' view_profile: View profile view_status: View post @@ -992,8 +993,9 @@ en: auth: apply_for_account: Request an account captcha_confirmation: - hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can contact the server administrator if you have questions or need assistance with confirming your account. - title: User verification + help_html: If you have issues solving the CAPTCHA, you can get in touch with us through %{email} and we can assist you. + hint_html: Just one more thing! We need to confirm you're a human (this is so we can keep the spam out!). Solve the CAPTCHA below and click "Continue". + title: Security check change_password: Password confirmations: wrong_email_hint: If that e-mail address is not correct, you can change it in account settings. @@ -1342,6 +1344,21 @@ en: failed_sign_in_html: Failed sign-in attempt with %{method} from %{ip} (%{browser}) successful_sign_in_html: Successful sign-in with %{method} from %{ip} (%{browser}) title: Authentication history + mail_subscriptions: + unsubscribe: + action: Yes, unsubscribe + complete: Unsubscribed + confirmation_html: Are you sure you want to unsubscribe from receiving %{type} for Mastodon on %{domain} to your e-mail at %{email}? You can always re-subscribe from your e-mail notification settings. + emails: + notification_emails: + favourite: favorite notification e-mails + follow: follow notification e-mails + follow_request: follow request e-mails + mention: mention notification e-mails + reblog: boost notification e-mails + resubscribe_html: If you've unsubscribed by mistake, you can re-subscribe from your e-mail notification settings. + success_html: You'll no longer receive %{type} for Mastodon on %{domain} to your e-mail at %{email}. + title: Unsubscribe media_attachments: validations: images_and_video: Cannot attach a video to a post that already contains images diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 330c8732f..a99a50e99 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -168,7 +168,7 @@ en: defaults: autofollow: Invite to follow your account avatar: Avatar - bot: This is a bot account + bot: This is an automated account chosen_languages: Filter languages confirm_new_password: Confirm new password confirm_password: Confirm password diff --git a/config/routes.rb b/config/routes.rb index ec5e34efd..7a46624ee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -68,6 +68,8 @@ Rails.application.routes.draw do devise_scope :user do get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite + resource :unsubscribe, only: [:show, :create], controller: :mail_subscriptions + namespace :auth do resource :setup, only: [:show, :update], controller: :setup resource :challenge, only: [:create], controller: :challenges diff --git a/lib/mastodon/cli/cache.rb b/lib/mastodon/cli/cache.rb index 105d4b3c3..e8a2ac161 100644 --- a/lib/mastodon/cli/cache.rb +++ b/lib/mastodon/cli/cache.rb @@ -23,22 +23,12 @@ module Mastodon::CLI def recount(type) case type when 'accounts' - processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account| - account_stat = account.account_stat - account_stat.following_count = account.active_relationships.count - account_stat.followers_count = account.passive_relationships.count - account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count - - account_stat.save if account_stat.changed? + processed, = parallelize_with_progress(accounts_with_stats) do |account| + recount_account_stats(account) end when 'statuses' - processed, = parallelize_with_progress(Status.includes(:status_stat)) do |status| - status_stat = status.status_stat - status_stat.replies_count = status.replies.where.not(visibility: :direct).count - status_stat.reblogs_count = status.reblogs.count - status_stat.favourites_count = status.favourites.count - - status_stat.save if status_stat.changed? + processed, = parallelize_with_progress(statuses_with_stats) do |status| + recount_status_stats(status) end else say("Unknown type: #{type}", :red) @@ -48,5 +38,35 @@ module Mastodon::CLI say say("OK, recounted #{processed} records", :green) end + + private + + def accounts_with_stats + Account.local.includes(:account_stat) + end + + def statuses_with_stats + Status.includes(:status_stat) + end + + def recount_account_stats(account) + account.account_stat.tap do |account_stat| + account_stat.following_count = account.active_relationships.count + account_stat.followers_count = account.passive_relationships.count + account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count + + account_stat.save if account_stat.changed? + end + end + + def recount_status_stats(status) + status.status_stat.tap do |status_stat| + status_stat.replies_count = status.replies.where.not(visibility: :direct).count + status_stat.reblogs_count = status.reblogs.count + status_stat.favourites_count = status.favourites.count + + status_stat.save if status_stat.changed? + end + end end end diff --git a/lib/mastodon/cli/feeds.rb b/lib/mastodon/cli/feeds.rb index 34617e753..3467dd427 100644 --- a/lib/mastodon/cli/feeds.rb +++ b/lib/mastodon/cli/feeds.rb @@ -19,7 +19,7 @@ module Mastodon::CLI LONG_DESC def build(username = nil) if options[:all] || username.nil? - processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account| + processed, = parallelize_with_progress(active_user_accounts) do |account| PrecomputeFeedService.new.call(account) unless dry_run? end @@ -47,5 +47,11 @@ module Mastodon::CLI redis.del(keys) say('OK', :green) end + + private + + def active_user_accounts + Account.joins(:user).merge(User.active) + end end end diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 6bc07fa9e..993ead636 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -48,25 +48,32 @@ describe Api::V1::Accounts::RelationshipsController do expect(response).to have_http_status(200) end - it 'returns JSON with correct data' do - json = body_as_json - - expect(json).to be_a Enumerable - expect(json.first[:id]).to eq simon.id.to_s - expect(json.first[:following]).to be true - expect(json.first[:showing_reblogs]).to be true - expect(json.first[:followed_by]).to be false - expect(json.first[:muting]).to be false - expect(json.first[:requested]).to be false - expect(json.first[:domain_blocking]).to be false - - expect(json.second[:id]).to eq lewis.id.to_s - expect(json.second[:following]).to be false - expect(json.second[:showing_reblogs]).to be false - expect(json.second[:followed_by]).to be true - expect(json.second[:muting]).to be false - expect(json.second[:requested]).to be false - expect(json.second[:domain_blocking]).to be false + context 'when there is returned JSON data' do + let(:json) { body_as_json } + + it 'returns an enumerable json' do + expect(json).to be_a Enumerable + end + + it 'returns a correct first element' do + expect(json.first[:id]).to eq simon.id.to_s + expect(json.first[:following]).to be true + expect(json.first[:showing_reblogs]).to be true + expect(json.first[:followed_by]).to be false + expect(json.first[:muting]).to be false + expect(json.first[:requested]).to be false + expect(json.first[:domain_blocking]).to be false + end + + it 'returns a correct second element' do + expect(json.second[:id]).to eq lewis.id.to_s + expect(json.second[:following]).to be false + expect(json.second[:showing_reblogs]).to be false + expect(json.second[:followed_by]).to be true + expect(json.second[:muting]).to be false + expect(json.second[:requested]).to be false + expect(json.second[:domain_blocking]).to be false + end end it 'returns JSON with correct data on cached requests too' do diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 992fb0e89..49d286774 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -55,20 +55,6 @@ RSpec.describe Api::V1::AccountsController do end end - describe 'GET #show' do - let(:scopes) { 'read:accounts' } - - before do - get :show, params: { id: user.account.id } - end - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - end - describe 'POST #follow' do let(:scopes) { 'write:follows' } let(:other_account) { Fabricate(:account, username: 'bob', locked: locked) } diff --git a/spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb deleted file mode 100644 index fe39596df..000000000 --- a/spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb +++ /dev/null @@ -1,358 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Api::V1::Admin::CanonicalEmailBlocksController do - render_views - - let(:role) { UserRole.find_by(name: 'Admin') } - let(:user) { Fabricate(:user, role: role) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:scopes) { 'admin:read:canonical_email_blocks admin:write:canonical_email_blocks' } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - shared_examples 'forbidden for wrong scope' do |wrong_scope| - let(:scopes) { wrong_scope } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { UserRole.find_by(name: wrong_role) } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - describe 'GET #index' do - context 'with wrong scope' do - before do - get :index - end - - it_behaves_like 'forbidden for wrong scope', 'read:statuses' - end - - context 'with wrong role' do - before do - get :index - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - get :index - - expect(response).to have_http_status(200) - end - - context 'when there is no canonical email block' do - it 'returns an empty list' do - get :index - - body = body_as_json - - expect(body).to be_empty - end - end - - context 'when there are canonical email blocks' do - let!(:canonical_email_blocks) { Fabricate.times(5, :canonical_email_block) } - let(:expected_email_hashes) { canonical_email_blocks.pluck(:canonical_email_hash) } - - it 'returns the correct canonical email hashes' do - get :index - - json = body_as_json - - expect(json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes) - end - - context 'with limit param' do - let(:params) { { limit: 2 } } - - it 'returns only the requested number of canonical email blocks' do - get :index, params: params - - json = body_as_json - - expect(json.size).to eq(params[:limit]) - end - end - - context 'with since_id param' do - let(:params) { { since_id: canonical_email_blocks[1].id } } - - it 'returns only the canonical email blocks after since_id' do - get :index, params: params - - canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) - json = body_as_json - - expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..]) - end - end - - context 'with max_id param' do - let(:params) { { max_id: canonical_email_blocks[3].id } } - - it 'returns only the canonical email blocks before max_id' do - get :index, params: params - - canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) - json = body_as_json - - expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2]) - end - end - end - end - - describe 'GET #show' do - let!(:canonical_email_block) { Fabricate(:canonical_email_block) } - let(:params) { { id: canonical_email_block.id } } - - context 'with wrong scope' do - before do - get :show, params: params - end - - it_behaves_like 'forbidden for wrong scope', 'read:statuses' - end - - context 'with wrong role' do - before do - get :show, params: params - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - context 'when canonical email block exists' do - it 'returns http success' do - get :show, params: params - - expect(response).to have_http_status(200) - end - - it 'returns canonical email block data correctly' do - get :show, params: params - - json = body_as_json - - expect(json[:id]).to eq(canonical_email_block.id.to_s) - expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) - end - end - - context 'when canonical block does not exist' do - it 'returns http not found' do - get :show, params: { id: 0 } - - expect(response).to have_http_status(404) - end - end - end - - describe 'POST #test' do - context 'with wrong scope' do - before do - post :test - end - - it_behaves_like 'forbidden for wrong scope', 'read:statuses' - end - - context 'with wrong role' do - before do - post :test, params: { email: 'whatever@email.com' } - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - context 'when required email is not provided' do - it 'returns http bad request' do - post :test - - expect(response).to have_http_status(400) - end - end - - context 'when required email is provided' do - let(:params) { { email: 'example@email.com' } } - - context 'when there is a matching canonical email block' do - let!(:canonical_email_block) { CanonicalEmailBlock.create(params) } - - it 'returns http success' do - post :test, params: params - - expect(response).to have_http_status(200) - end - - it 'returns expected canonical email hash' do - post :test, params: params - - json = body_as_json - - expect(json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) - end - end - - context 'when there is no matching canonical email block' do - it 'returns http success' do - post :test, params: params - - expect(response).to have_http_status(200) - end - - it 'returns an empty list' do - post :test, params: params - - json = body_as_json - - expect(json).to be_empty - end - end - end - end - - describe 'POST #create' do - let(:params) { { email: 'example@email.com' } } - let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) } - - context 'with wrong scope' do - before do - post :create, params: params - end - - it_behaves_like 'forbidden for wrong scope', 'read:statuses' - end - - context 'with wrong role' do - before do - post :create, params: params - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - post :create, params: params - - expect(response).to have_http_status(200) - end - - it 'returns canonical_email_hash correctly' do - post :create, params: params - - json = body_as_json - - expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) - end - - context 'when required email param is not provided' do - it 'returns http unprocessable entity' do - post :create - - expect(response).to have_http_status(422) - end - end - - context 'when canonical_email_hash param is provided instead of email' do - let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } - - it 'returns http success' do - post :create, params: params - - expect(response).to have_http_status(200) - end - - it 'returns correct canonical_email_hash' do - post :create, params: params - - json = body_as_json - - expect(json[:canonical_email_hash]).to eq(params[:canonical_email_hash]) - end - end - - context 'when both email and canonical_email_hash params are provided' do - let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } - - it 'returns http success' do - post :create, params: params - - expect(response).to have_http_status(200) - end - - it 'ignores canonical_email_hash param' do - post :create, params: params - - json = body_as_json - - expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) - end - end - - context 'when canonical email was already blocked' do - before do - canonical_email_block.save - end - - it 'returns http unprocessable entity' do - post :create, params: params - - expect(response).to have_http_status(422) - end - end - end - - describe 'DELETE #destroy' do - let!(:canonical_email_block) { Fabricate(:canonical_email_block) } - let(:params) { { id: canonical_email_block.id } } - - context 'with wrong scope' do - before do - delete :destroy, params: params - end - - it_behaves_like 'forbidden for wrong scope', 'read:statuses' - end - - context 'with wrong role' do - before do - delete :destroy, params: params - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - delete :destroy, params: params - - expect(response).to have_http_status(200) - end - - context 'when canonical email block is not found' do - it 'returns http not found' do - delete :destroy, params: { id: 0 } - - expect(response).to have_http_status(404) - end - end - end -end diff --git a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb deleted file mode 100644 index 69aeb6451..000000000 --- a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::Admin::DomainAllowsController do - render_views - - let(:role) { UserRole.find_by(name: 'Admin') } - let(:user) { Fabricate(:user, role: role) } - let(:scopes) { 'admin:read admin:write' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - shared_examples 'forbidden for wrong scope' do |wrong_scope| - let(:scopes) { wrong_scope } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { UserRole.find_by(name: wrong_role) } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - describe 'GET #index' do - let!(:domain_allow) { Fabricate(:domain_allow) } - - before do - get :index - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns the expected domain allows' do - json = body_as_json - expect(json.length).to eq 1 - expect(json[0][:id].to_i).to eq domain_allow.id - end - end - - describe 'GET #show' do - let!(:domain_allow) { Fabricate(:domain_allow) } - - before do - get :show, params: { id: domain_allow.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns expected domain name' do - json = body_as_json - expect(json[:domain]).to eq domain_allow.domain - end - end - - describe 'DELETE #destroy' do - let!(:domain_allow) { Fabricate(:domain_allow) } - - before do - delete :destroy, params: { id: domain_allow.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'deletes the block' do - expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil - end - end - - describe 'POST #create' do - let!(:domain_allow) { Fabricate(:domain_allow, domain: 'example.com') } - - context 'with a valid domain' do - before do - post :create, params: { domain: 'foo.bar.com' } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns expected domain name' do - json = body_as_json - expect(json[:domain]).to eq 'foo.bar.com' - end - - it 'creates a domain block' do - expect(DomainAllow.find_by(domain: 'foo.bar.com')).to_not be_nil - end - end - - context 'with invalid domain name' do - before do - post :create, params: { domain: 'foo bar' } - end - - it 'returns http unprocessable entity' do - expect(response).to have_http_status(422) - end - end - - context 'when domain name is not specified' do - it 'returns http unprocessable entity' do - post :create - - expect(response).to have_http_status(422) - end - end - end -end diff --git a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb deleted file mode 100644 index 5659843f7..000000000 --- a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::Admin::DomainBlocksController do - render_views - - let(:role) { UserRole.find_by(name: 'Admin') } - let(:user) { Fabricate(:user, role: role) } - let(:scopes) { 'admin:read admin:write' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - shared_examples 'forbidden for wrong scope' do |wrong_scope| - let(:scopes) { wrong_scope } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { UserRole.find_by(name: wrong_role) } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - describe 'GET #index' do - let!(:block) { Fabricate(:domain_block) } - - before do - get :index - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns the expected domain blocks' do - json = body_as_json - expect(json.length).to eq 1 - expect(json[0][:id].to_i).to eq block.id - end - end - - describe 'GET #show' do - let!(:block) { Fabricate(:domain_block) } - - before do - get :show, params: { id: block.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns expected domain name' do - json = body_as_json - expect(json[:domain]).to eq block.domain - end - end - - describe 'PUT #update' do - let!(:remote_account) { Fabricate(:account, domain: 'example.com') } - let(:subject) do - post :update, params: { id: domain_block.id, domain: 'example.com', severity: new_severity } - end - let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) } - - before do - BlockDomainService.new.call(domain_block) - end - - context 'when downgrading a domain suspension to silence' do - let(:original_severity) { 'suspend' } - let(:new_severity) { 'silence' } - - it 'changes the block severity' do - expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence') - end - - it 'undoes individual suspensions' do - expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false) - end - - it 'performs individual silences' do - expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true) - end - end - - context 'when upgrading a domain silence to suspend' do - let(:original_severity) { 'silence' } - let(:new_severity) { 'suspend' } - - it 'changes the block severity' do - expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend') - end - - it 'undoes individual silences' do - expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false) - end - - it 'performs individual suspends' do - expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true) - end - end - end - - describe 'DELETE #destroy' do - let!(:block) { Fabricate(:domain_block) } - - before do - delete :destroy, params: { id: block.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'deletes the block' do - expect(DomainBlock.find_by(id: block.id)).to be_nil - end - end - - describe 'POST #create' do - let(:existing_block_domain) { 'example.com' } - let!(:block) { Fabricate(:domain_block, domain: existing_block_domain, severity: :suspend) } - - before do - post :create, params: { domain: 'foo.bar.com', severity: :silence } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - - it 'returns expected domain name' do - json = body_as_json - expect(json[:domain]).to eq 'foo.bar.com' - end - - it 'creates a domain block' do - expect(DomainBlock.find_by(domain: 'foo.bar.com')).to_not be_nil - end - - context 'when a stricter domain block already exists' do - let(:existing_block_domain) { 'bar.com' } - - it 'returns http unprocessable entity' do - expect(response).to have_http_status(422) - end - - it 'renders existing domain block in error' do - json = body_as_json - expect(json[:existing_domain_block][:domain]).to eq existing_block_domain - end - end - end -end diff --git a/spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb deleted file mode 100644 index a5787883e..000000000 --- a/spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb +++ /dev/null @@ -1,309 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Api::V1::Admin::IpBlocksController do - render_views - - let(:role) { UserRole.find_by(name: 'Admin') } - let(:user) { Fabricate(:user, role: role) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:scopes) { 'admin:read:ip_blocks admin:write:ip_blocks' } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - shared_examples 'forbidden for wrong scope' do |wrong_scope| - let(:scopes) { wrong_scope } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { UserRole.find_by(name: wrong_role) } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - describe 'GET #index' do - context 'with wrong scope' do - before do - get :index - end - - it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' - end - - context 'with wrong role' do - before do - get :index - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - get :index - - expect(response).to have_http_status(200) - end - - context 'when there is no ip block' do - it 'returns an empty body' do - get :index - - json = body_as_json - - expect(json).to be_empty - end - end - - context 'when there are ip blocks' do - let!(:ip_blocks) do - [ - IpBlock.create(ip: '192.0.2.0/24', severity: :no_access), - IpBlock.create(ip: '172.16.0.1', severity: :sign_up_requires_approval, comment: 'Spam'), - IpBlock.create(ip: '2001:0db8::/32', severity: :sign_up_block, expires_in: 10.days), - ] - end - let(:expected_response) do - ip_blocks.map do |ip_block| - { - id: ip_block.id.to_s, - ip: ip_block.ip, - severity: ip_block.severity.to_s, - comment: ip_block.comment, - created_at: ip_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), - expires_at: ip_block.expires_at&.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), - } - end - end - - it 'returns the correct blocked ips' do - get :index - - json = body_as_json - - expect(json).to match_array(expected_response) - end - - context 'with limit param' do - let(:params) { { limit: 2 } } - - it 'returns only the requested number of ip blocks' do - get :index, params: params - - json = body_as_json - - expect(json.size).to eq(params[:limit]) - end - end - end - end - - describe 'GET #show' do - let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) } - let(:params) { { id: ip_block.id } } - - context 'with wrong scope' do - before do - get :show, params: params - end - - it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' - end - - context 'with wrong role' do - before do - get :show, params: params - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - get :show, params: params - - expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - get :show, params: params - - json = body_as_json - - expect(json[:ip]).to eq("#{ip_block.ip}/#{ip_block.ip.prefix}") - expect(json[:severity]).to eq(ip_block.severity.to_s) - end - - context 'when ip block does not exist' do - it 'returns http not found' do - get :show, params: { id: 0 } - - expect(response).to have_http_status(404) - end - end - end - - describe 'POST #create' do - let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } } - - context 'with wrong scope' do - before do - post :create, params: params - end - - it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks' - end - - context 'with wrong role' do - before do - post :create, params: params - end - - it_behaves_like 'forbidden for wrong role', '' - it_behaves_like 'forbidden for wrong role', 'Moderator' - end - - it 'returns http success' do - post :create, params: params - - expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - post :create, params: params - - json = body_as_json - - expect(json[:ip]).to eq("#{params[:ip]}/32") - expect(json[:severity]).to eq(params[:severity]) - expect(json[:comment]).to eq(params[:comment]) - end - - context 'when ip is not provided' do - let(:params) { { ip: '', severity: 'no_access' } } - - it 'returns http unprocessable entity' do - post :create, params: params - - expect(response).to have_http_status(422) - end - end - - context 'when severity is not provided' do - let(:params) { { ip: '173.65.23.1', severity: '' } } - - it 'returns http unprocessable entity' do - post :create, params: params - - expect(response).to have_http_status(422) - end - end - - context 'when provided ip is already blocked' do - before do - IpBlock.create(params) - end - - it 'returns http unprocessable entity' do - post :create, params: params - - expect(response).to have_http_status(422) - end - end - - context 'when provided ip address is invalid' do - let(:params) { { ip: '520.13.54.120', severity: 'no_access' } } - - it 'returns http unprocessable entity' do - post :create, params: params - - expect(response).to have_http_status(422) - end - end - end - - describe 'PUT #update' do - context 'when ip block exists' do - let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) } - let(:params) { { id: ip_block.id, severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } } - - it 'returns http success' do - put :update, params: params - - expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - put :update, params: params - - json = body_as_json - - expect(json).to match(hash_including({ - ip: "#{ip_block.ip}/#{ip_block.ip.prefix}", - severity: 'sign_up_requires_approval', - comment: 'Decreasing severity', - })) - end - - it 'updates the severity correctly' do - expect { put :update, params: params }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval') - end - - it 'updates the comment correctly' do - expect { put :update, params: params }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity') - end - end - - context 'when ip block does not exist' do - it 'returns http not found' do - put :update, params: { id: 0 } - - expect(response).to have_http_status(404) - end - end - end - - describe 'DELETE #destroy' do - context 'when ip block exists' do - let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') } - let(:params) { { id: ip_block.id } } - - it 'returns http success' do - delete :destroy, params: params - - expect(response).to have_http_status(200) - end - - it 'returns an empty body' do - delete :destroy, params: params - - json = body_as_json - - expect(json).to be_empty - end - - it 'deletes the ip block' do - delete :destroy, params: params - - expect(IpBlock.find_by(id: ip_block.id)).to be_nil - end - end - - context 'when ip block does not exist' do - it 'returns http not found' do - delete :destroy, params: { id: 0 } - - expect(response).to have_http_status(404) - end - end - end -end diff --git a/spec/controllers/api/v1/admin/reports_controller_spec.rb b/spec/controllers/api/v1/admin/reports_controller_spec.rb deleted file mode 100644 index 4f0c484e5..000000000 --- a/spec/controllers/api/v1/admin/reports_controller_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::V1::Admin::ReportsController do - render_views - - let(:role) { UserRole.find_by(name: 'Moderator') } - let(:user) { Fabricate(:user, role: role) } - let(:scopes) { 'admin:read admin:write' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } - let(:report) { Fabricate(:report) } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - shared_examples 'forbidden for wrong scope' do |wrong_scope| - let(:scopes) { wrong_scope } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { UserRole.find_by(name: wrong_role) } - - it 'returns http forbidden' do - expect(response).to have_http_status(403) - end - end - - describe 'GET #index' do - before do - get :index - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end - - describe 'GET #show' do - before do - get :show, params: { id: report.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end - - describe 'POST #resolve' do - before do - post :resolve, params: { id: report.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end - - describe 'POST #reopen' do - before do - post :reopen, params: { id: report.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end - - describe 'POST #assign_to_self' do - before do - post :assign_to_self, params: { id: report.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end - - describe 'POST #unassign' do - before do - post :unassign, params: { id: report.id } - end - - it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', '' - - it 'returns http success' do - expect(response).to have_http_status(200) - end - end -end diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb index 0b807b280..84dfd60b3 100644 --- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb @@ -56,18 +56,11 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do end describe 'when creation succeeds' do + let!(:otp_backup_codes) { user.generate_otp_backup_codes! } + it 'renders page with success' do - otp_backup_codes = user.generate_otp_backup_codes! - expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value| - expect(value).to eq user - otp_backup_codes - end - expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options| - expect(value).to eq user - expect(code).to eq '123456' - expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' }) - true - end + prepare_user_otp_generation + prepare_user_otp_consumption expect do post :create, @@ -80,6 +73,22 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do expect(response).to have_http_status(200) expect(response).to render_template('settings/two_factor_authentication/recovery_codes/index') end + + def prepare_user_otp_generation + expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value| + expect(value).to eq user + otp_backup_codes + end + end + + def prepare_user_otp_consumption + expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options| + expect(value).to eq user + expect(code).to eq '123456' + expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' }) + true + end + end end describe 'when creation fails' do diff --git a/spec/fabricators/domain_allow_fabricator.rb b/spec/fabricators/domain_allow_fabricator.rb index b32af129b..12fdaaea1 100644 --- a/spec/fabricators/domain_allow_fabricator.rb +++ b/spec/fabricators/domain_allow_fabricator.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true Fabricator(:domain_allow) do - domain 'MyString' + domain { sequence(:domain) { |i| "example#{i}.com" } } end diff --git a/spec/fabricators/status_stat_fabricator.rb b/spec/fabricators/status_stat_fabricator.rb new file mode 100644 index 000000000..715e6d4ab --- /dev/null +++ b/spec/fabricators/status_stat_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:status_stat) do + status + replies_count '123' + reblogs_count '456' + favourites_count '789' +end diff --git a/spec/lib/mastodon/cli/cache_spec.rb b/spec/lib/mastodon/cli/cache_spec.rb index f101bc200..3ab42dc8c 100644 --- a/spec/lib/mastodon/cli/cache_spec.rb +++ b/spec/lib/mastodon/cli/cache_spec.rb @@ -4,9 +4,68 @@ require 'rails_helper' require 'mastodon/cli/cache' describe Mastodon::CLI::Cache do + let(:cli) { described_class.new } + describe '.exit_on_failure?' do it 'returns true' do expect(described_class.exit_on_failure?).to be true end end + + describe '#clear' do + before { allow(Rails.cache).to receive(:clear) } + + it 'clears the Rails cache' do + expect { cli.invoke(:clear) }.to output( + a_string_including('OK') + ).to_stdout + expect(Rails.cache).to have_received(:clear) + end + end + + describe '#recount' do + context 'with the `accounts` argument' do + let(:arguments) { ['accounts'] } + let(:account_stat) { Fabricate(:account_stat) } + + before do + account_stat.update(statuses_count: 123) + end + + it 're-calculates account records in the cache' do + expect { cli.invoke(:recount, arguments) }.to output( + a_string_including('OK') + ).to_stdout + + expect(account_stat.reload.statuses_count).to be_zero + end + end + + context 'with the `statuses` argument' do + let(:arguments) { ['statuses'] } + let(:status_stat) { Fabricate(:status_stat) } + + before do + status_stat.update(replies_count: 123) + end + + it 're-calculates account records in the cache' do + expect { cli.invoke(:recount, arguments) }.to output( + a_string_including('OK') + ).to_stdout + + expect(status_stat.reload.replies_count).to be_zero + end + end + + context 'with an unknown type' do + let(:arguments) { ['other-type'] } + + it 'Exits with an error message' do + expect { cli.invoke(:recount, arguments) }.to output( + a_string_including('Unknown') + ).to_stdout.and raise_error(SystemExit) + end + end + end end diff --git a/spec/lib/mastodon/cli/feeds_spec.rb b/spec/lib/mastodon/cli/feeds_spec.rb index 4e1e214ef..030f08721 100644 --- a/spec/lib/mastodon/cli/feeds_spec.rb +++ b/spec/lib/mastodon/cli/feeds_spec.rb @@ -4,9 +4,65 @@ require 'rails_helper' require 'mastodon/cli/feeds' describe Mastodon::CLI::Feeds do + let(:cli) { described_class.new } + describe '.exit_on_failure?' do it 'returns true' do expect(described_class.exit_on_failure?).to be true end end + + describe '#build' do + before { Fabricate(:account) } + + context 'with --all option' do + let(:options) { { all: true } } + + it 'regenerates feeds for all accounts' do + expect { cli.invoke(:build, [], options) }.to output( + a_string_including('Regenerated feeds') + ).to_stdout + end + end + + context 'with a username' do + before { Fabricate(:account, username: 'alice') } + + let(:arguments) { ['alice'] } + + it 'regenerates feeds for the account' do + expect { cli.invoke(:build, arguments) }.to output( + a_string_including('OK') + ).to_stdout + end + end + + context 'with invalid username' do + let(:arguments) { ['invalid-username'] } + + it 'displays an error and exits' do + expect { cli.invoke(:build, arguments) }.to output( + a_string_including('No such account') + ).to_stdout.and raise_error(SystemExit) + end + end + end + + describe '#clear' do + before do + allow(redis).to receive(:del).with(key_namespace) + end + + it 'clears the redis `feed:*` namespace' do + expect { cli.invoke(:clear) }.to output( + a_string_including('OK') + ).to_stdout + + expect(redis).to have_received(:del).with(key_namespace).once + end + + def key_namespace + redis.keys('feed:*') + end + end end diff --git a/spec/models/form/import_spec.rb b/spec/models/form/import_spec.rb index 52cf1c96e..2b70e396b 100644 --- a/spec/models/form/import_spec.rb +++ b/spec/models/form/import_spec.rb @@ -245,17 +245,44 @@ RSpec.describe Form::Import do expect(account.bulk_imports.first.rows.pluck(:data)).to match_array(expected_rows) end - it 'creates a BulkImport with expected attributes' do - bulk_import = account.bulk_imports.first - expect(bulk_import).to_not be_nil - expect(bulk_import.type.to_sym).to eq subject.type.to_sym - expect(bulk_import.original_filename).to eq subject.data.original_filename - expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched? - expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation - expect(bulk_import.processed_items).to eq 0 - expect(bulk_import.imported_items).to eq 0 - expect(bulk_import.total_items).to eq bulk_import.rows.count - expect(bulk_import.unconfirmed?).to be true + context 'with a BulkImport' do + let(:bulk_import) { account.bulk_imports.first } + + it 'creates a non-nil bulk import' do + expect(bulk_import).to_not be_nil + end + + it 'matches the subjects type' do + expect(bulk_import.type.to_sym).to eq subject.type.to_sym + end + + it 'matches the subjects original filename' do + expect(bulk_import.original_filename).to eq subject.data.original_filename + end + + it 'matches the subjects likely_mismatched? value' do + expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched? + end + + it 'matches the subject overwrite value' do + expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation + end + + it 'has zero processed items' do + expect(bulk_import.processed_items).to eq 0 + end + + it 'has zero imported items' do + expect(bulk_import.imported_items).to eq 0 + end + + it 'has a correct total_items value' do + expect(bulk_import.total_items).to eq bulk_import.rows.count + end + + it 'defaults to unconfirmed true' do + expect(bulk_import.unconfirmed?).to be true + end end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 0dd9264e0..d6e228202 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -99,73 +99,87 @@ RSpec.describe Notification do ] end - it 'preloads target status' do - # mention - expect(subject[0].type).to eq :mention - expect(subject[0].association(:mention)).to be_loaded - expect(subject[0].mention.association(:status)).to be_loaded - - # status - expect(subject[1].type).to eq :status - expect(subject[1].association(:status)).to be_loaded - - # reblog - expect(subject[2].type).to eq :reblog - expect(subject[2].association(:status)).to be_loaded - expect(subject[2].status.association(:reblog)).to be_loaded - - # follow: nothing - expect(subject[3].type).to eq :follow - expect(subject[3].target_status).to be_nil - - # follow_request: nothing - expect(subject[4].type).to eq :follow_request - expect(subject[4].target_status).to be_nil - - # favourite - expect(subject[5].type).to eq :favourite - expect(subject[5].association(:favourite)).to be_loaded - expect(subject[5].favourite.association(:status)).to be_loaded - - # poll - expect(subject[6].type).to eq :poll - expect(subject[6].association(:poll)).to be_loaded - expect(subject[6].poll.association(:status)).to be_loaded + context 'with a preloaded target status' do + it 'preloads mention' do + expect(subject[0].type).to eq :mention + expect(subject[0].association(:mention)).to be_loaded + expect(subject[0].mention.association(:status)).to be_loaded + end + + it 'preloads status' do + expect(subject[1].type).to eq :status + expect(subject[1].association(:status)).to be_loaded + end + + it 'preloads reblog' do + expect(subject[2].type).to eq :reblog + expect(subject[2].association(:status)).to be_loaded + expect(subject[2].status.association(:reblog)).to be_loaded + end + + it 'preloads follow as nil' do + expect(subject[3].type).to eq :follow + expect(subject[3].target_status).to be_nil + end + + it 'preloads follow_request as nill' do + expect(subject[4].type).to eq :follow_request + expect(subject[4].target_status).to be_nil + end + + it 'preloads favourite' do + expect(subject[5].type).to eq :favourite + expect(subject[5].association(:favourite)).to be_loaded + expect(subject[5].favourite.association(:status)).to be_loaded + end + + it 'preloads poll' do + expect(subject[6].type).to eq :poll + expect(subject[6].association(:poll)).to be_loaded + expect(subject[6].poll.association(:status)).to be_loaded + end end - it 'replaces to cached status' do - # mention - expect(subject[0].type).to eq :mention - expect(subject[0].target_status.association(:account)).to be_loaded - expect(subject[0].target_status).to eq mention.status - - # status - expect(subject[1].type).to eq :status - expect(subject[1].target_status.association(:account)).to be_loaded - expect(subject[1].target_status).to eq status - - # reblog - expect(subject[2].type).to eq :reblog - expect(subject[2].target_status.association(:account)).to be_loaded - expect(subject[2].target_status).to eq reblog.reblog - - # follow: nothing - expect(subject[3].type).to eq :follow - expect(subject[3].target_status).to be_nil - - # follow_request: nothing - expect(subject[4].type).to eq :follow_request - expect(subject[4].target_status).to be_nil - - # favourite - expect(subject[5].type).to eq :favourite - expect(subject[5].target_status.association(:account)).to be_loaded - expect(subject[5].target_status).to eq favourite.status - - # poll - expect(subject[6].type).to eq :poll - expect(subject[6].target_status.association(:account)).to be_loaded - expect(subject[6].target_status).to eq poll.status + context 'with a cached status' do + it 'replaces mention' do + expect(subject[0].type).to eq :mention + expect(subject[0].target_status.association(:account)).to be_loaded + expect(subject[0].target_status).to eq mention.status + end + + it 'replaces status' do + expect(subject[1].type).to eq :status + expect(subject[1].target_status.association(:account)).to be_loaded + expect(subject[1].target_status).to eq status + end + + it 'replaces reblog' do + expect(subject[2].type).to eq :reblog + expect(subject[2].target_status.association(:account)).to be_loaded + expect(subject[2].target_status).to eq reblog.reblog + end + + it 'replaces follow' do + expect(subject[3].type).to eq :follow + expect(subject[3].target_status).to be_nil + end + + it 'replaces follow_request' do + expect(subject[4].type).to eq :follow_request + expect(subject[4].target_status).to be_nil + end + + it 'replaces favourite' do + expect(subject[5].type).to eq :favourite + expect(subject[5].target_status.association(:account)).to be_loaded + expect(subject[5].target_status).to eq favourite.status + end + + it 'replaces poll' do + expect(subject[6].type).to eq :poll + expect(subject[6].target_status.association(:account)).to be_loaded + expect(subject[6].target_status).to eq poll.status + end end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index f4113d565..2645f74e4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -79,6 +79,7 @@ RSpec.configure do |config| config.before :each, type: :cli do stub_stdout + stub_reset_connection_pools end config.before :each, type: :feature do @@ -121,9 +122,20 @@ def attachment_fixture(name) end def stub_stdout + # TODO: Is there a bettery way to: + # - Avoid CLI command output being printed out + # - Allow rspec to assert things against STDOUT + # - Avoid disabling stdout for other desirable output (deprecation warnings, for example) allow($stdout).to receive(:write) end +def stub_reset_connection_pools + # TODO: Is there a better way to correctly run specs without stubbing this? + # (Avoids reset_connection_pools! in test env) + allow(ActiveRecord::Base).to receive(:establish_connection) + allow(RedisConfiguration).to receive(:establish_pool) +end + def stub_jsonld_contexts! stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt')) stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt')) diff --git a/spec/requests/api/v1/accounts_show_spec.rb b/spec/requests/api/v1/accounts_show_spec.rb new file mode 100644 index 000000000..ee6e925aa --- /dev/null +++ b/spec/requests/api/v1/accounts_show_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'GET /api/v1/accounts/{account_id}' do + it 'returns account entity as 200 OK' do + account = Fabricate(:account) + + get "/api/v1/accounts/#{account.id}" + + aggregate_failures do + expect(response).to have_http_status(200) + expect(body_as_json[:id]).to eq(account.id.to_s) + end + end + + it 'returns 404 if account not found' do + get '/api/v1/accounts/1' + + aggregate_failures do + expect(response).to have_http_status(404) + expect(body_as_json[:error]).to eq('Record not found') + end + end + + context 'when with token' do + it 'returns account entity as 200 OK if token is valid' do + account = Fabricate(:account) + user = Fabricate(:user, account: account) + token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts').token + + get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" } + + aggregate_failures do + expect(response).to have_http_status(200) + expect(body_as_json[:id]).to eq(account.id.to_s) + end + end + + it 'returns 403 if scope of token is invalid' do + account = Fabricate(:account) + user = Fabricate(:user, account: account) + token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:statuses').token + + get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" } + + aggregate_failures do + expect(response).to have_http_status(403) + expect(body_as_json[:error]).to eq('This action is outside the authorized scopes') + end + end + end +end diff --git a/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb b/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb new file mode 100644 index 000000000..d70e6fc8a --- /dev/null +++ b/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Canonical Email Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'admin:read:canonical_email_blocks admin:write:canonical_email_blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + describe 'GET /api/v1/admin/canonical_email_blocks' do + subject do + get '/api/v1/admin/canonical_email_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there is no canonical email block' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are canonical email blocks' do + let!(:canonical_email_blocks) { Fabricate.times(5, :canonical_email_block) } + let(:expected_email_hashes) { canonical_email_blocks.pluck(:canonical_email_hash) } + + it 'returns the correct canonical email hashes' do + subject + + expect(body_as_json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of canonical email blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + + context 'with since_id param' do + let(:params) { { since_id: canonical_email_blocks[1].id } } + + it 'returns only the canonical email blocks after since_id' do + subject + + canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..]) + end + end + + context 'with max_id param' do + let(:params) { { max_id: canonical_email_blocks[3].id } } + + it 'returns only the canonical email blocks before max_id' do + subject + + canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2]) + end + end + end + end + + describe 'GET /api/v1/admin/canonical_email_blocks/:id' do + subject do + get "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers + end + + let!(:canonical_email_block) { Fabricate(:canonical_email_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'when the requested canonical email block exists' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the requested canonical email block data correctly' do + subject + + json = body_as_json + + expect(json[:id]).to eq(canonical_email_block.id.to_s) + expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + end + + context 'when the requested canonical block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/canonical_email_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/canonical_email_blocks/test' do + subject do + post '/api/v1/admin/canonical_email_blocks/test', headers: headers, params: params + end + + let(:params) { { email: 'email@example.com' } } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'when the required email param is not provided' do + let(:params) { {} } + + it 'returns http bad request' do + subject + + expect(response).to have_http_status(400) + end + end + + context 'when the required email param is provided' do + context 'when there is a matching canonical email block' do + let!(:canonical_email_block) { CanonicalEmailBlock.create(params) } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the expected canonical email hash' do + subject + + expect(body_as_json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + end + + context 'when there is no matching canonical email block' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + end + end + + describe 'POST /api/v1/admin/canonical_email_blocks' do + subject do + post '/api/v1/admin/canonical_email_blocks', headers: headers, params: params + end + + let(:params) { { email: 'example@email.com' } } + let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the canonical_email_hash correctly' do + subject + + expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + + context 'when the required email param is not provided' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the canonical_email_hash param is provided instead of email' do + let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the correct canonical_email_hash' do + subject + + expect(body_as_json[:canonical_email_hash]).to eq(params[:canonical_email_hash]) + end + end + + context 'when both email and canonical_email_hash params are provided' do + let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'ignores the canonical_email_hash param' do + subject + + expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + end + + context 'when the given canonical email was already blocked' do + before do + canonical_email_block.save + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/admin/canonical_email_blocks/:id' do + subject do + delete "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers + end + + let!(:canonical_email_block) { Fabricate(:canonical_email_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'deletes the canonical email block' do + subject + + expect(CanonicalEmailBlock.find_by(id: canonical_email_block.id)).to be_nil + end + + context 'when the canonical email block is not found' do + it 'returns http not found' do + delete '/api/v1/admin/canonical_email_blocks/0', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/domain_allows_spec.rb b/spec/requests/api/v1/admin/domain_allows_spec.rb new file mode 100644 index 000000000..eb7915e77 --- /dev/null +++ b/spec/requests/api/v1/admin/domain_allows_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Allows' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read admin:write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + describe 'GET /api/v1/admin/domain_allows' do + subject do + get '/api/v1/admin/domain_allows', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there is no allowed domains' do + it 'returns an empty body' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are allowed domains' do + let!(:domain_allows) { Fabricate.times(5, :domain_allow) } + let(:expected_response) do + domain_allows.map do |domain_allow| + { + id: domain_allow.id.to_s, + domain: domain_allow.domain, + created_at: domain_allow.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + } + end + end + + it 'returns the correct allowed domains' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of allowed domains' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/domain_allows/:id' do + subject do + get "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers + end + + let!(:domain_allow) { Fabricate(:domain_allow) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the expected allowed domain name' do + subject + + expect(body_as_json[:domain]).to eq domain_allow.domain + end + + context 'when the requested allowed domain does not exist' do + it 'returns http not found' do + get '/api/v1/admin/domain_allows/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/domain_allows' do + subject do + post '/api/v1/admin/domain_allows', headers: headers, params: params + end + + let(:params) { { domain: 'foo.bar.com' } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'with a valid domain name' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the expected domain name' do + subject + + expect(body_as_json[:domain]).to eq 'foo.bar.com' + end + + it 'creates a domain allow' do + subject + + expect(DomainAllow.find_by(domain: 'foo.bar.com')).to be_present + end + end + + context 'with invalid domain name' do + let(:params) { 'foo bar' } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when domain name is not specified' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the domain is already allowed' do + before do + DomainAllow.create(params) + end + + it 'returns the existing allowed domain name' do + subject + + expect(body_as_json[:domain]).to eq(params[:domain]) + end + end + end + + describe 'DELETE /api/v1/admin/domain_allows/:id' do + subject do + delete "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers + end + + let!(:domain_allow) { Fabricate(:domain_allow) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'deletes the allowed domain' do + subject + + expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil + end + + context 'when the allowed domain does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/domain_allows/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/domain_blocks_spec.rb b/spec/requests/api/v1/admin/domain_blocks_spec.rb new file mode 100644 index 000000000..b3d52311b --- /dev/null +++ b/spec/requests/api/v1/admin/domain_blocks_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read:domain_blocks admin:write:domain_blocks' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + describe 'GET /api/v1/admin/domain_blocks' do + subject do + get '/api/v1/admin/domain_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there are no domain blocks' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are domain blocks' do + let!(:domain_blocks) do + [ + Fabricate(:domain_block, severity: :silence, reject_media: true), + Fabricate(:domain_block, severity: :suspend, obfuscate: true), + Fabricate(:domain_block, severity: :noop, reject_reports: true), + Fabricate(:domain_block, public_comment: 'Spam'), + Fabricate(:domain_block, private_comment: 'Spam'), + ] + end + let(:expected_responde) do + domain_blocks.map do |domain_block| + { + id: domain_block.id.to_s, + domain: domain_block.domain, + created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + severity: domain_block.severity.to_s, + reject_media: domain_block.reject_media, + reject_reports: domain_block.reject_reports, + private_comment: domain_block.private_comment, + public_comment: domain_block.public_comment, + obfuscate: domain_block.obfuscate, + } + end + end + + it 'returns the expected domain blocks' do + subject + + expect(body_as_json).to match_array(expected_responde) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of domain blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/domain_blocks/:id' do + subject do + get "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers + end + + let!(:domain_block) { Fabricate(:domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the expected domain block content' do + subject + + expect(body_as_json).to eq( + { + id: domain_block.id.to_s, + domain: domain_block.domain, + created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + severity: domain_block.severity.to_s, + reject_media: domain_block.reject_media, + reject_reports: domain_block.reject_reports, + private_comment: domain_block.private_comment, + public_comment: domain_block.public_comment, + obfuscate: domain_block.obfuscate, + } + ) + end + + context 'when the requested domain block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/domain_blocks' do + subject do + post '/api/v1/admin/domain_blocks', headers: headers, params: params + end + + let(:params) { { domain: 'foo.bar.com', severity: :silence } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns expected domain name and severity' do + subject + + body = body_as_json + + expect(body).to match a_hash_including( + { + domain: 'foo.bar.com', + severity: 'silence', + } + ) + end + + it 'creates a domain block' do + subject + + expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present + end + + context 'when a stricter domain block already exists' do + before do + Fabricate(:domain_block, domain: 'bar.com', severity: :suspend) + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + + it 'returns existing domain block in error' do + subject + + expect(body_as_json[:existing_domain_block][:domain]).to eq('bar.com') + end + end + + context 'when given domain name is invalid' do + let(:params) { { domain: 'foo bar', severity: :silence } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/admin/domain_blocks/:id' do + subject do + put "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers, params: params + end + + let!(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: :silence) } + let(:params) { { domain: 'example.com', severity: 'suspend' } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the updated domain block' do + subject + + expect(body_as_json).to match a_hash_including( + { + id: domain_block.id.to_s, + domain: domain_block.domain, + severity: 'suspend', + } + ) + end + + it 'updates the block severity' do + expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend') + end + + context 'when domain block does not exist' do + it 'returns http not found' do + put '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/admin/domain_blocks/:id' do + subject do + delete "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers + end + + let!(:domain_block) { Fabricate(:domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'deletes the domain block' do + subject + + expect(DomainBlock.find_by(id: domain_block.id)).to be_nil + end + + context 'when domain block does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/ip_blocks_spec.rb b/spec/requests/api/v1/admin/ip_blocks_spec.rb new file mode 100644 index 000000000..2091ef3dc --- /dev/null +++ b/spec/requests/api/v1/admin/ip_blocks_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'IP Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'admin:read:ip_blocks admin:write:ip_blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + describe 'GET /api/v1/admin/ip_blocks' do + subject do + get '/api/v1/admin/ip_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there is no ip block' do + it 'returns an empty body' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are ip blocks' do + let!(:ip_blocks) do + [ + IpBlock.create(ip: '192.0.2.0/24', severity: :no_access), + IpBlock.create(ip: '172.16.0.1', severity: :sign_up_requires_approval, comment: 'Spam'), + IpBlock.create(ip: '2001:0db8::/32', severity: :sign_up_block, expires_in: 10.days), + ] + end + let(:expected_response) do + ip_blocks.map do |ip_block| + { + id: ip_block.id.to_s, + ip: ip_block.ip, + severity: ip_block.severity.to_s, + comment: ip_block.comment, + created_at: ip_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + expires_at: ip_block.expires_at&.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + } + end + end + + it 'returns the correct blocked ips' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of ip blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/ip_blocks/:id' do + subject do + get "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers + end + + let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) } + + it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the correct ip block' do + subject + + json = body_as_json + + expect(json[:ip]).to eq("#{ip_block.ip}/#{ip_block.ip.prefix}") + expect(json[:severity]).to eq(ip_block.severity.to_s) + end + + context 'when ip block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/ip_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/ip_blocks' do + subject do + post '/api/v1/admin/ip_blocks', headers: headers, params: params + end + + let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the correct ip block' do + subject + + json = body_as_json + + expect(json[:ip]).to eq("#{params[:ip]}/32") + expect(json[:severity]).to eq(params[:severity]) + expect(json[:comment]).to eq(params[:comment]) + end + + context 'when the required ip param is not provided' do + let(:params) { { ip: '', severity: 'no_access' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the required severity param is not provided' do + let(:params) { { ip: '173.65.23.1', severity: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given ip address is already blocked' do + before do + IpBlock.create(params) + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given ip address is invalid' do + let(:params) { { ip: '520.13.54.120', severity: 'no_access' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/admin/ip_blocks/:id' do + subject do + put "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers, params: params + end + + let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) } + let(:params) { { severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the correct ip block' do + subject + + expect(body_as_json).to match(hash_including({ + ip: "#{ip_block.ip}/#{ip_block.ip.prefix}", + severity: 'sign_up_requires_approval', + comment: 'Decreasing severity', + })) + end + + it 'updates the severity correctly' do + expect { subject }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval') + end + + it 'updates the comment correctly' do + expect { subject }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity') + end + + context 'when ip block does not exist' do + it 'returns http not found' do + put '/api/v1/admin/ip_blocks/-1', headers: headers, params: params + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/admin/ip_blocks/:id' do + subject do + delete "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers + end + + let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns an empty body' do + subject + + expect(body_as_json).to be_empty + end + + it 'deletes the ip block' do + subject + + expect(IpBlock.find_by(id: ip_block.id)).to be_nil + end + + context 'when ip block does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/ip_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/reports_spec.rb b/spec/requests/api/v1/admin/reports_spec.rb new file mode 100644 index 000000000..cd9fc100e --- /dev/null +++ b/spec/requests/api/v1/admin/reports_spec.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Reports' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read:reports admin:write:reports' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + describe 'GET /api/v1/admin/reports' do + subject do + get '/api/v1/admin/reports', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there are no reports' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are reports' do + let!(:reporter) { Fabricate(:account) } + let!(:spammer) { Fabricate(:account) } + let(:expected_response) do + scope.map do |report| + hash_including({ + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: hash_including(id: report.account.id.to_s), + target_account: hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + }) + end + end + let(:scope) { Report.unresolved } + + before do + Fabricate(:report) + Fabricate(:report, target_account: spammer) + Fabricate(:report, account: reporter, target_account: spammer) + Fabricate(:report, action_taken_at: 4.days.ago, account: reporter) + Fabricate(:report, action_taken_at: 20.days.ago) + end + + it 'returns all unresolved reports' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with resolved param' do + let(:params) { { resolved: true } } + let(:scope) { Report.resolved } + + it 'returns only the resolved reports' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with account_id param' do + let(:params) { { account_id: reporter.id } } + let(:scope) { Report.unresolved.where(account: reporter) } + + it 'returns all unresolved reports filed by the specified account' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with target_account_id param' do + let(:params) { { target_account_id: spammer.id } } + let(:scope) { Report.unresolved.where(target_account: spammer) } + + it 'returns all unresolved reports targeting the specified account' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of reports' do + subject + + expect(body_as_json.size).to eq(1) + end + end + end + end + + describe 'GET /api/v1/admin/reports/:id' do + subject do + get "/api/v1/admin/reports/#{report.id}", headers: headers + end + + let(:report) { Fabricate(:report) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the requested report content' do + subject + + expect(body_as_json).to include( + { + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: a_hash_including(id: report.account.id.to_s), + target_account: a_hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + } + ) + end + end + + describe 'PUT /api/v1/admin/reports/:id' do + subject do + put "/api/v1/admin/reports/#{report.id}", headers: headers, params: params + end + + let!(:report) { Fabricate(:report, category: :other) } + let(:params) { { category: 'spam' } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'updates the report category' do + expect { subject }.to change { report.reload.category }.from('other').to('spam') + end + + it 'returns the updated report content' do + subject + + report.reload + + expect(body_as_json).to include( + { + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: a_hash_including(id: report.account.id.to_s), + target_account: a_hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + } + ) + end + end + + describe 'POST #resolve' do + subject do + post "/api/v1/admin/reports/#{report.id}/resolve", headers: headers + end + + let(:report) { Fabricate(:report, action_taken_at: nil) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks report as resolved' do + expect { subject }.to change { report.reload.unresolved? }.from(true).to(false) + end + end + + describe 'POST #reopen' do + subject do + post "/api/v1/admin/reports/#{report.id}/reopen", headers: headers + end + + let(:report) { Fabricate(:report, action_taken_at: 10.days.ago) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'marks report as unresolved' do + expect { subject }.to change { report.reload.unresolved? }.from(false).to(true) + end + end + + describe 'POST #assign_to_self' do + subject do + post "/api/v1/admin/reports/#{report.id}/assign_to_self", headers: headers + end + + let(:report) { Fabricate(:report) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'assigns report to the requesting user' do + expect { subject }.to change { report.reload.assigned_account_id }.from(nil).to(user.account.id) + end + end + + describe 'POST #unassign' do + subject do + post "/api/v1/admin/reports/#{report.id}/unassign", headers: headers + end + + let(:report) { Fabricate(:report, assigned_account_id: user.account.id) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'unassigns report from assignee' do + expect { subject }.to change { report.reload.assigned_account_id }.from(user.account.id).to(nil) + end + end +end diff --git a/spec/services/translate_status_service_spec.rb b/spec/services/translate_status_service_spec.rb index 074f55544..515dd1a99 100644 --- a/spec/services/translate_status_service_spec.rb +++ b/spec/services/translate_status_service_spec.rb @@ -152,22 +152,31 @@ RSpec.describe TranslateStatusService, type: :service do describe 'status has poll' do let(:poll) { Fabricate(:poll, options: %w(Blue Green)) } - it 'returns formatted poll options' do - source_texts = service.send(:source_texts) - expect(source_texts.size).to eq 3 - expect(source_texts.values).to eq %w(

Hello

Blue Green) + context 'with source texts from the service' do + let!(:source_texts) { service.send(:source_texts) } - expect(source_texts.keys.first).to eq :content + it 'returns formatted poll options' do + expect(source_texts.size).to eq 3 + expect(source_texts.values).to eq %w(

Hello

Blue Green) + end - option1 = source_texts.keys.second - expect(option1).to be_a Poll::Option - expect(option1.id).to eq '0' - expect(option1.title).to eq 'Blue' + it 'has a first key with content' do + expect(source_texts.keys.first).to eq :content + end - option2 = source_texts.keys.third - expect(option2).to be_a Poll::Option - expect(option2.id).to eq '1' - expect(option2.title).to eq 'Green' + it 'has the first option in the second key with correct options' do + option1 = source_texts.keys.second + expect(option1).to be_a Poll::Option + expect(option1.id).to eq '0' + expect(option1.title).to eq 'Blue' + end + + it 'has the second option in the third key with correct options' do + option2 = source_texts.keys.third + expect(option2).to be_a Poll::Option + expect(option2.id).to eq '1' + expect(option2.title).to eq 'Green' + end end end diff --git a/spec/support/examples/lib/settings/scoped_settings.rb b/spec/support/examples/lib/settings/scoped_settings.rb deleted file mode 100644 index 106adb4fa..000000000 --- a/spec/support/examples/lib/settings/scoped_settings.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -shared_examples 'ScopedSettings' do - describe '[]' do - it 'inherits default settings' do - expect(Setting.boost_modal).to be false - expect(Setting.interactions['must_be_follower']).to be false - - settings = create! - - expect(settings['boost_modal']).to be false - expect(settings['interactions']['must_be_follower']).to be false - end - end - - describe 'all_as_records' do - # expecting [] and []= works - - it 'returns records merged with default values except hashes' do - expect(Setting.boost_modal).to be false - expect(Setting.delete_modal).to be true - - settings = create! - settings['boost_modal'] = true - - records = settings.all_as_records - - expect(records['boost_modal'].value).to be true - expect(records['delete_modal'].value).to be true - end - end - - describe 'missing methods' do - # expecting [] and []= works. - - it 'reads settings' do - expect(Setting.boost_modal).to be false - settings = create! - expect(settings.boost_modal).to be false - end - - it 'updates settings' do - settings = fabricate - settings.boost_modal = true - expect(settings['boost_modal']).to be true - end - end - - it 'can update settings with [] and can read with []=' do - settings = fabricate - - settings['boost_modal'] = true - settings['interactions'] = settings['interactions'].merge('must_be_follower' => true) - - Setting.save! - - expect(settings['boost_modal']).to be true - expect(settings['interactions']['must_be_follower']).to be true - - Rails.cache.clear - - expect(settings['boost_modal']).to be true - expect(settings['interactions']['must_be_follower']).to be true - end - - xit 'does not mutate defaults via the cache' do - fabricate['interactions']['must_be_follower'] = true - # TODO - # This mutates the global settings default such that future - # instances will inherit the incorrect starting values - - expect(fabricate.settings['interactions']['must_be_follower']).to be false - end -end diff --git a/spec/support/examples/lib/settings/settings_extended.rb b/spec/support/examples/lib/settings/settings_extended.rb deleted file mode 100644 index 5a9d34bb0..000000000 --- a/spec/support/examples/lib/settings/settings_extended.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -shared_examples 'Settings-extended' do - describe 'settings' do - def fabricate - super.settings - end - - def create! - super.settings - end - - it_behaves_like 'ScopedSettings' - end -end diff --git a/spec/support/examples/models/concerns/account_avatar.rb b/spec/support/examples/models/concerns/account_avatar.rb index 8f5b81f3a..16ebda564 100644 --- a/spec/support/examples/models/concerns/account_avatar.rb +++ b/spec/support/examples/models/concerns/account_avatar.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true shared_examples 'AccountAvatar' do |fabricator| - describe 'static avatars' do + describe 'static avatars', paperclip_processing: true do describe 'when GIF' do it 'creates a png static style' do account = Fabricate(fabricator, avatar: attachment_fixture('avatar.gif')) diff --git a/streaming/index.js b/streaming/index.js index 916962c59..89cb3549d 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -835,7 +835,11 @@ const startServer = async () => { return; } - ws.send(JSON.stringify({ stream: streamName, event, payload })); + ws.send(JSON.stringify({ stream: streamName, event, payload }), (err) => { + if (err) { + log.error(req.requestId, `Failed to send to websocket: ${err}`); + } + }); }; /**