From ecaec69e17c68817b67b86311130ff4dd8e1d89e Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Wed, 21 Feb 2024 21:14:21 -0800 Subject: [PATCH 01/12] [F] Improve settings sections & test coverage * Finishes up an in-progress branch to make settings easier to grok. * Normalizes settings into store models rather than arbitrary hash with multiple sources of truth across the application * Tightens up serializer to have parity with new section store models, ensures that the API definition actually declares which settings can be nil instead of relying on missing keys not tripping the validators. * Improve test coverage over updating settings from environment, pulling google drive data out of config files, and handling of unknown settings that may get set or exist from legacy use cases. --- api/.rubocop.yml | 9 ++ api/Gemfile | 2 +- api/Gemfile.lock | 4 +- api/app/controllers/concerns/validation.rb | 114 ++----------- api/app/models/concerns/arel_helpers.rb | 2 - api/app/models/settings.rb | 150 +++++++----------- api/app/operations/utility/booleanize.rb | 29 ++++ api/app/serializers/v1/setting_serializer.rb | 121 +++++++------- .../concerns/has_filtered_attributes.rb | 5 +- api/app/services/setting_sections.rb | 41 +++++ .../services/setting_sections/accessors.rb | 47 ++++++ api/app/services/setting_sections/base.rb | 128 +++++++++++++++ api/app/services/setting_sections/email.rb | 27 ++++ api/app/services/setting_sections/general.rb | 52 ++++++ .../services/setting_sections/ingestion.rb | 9 ++ .../services/setting_sections/integrations.rb | 20 +++ api/app/services/setting_sections/secrets.rb | 21 +++ .../setting_sections/serialization.rb | 22 +++ api/app/services/setting_sections/theme.rb | 37 +++++ api/app/services/setting_sections/types.rb | 9 ++ .../settings_service/adjust_google_config.rb | 51 +++--- .../settings_service/read_from_env.rb | 8 +- .../settings_service/update_from_env.rb | 16 +- .../update_oauth_providers.rb | 14 +- .../services/utility/enhanced_store_model.rb | 59 +++++-- api/app/services/validator/stylesheet.rb | 20 +-- api/spec/models/settings_spec.rb | 60 ++++--- .../operations/utility/booleanize_spec.rb | 17 ++ .../settings/adjust_google_config_spec.rb | 25 --- .../adjust_google_config_spec.rb | 27 ++++ .../settings_service/read_from_env_spec.rb | 64 ++++++++ .../settings_service/update_from_env_spec.rb | 25 +++ api/spec/support/helpers/stubbed_env.rb | 20 +++ 33 files changed, 898 insertions(+), 357 deletions(-) create mode 100644 api/app/operations/utility/booleanize.rb create mode 100644 api/app/services/setting_sections.rb create mode 100644 api/app/services/setting_sections/accessors.rb create mode 100644 api/app/services/setting_sections/base.rb create mode 100644 api/app/services/setting_sections/email.rb create mode 100644 api/app/services/setting_sections/general.rb create mode 100644 api/app/services/setting_sections/ingestion.rb create mode 100644 api/app/services/setting_sections/integrations.rb create mode 100644 api/app/services/setting_sections/secrets.rb create mode 100644 api/app/services/setting_sections/serialization.rb create mode 100644 api/app/services/setting_sections/theme.rb create mode 100644 api/app/services/setting_sections/types.rb create mode 100644 api/spec/operations/utility/booleanize_spec.rb delete mode 100644 api/spec/services/settings/adjust_google_config_spec.rb create mode 100644 api/spec/services/settings_service/adjust_google_config_spec.rb create mode 100644 api/spec/services/settings_service/read_from_env_spec.rb create mode 100644 api/spec/services/settings_service/update_from_env_spec.rb create mode 100644 api/spec/support/helpers/stubbed_env.rb diff --git a/api/.rubocop.yml b/api/.rubocop.yml index 209905970a..7c65748fa0 100644 --- a/api/.rubocop.yml +++ b/api/.rubocop.yml @@ -22,6 +22,7 @@ Metrics/BlockLength: Metrics/MethodLength: Exclude: + - "app/controllers/concerns/validation.rb" - "app/operations/**/*.rb" Max: 15 @@ -56,10 +57,15 @@ Layout/EmptyLinesAroundAttributeAccessor: Layout/SpaceAroundMethodCallOperator: Enabled: true +Layout/EmptyLineAfterGuardClause: + Enabled: false + Layout/EmptyLinesAroundClassBody: Enabled: false Layout/LineLength: + Exclude: + - "app/services/setting_sections/*.rb" Max: 140 Layout/BeginEndAlignment: # (new in 0.91) @@ -164,6 +170,9 @@ Style/BisectedAttrAccessor: Style/CaseLikeIf: Enabled: true +Style/CharacterLiteral: + Enabled: false + Style/ExplicitBlockArgument: Enabled: false diff --git a/api/Gemfile b/api/Gemfile index f75389612a..75e96cf496 100644 --- a/api/Gemfile +++ b/api/Gemfile @@ -115,7 +115,7 @@ gem "signet", "~> 0.10" gem "sinatra", "~>2.2" gem "statesman", "~> 3.4" gem "statesman-events", "~> 0.0.1" -gem "store_model", "~> 0.7.0" +gem "store_model", "~> 2.2.0" gem "strip_attributes", "~> 1.13.0" gem "terrapin", "~> 0.6.0" gem "tus-server", "~> 2.0" diff --git a/api/Gemfile.lock b/api/Gemfile.lock index 4862094a2e..50033b2787 100644 --- a/api/Gemfile.lock +++ b/api/Gemfile.lock @@ -732,7 +732,7 @@ GEM statesman (3.5.0) statesman-events (0.0.1) statesman (>= 1.3) - store_model (0.7.0) + store_model (2.2.0) activerecord (>= 5.2) strip_attributes (1.13.0) activemodel (>= 3.0, < 8.0) @@ -942,7 +942,7 @@ DEPENDENCIES spring-watcher-listen (~> 2.1.0) statesman (~> 3.4) statesman-events (~> 0.0.1) - store_model (~> 0.7.0) + store_model (~> 2.2.0) strip_attributes (~> 1.13.0) terrapin (~> 0.6.0) test-prof (~> 1.0) diff --git a/api/app/controllers/concerns/validation.rb b/api/app/controllers/concerns/validation.rb index 9bd0e12ada..de4fec1af5 100644 --- a/api/app/controllers/concerns/validation.rb +++ b/api/app/controllers/concerns/validation.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + # Includes strong parameter configuration module Validation extend ActiveSupport::Concern - # rubocop:disable Metrics/MethodLength def user_params params.require(:data) persistent_ui = { @@ -40,7 +41,6 @@ def user_params param_config = structure_params(attributes: attributes, relationships: relationships) params.permit(param_config) end - # rubocop:enable Metrics/MethodLength def reading_group_membership_params params.require(:data) @@ -247,7 +247,6 @@ def entitlement_import_params params.permit(param_config) end - # rubocop:disable Metrics/MethodLength def resource_params params.require(:data) attributes = [attachment(:attachment), :remove_attachment, @@ -266,7 +265,6 @@ def resource_params param_config = structure_params(attributes: attributes, relationships: relationships) params.permit(param_config) end - # rubocop:enable Metrics/MethodLength def resource_metadata_params params.require(:data) @@ -284,7 +282,6 @@ def resource_import_params params.permit(param_config) end - # rubocop:disable Metrics/MethodLength def ingestion_params params.require(:data) @@ -308,7 +305,6 @@ def ingestion_params params.permit(param_config) end - # rubocop:enable Metrics/MethodLength def ingestion_source_params params.require(:data) @@ -404,109 +400,26 @@ def content_block_params(type = nil) params.permit(param_config) end - # rubocop:disable Metrics/MethodLength + # @see SettingSections + # @api private + BASE_SETTING_ATTRIBUTES = { + **SettingSections[:strong_params], + google_service: %i[data], + }.freeze + def settings_params params.require(:data) + attributes = [ - { - general: [ - :installation_name, - :default_publisher, - :default_publisher_place, - :head_description, - :social_share_message, - :contact_email, - :copyright, - :press_site, - :terms_url, - :head_title, - :twitter, - :facebook, - :library_disabled, - :all_standalone, - :library_redirect_url, - :home_redirect_url, - :restricted_access, - :restricted_access_heading, - :restricted_access_body, - :disable_engagement, - :disable_reading_groups, - :disable_internal_analytics - ] - }, - { - secrets: [ - :facebook_app_secret, - :twitter_app_secret, - :twitter_access_token_secret, - :google_private_key, - :google_oauth_client_secret, - :smtp_settings_password - ] - }, - { - ingestion: [ - :global_styles, - :mammoth_style_map - ] - }, - { - integrations: [ - :facebook_app_id, - :twitter_app_id, - :twitter_access_token, - :google_project_id, - :google_private_key_id, - :google_client_email, - :google_client_id, - :google_oauth_client_id, - :ga_four_tracking_id - ] - }, - { - email: [ - :from_address, - :from_name, - :reply_to_address, - :reply_to_name, - :closing, - :delivery_method, - :smtp_settings_address, - :smtp_settings_port, - :smtp_settings_user_name, - :sendmail_settings_location, - :sendmail_settings_arguments - ] - }, - { - theme: [ - :logo_styles, - :typekit_id, - :header_offset, - :top_bar_text, - :top_bar_url, - :top_bar_color, - :top_bar_mode, - :accent_color, - :header_foreground_color, - :header_foreground_active_color, - :header_background_color, - :string_signup_terms_header, - :string_signup_terms_one, - :string_signup_terms_two, - :string_data_use_header, - :string_data_use_copy, - :string_cookies_banner_header, - :string_cookies_banner_copy - ] - }, + BASE_SETTING_ATTRIBUTES, :remove_press_logo, attachment(:press_logo), :remove_press_logo_footer, attachment(:press_logo_footer), :remove_press_logo_mobile, attachment(:press_logo_mobile), :remove_favicon, attachment(:favicon), - { google_service: [:data] } ] + param_config = structure_params(attributes: attributes) + params.permit(param_config) end @@ -546,7 +459,6 @@ def project_exportation_params params.permit(param_config) end - # rubocop:enable Metrics/MethodLength def maker_params params.require(:data) diff --git a/api/app/models/concerns/arel_helpers.rb b/api/app/models/concerns/arel_helpers.rb index a5def6d284..2af15c0128 100644 --- a/api/app/models/concerns/arel_helpers.rb +++ b/api/app/models/concerns/arel_helpers.rb @@ -1,7 +1,6 @@ # Shared collection of (mostly Arel) class-level helpers for working with advanced # SQL selections. # -# rubocop:disable Style/CharacterLiteral # rubocop:disable Style/StringLiterals # @see https://www.postgresql.org/docs/9.5/static/functions-json.html JSON functions and operators in PostgreSQL module ArelHelpers @@ -411,5 +410,4 @@ def arel_quote(arg) # @!endgroup end end -# rubocop:enable Style/CharacterLiteral # rubocop:enable Style/StringLiterals diff --git a/api/app/models/settings.rb b/api/app/models/settings.rb index ac3873bc81..52a978190a 100644 --- a/api/app/models/settings.rb +++ b/api/app/models/settings.rb @@ -1,83 +1,25 @@ -class Settings < ApplicationRecord +# frozen_string_literal: true - # Concerns +class Settings < ApplicationRecord include Attachments include Authority::Abilities include HasFormattedAttributes include SerializedAbilitiesFor - SECTIONS = [:general, :integrations, :ingestion, :secrets, :email, :theme].freeze - - DEFAULT_RESTRICTED_ACCESS_HEADING = "Access to this project is restricted.".freeze - DEFAULT_RESTRICTED_ACCESS_BODY = "Only users granted permission may view this project's texts, resources, and other content.".freeze - - # rubocop:disable Layout/LineLength - DEFAULTS = { - general: { - installation_name: "Manifold", - head_title: "Manifold Scholarship", - head_description: "Transforming scholarly publications into living digital works", - social_share_message: "Shared from Manifold Scholarship", - library_disabled: false, - restricted_access: false, - restricted_access_heading: DEFAULT_RESTRICTED_ACCESS_HEADING, - restricted_access_body: DEFAULT_RESTRICTED_ACCESS_BODY, - disable_engagement: false, - disable_reading_groups: false, - disable_internal_analytics: false - }, - email: { - from_address: "do-not-reply@manifoldapp.org", - from_name: "Manifold Scholarship", - reply_to_address: "do-not-reply@manifoldapp.org", - reply_to_name: "Manifold Scholarship", - closing: "Sincerely,\nThe Manifold Team", - delivery_method: "sendmail" - }, - theme: { - string_cookies_banner_header: "Manifold uses cookies", - string_cookies_banner_copy: "We use cookies to analyze our traffic. Please decide if you are willing to accept cookies from our website. You can change this setting anytime in [Privacy Settings](/privacy).", - string_signup_terms_header: "First things first...", - string_signup_terms_one: "When you create an account, we will collect and store your name and email address for account management purposes.", - string_signup_terms_two: "This site will also store the annotations and highlights you create on texts, and it will keep track of content that you've starred. Depending on its configuration, this site may store anonymous data on how the site is being used.", - string_data_use_header: "What data does Manifold store about me?", - string_data_use_copy: <<~HEREDOC -## Internal Analytics -Manifold stores anonymous data about what pages users access and how much time they spend on those pages. There is no personally identifiable information stored in relation to usage data. - -## Annotations and Comments -When you create a highlight, annotate a text, or write a comment, Manifold stores it in the database. - -## Reading Groups -Manifold stores basic information about each reading group, the content that has been collected in the group, and the group's members. - HEREDOC - }, - ingestion: { - global_styles: "", - mammoth_style_map: "" - } - }.freeze - # rubocop:enable Layout/LineLength + # @see SettingSections + SECTIONS = SettingSections::NAMES.dup.freeze - # Create merge setters for the various settings sections. Initialize the hashes. - SECTIONS.each do |section| - attribute section, :indifferent_hash, default: {} - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{section}=(new_values) - value = merge_settings_into!(:#{section}, new_values.symbolize_keys) - write_attribute(:#{section}, value) - end + self.filter_attributes = [*SECTIONS.dup, :fa_cache] - def force_#{section}=(value) - write_attribute(:#{section}, value) - end - RUBY - end + attribute :general, SettingSections::General.to_type, default: {} + attribute :email, SettingSections::Email.to_type, default: {} + attribute :ingestion, SettingSections::Ingestion.to_type, default: {} + attribute :integrations, SettingSections::Integrations.to_type, default: {} + attribute :secrets, SettingSections::Secrets.to_type, default: {} + attribute :theme, SettingSections::Theme.to_type, default: {} - # Validation validates :singleton_guard, inclusion: [0], uniqueness: true - # Attachments manifold_has_attached_file :press_logo, :image manifold_has_attached_file :press_logo_footer, :image manifold_has_attached_file :press_logo_mobile, :image @@ -88,19 +30,39 @@ def force_#{section}=(value) has_formatted_attributes :string_data_use_copy, include_wrap: false, container: :theme has_formatted_attributes :string_cookies_banner_copy, include_wrap: false, container: :theme - # Callbacks + delegate :default_restricted_access_heading, :default_restricted_access_body, to: :general + after_update :update_oauth_providers! - after_initialize :ensure_defaults + + # @!group Derived Settings + + # @return [Boolean] + def google_analytics_enabled + integrations.ga_four_tracking_id? + end + + alias google_analytics_enabled? google_analytics_enabled + + # @!endgroup + + SECTIONS.each do |section| + include SettingSections::Accessors.new(section) + end # @param [Symbol] section # @param [{Symbol => String}] new_values # @return [void] def merge_settings_into!(section, **new_values) - current = self[section] - # raise TypeError, "#{section} is not mergeable!" unless current.respond_to?(:merge) - return unless current.respond_to?(:merge) - - self[section] = current.merge(new_values) + if SettingSections.valid?(section) + self[section].merge!(**new_values) + __send__(:"#{section}_will_change!") + else + # :nocov: + # We ignore invalid sections that might be the result of a typo + # in an env var as per original behavior. Now we just log it. + Rails.logger.warn("Tried to set unknown setting section: #{section.inspect}") + # :nocov: + end end # @see [SettingsService::UpdateFromEnv] @@ -115,6 +77,8 @@ def update_oauth_providers! SettingsService::UpdateOauthProviders.run! end + # @param [User, nil] current_user + # @return [Hash] def calculated(current_user = nil) { has_visible_home_project_collections: ProjectCollection.by_visible_on_homepage.exists?, @@ -123,31 +87,23 @@ def calculated(current_user = nil) has_project_collections: ProjectCollection.count.positive?, manifold_version: self.class.manifold_version, require_terms_and_conditions: Page.by_purpose(:terms_and_conditions).exists?, - google_analytics_enabled: integrations["ga_four_tracking_id"].present?, + google_analytics_enabled: google_analytics_enabled, manifold_analytics_enabled: integrations["disable_internal_analytics"] != true } end - def ensure_defaults - DEFAULTS.each do |section_key, section_defaults| - section = send(section_key) - section_defaults.each do |key, value| - section[key] = value if section[key].blank? + class << self + # Fetch the current instance from the `RequestStore`. + # + # This avoids needless repeated trips to the database. + # + # @return [Settings] + def current + RequestStore.fetch(:current_settings) do + instance end end - end - # @return [String] - def default_restricted_access_heading - general.fetch(:restricted_access_heading, DEFAULT_RESTRICTED_ACCESS_HEADING) - end - - # @return [String] - def default_restricted_access_body - general.fetch(:restricted_access_body, DEFAULT_RESTRICTED_ACCESS_BODY) - end - - class << self # Check if we {.update_from_environment? should update from the environment} # and {#update_from_environment! do so}. # @return [void] @@ -188,8 +144,12 @@ def manifold_analytics_enabled? end end + def manage_from_env? + ManifoldApi::Container["utility.booleanize"].env("MANAGE_SETTINGS_FROM_ENV") + end + def update_from_environment? - ENV["MANAGE_SETTINGS_FROM_ENV"].present? && table_exists? + manage_from_env? && table_exists? end end end diff --git a/api/app/operations/utility/booleanize.rb b/api/app/operations/utility/booleanize.rb new file mode 100644 index 0000000000..cf7a95e079 --- /dev/null +++ b/api/app/operations/utility/booleanize.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Utility + # Accept a stringish or numeric value and transform it into a boolean, + # relying on ActiveRecord's logic for handling params. + class Booleanize + TYPE = ActiveRecord::Type::Boolean.new + + # @param [Boolean, Object] value + # @return [Boolean] + def call(value) + case value + when true then true + when false, nil, /\Ano?\z/i, "" then false + else + TYPE.cast(value) + end + end + + # Pull a variable out of the environment (that may be missing) + # and parse it using {#call}. + # + # @param [String] key + # @return [Boolean] + def env(key) + call(ENV[key]) + end + end +end diff --git a/api/app/serializers/v1/setting_serializer.rb b/api/app/serializers/v1/setting_serializer.rb index 766a467817..d7ed4712bf 100644 --- a/api/app/serializers/v1/setting_serializer.rb +++ b/api/app/serializers/v1/setting_serializer.rb @@ -1,56 +1,63 @@ +# frozen_string_literal: true + module V1 + # @see Settings + # @see SettingSections::Base class SettingSerializer < ManifoldSerializer - include ::V1::Concerns::ManifoldSerializer + include ::SettingSections::Serialization set_id :singleton_guard typed_attribute :restricted_access_body_plaintext, Types::String.optional typed_attribute :restricted_access_body_formatted, Types::String.meta(read_only: true) - typed_attribute :general, Types::Hash.schema( + typed_section_attribute :general, Types::Hash.schema( installation_name: Types::String, - default_publisher: Types::String, - default_publisher_place: Types::String, + head_tile: Types::String, head_description: Types::String, social_share_message: Types::String, - contact_email: Types::Serializer::Email, - copyright: Types::String, - press_site: Types::String, - terms_url: Types::Serializer::URL, - head_tile: Types::String, - twitter: Types::String, - facebook: Types::String, - library_disabled: Types::Bool, + all_standalone: Types::Bool, - library_redirect_url: Types::String, - home_redirect_url: Types::String, + library_disabled: Types::Bool, restricted_access: Types::Bool, restricted_access_heading: Types::String, restricted_access_body: Types::String, restricted_access_body_formatted: Types::String, + disable_engagement: Types::Bool, disable_reading_groups: Types::Bool, - disable_internal_analytics: Types::Bool - ) do |object, _params| - object.general.merge(restricted_access_body_formatted: object.restricted_access_body_formatted) - end - typed_attribute :ingestion, Types::Hash.schema( + disable_internal_analytics: Types::Bool, + + contact_email: Types::Serializer::Email.optional, + copyright: Types::String.optional, + default_publisher: Types::String.optional, + default_publisher_place: Types::String.optional, + facebook: Types::String.optional, + home_redirect_url: Types::String.optional, + library_redirect_url: Types::String.optional, + press_site: Types::String.optional, + terms_url: Types::Serializer::URL.optional, + twitter: Types::String.optional + ) + + typed_section_attribute :ingestion, Types::Hash.schema( global_styles: Types::String, mammoth_style_map: Types::String ) - typed_attribute :theme, Types::Hash.schema( - logo_styles: Types::String, - typekit_id: Types::String, - header_offset: Types::String, - top_bar_text: Types::String, - top_bar_url: Types::String, - top_bar_color: Types::String, - top_bar_mode: Types::String, - accent_color: Types::String, - header_foreground_color: Types::String, - header_foreground_active_color: Types::String, - header_background_color: Types::String, + + typed_section_attribute :theme, Types::Hash.schema( + logo_styles: Types::String.optional, + typekit_id: Types::String.optional, + header_offset: Types::String.optional, + top_bar_text: Types::String.optional, + top_bar_url: Types::String.optional, + top_bar_color: Types::String.optional, + top_bar_mode: Types::String.optional, + accent_color: Types::String.optional, + header_foreground_color: Types::String.optional, + header_foreground_active_color: Types::String.optional, + header_background_color: Types::String.optional, string_signup_terms_header: Types::String, string_signup_terms_one: Types::String, string_signup_terms_two: Types::String, @@ -59,41 +66,50 @@ class SettingSerializer < ManifoldSerializer string_cookies_banner_header: Types::String, string_cookies_banner_copy: Types::String ) + typed_attribute :string_data_use_copy_formatted, Types::String.meta(read_only: true) typed_attribute :string_cookies_banner_copy_formatted, Types::String.meta(read_only: true) - typed_attribute :integrations, Types::Hash.schema( - facebook_app_id: Types::String, - twitter_app_id: Types::String, - twitter_access_token: Types::String, - google_project_id: Types::String, - google_private_key_id: Types::String, - google_client_email: Types::String, - google_client_id: Types::String, - ga_four_tracking_id: Types::String + + typed_section_attribute :integrations, Types::Hash.schema( + facebook_app_id: Types::String.optional, + ga_four_tracking_id: Types::String.optional, + google_client_email: Types::String.optional, + google_client_id: Types::String.optional, + google_private_key_id: Types::String.optional, + google_project_id: Types::String.optional, + twitter_access_token: Types::String.optional, + twitter_app_id: Types::String.optional ) - typed_attribute :email, Types::Hash.schema( + + typed_section_attribute :email, Types::Hash.schema( from_address: Types::Serializer::Email, from_name: Types::String, - reply_to_address: Types::Serializer::Email, - reply_to_name: Types::String, closing: Types::String, delivery_method: Types::String.enum("sendmail"), - smtp_settings_address: Types::String, - smtp_settings_port: Types::Integer, - smtp_settings_user_name: Types::String, - sendmail_settings_location: Types::String, - sendmail_settings_arguments: Types::String + reply_to_address: Types::Serializer::Email, + reply_to_name: Types::String, + + sendmail_settings_location: Types::String.optional, + sendmail_settings_arguments: Types::String.optional, + + smtp_settings_address: Types::String.optional, + smtp_settings_port: Types::Integer.optional, + smtp_settings_user_name: Types::String.optional ) + typed_attribute :press_logo_styles, Types::Serializer::Attachment.meta(read_only: true) typed_attribute :press_logo_footer_styles, Types::Serializer::Attachment.meta(read_only: true) typed_attribute :press_logo_mobile_styles, Types::Serializer::Attachment.meta(read_only: true) + typed_attribute :favicon_styles, Types::Hash.schema( small: Types::Serializer::URL, medium: Types::Serializer::URL, large: Types::Serializer::URL, original: Types::Serializer::URL ).meta(read_only: true) + typed_attribute :copyright_formatted, Types::String.meta(read_only: true) + typed_attribute :calculated, Types::Hash.schema( has_visible_home_project_collections: Types::Bool, has_visible_projects: Types::Bool, @@ -121,17 +137,12 @@ class SettingSerializer < ManifoldSerializer ManifoldEnv.oauth.as_json end - typed_attribute :secrets, Types::Hash.schema( + typed_section_attribute :secrets, Types::Hash.schema( facebook_app_secret: Types::String, twitter_app_secret: Types::String, twitter_access_token_secret: Types::String, google_private_key: Types::String, smtp_settings_password: Types::String - ) do |object, _params| - object.secrets.transform_values do |_value| - "(redacted)" - end - end - + ) end end diff --git a/api/app/services/concerns/has_filtered_attributes.rb b/api/app/services/concerns/has_filtered_attributes.rb index 9f0a5da8cb..39a767bfa4 100644 --- a/api/app/services/concerns/has_filtered_attributes.rb +++ b/api/app/services/concerns/has_filtered_attributes.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Provide filterable attributes for basic StoreModel classes. module HasFilteredAttributes extend ActiveSupport::Concern @@ -6,7 +9,7 @@ module HasFilteredAttributes end def as_filtered_json(options = nil) - parameter_filter.filter as_json(options) + parameter_filter.filter as_json(options || {}) end class_methods do diff --git a/api/app/services/setting_sections.rb b/api/app/services/setting_sections.rb new file mode 100644 index 0000000000..0aa169ee84 --- /dev/null +++ b/api/app/services/setting_sections.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Namespace for segmented sections for the {Settings} model. +# +# @see SettingSections::Base +module SettingSections + extend Dry::Core::Container::Mixin + + # The various names of the sections. + # + # Each corresponds to a class under this module. + NAMES = [ + :email, + :general, + :ingestion, + :integrations, + :secrets, + :theme, + ].freeze + + register "classes", memoize: true do + NAMES.index_with do |name| + "setting_sections/#{name}".camelize(:upper).constantize + end + end + + register "matcher", memoize: true do + NAMES | NAMES.map(&:to_s) + end + + register "strong_params", memoize: true do + resolve(:classes).transform_values(&:strong_params) + end + + class << self + # @param [String, Symbol] section + def valid?(section) + section.in?(self[:matcher]) + end + end +end diff --git a/api/app/services/setting_sections/accessors.rb b/api/app/services/setting_sections/accessors.rb new file mode 100644 index 0000000000..8ac53ec807 --- /dev/null +++ b/api/app/services/setting_sections/accessors.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module SettingSections + # A module subclass that defines special writers for setting sections. + # + # @api private + class Accessors < Module + include Dry::Initializer[undefined: false].define -> do + param :section, SettingSections::Types::Section + end + + def initialize(...) + super + + define_methods! + end + + private + + # @return [void] + def define_methods! + define_writer! + + define_force_writer! + end + + # @return [void] + def define_writer! + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{section}=(new_values) # def general=(new_values) + #{section}.merge!(new_values) # general.merge!(new_values) + #{section}_will_change! # general_will_change! + end # end + RUBY + end + + # @deprecated Can't find any reference to this, but keeping for now. + # @return [void] + def define_force_writer! + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def force_#{section}=(value) # def force_general=(value) + write_attribute(:#{section}, value) # write_attribute(:general, value) + end # end + RUBY + end + end +end diff --git a/api/app/services/setting_sections/base.rb b/api/app/services/setting_sections/base.rb new file mode 100644 index 0000000000..ea8be274ec --- /dev/null +++ b/api/app/services/setting_sections/base.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module SettingSections + # A base class for defining settings in different semantic "sections" + # of the {Settings} record. + # + # @abstract + # @see Settings + class Base + extend Dry::Core::ClassAttributes + + include Utility::EnhancedStoreModel + + REDACTED = "(redacted)" + + defines :section_name, type: Types::Symbol.optional + + defines :redact_all, type: Types::Bool + defines :redaction_mask, type: Types::String + + defines :exposed_derived_attributes, type: Types::Array.of(Types::Symbol) + defines :redacted_attributes, type: Types::Array.of(Types::Symbol) + defines :skipped_strong_params, type: Types::Array.of(Types::Symbol) + + redact_all false + + redaction_mask REDACTED + + exposed_derived_attributes [] + + redacted_attributes [] + + skipped_strong_params [] + + # @return [Hash] + def to_serialized_response + output = as_json(serialize_unknown_attributes: false).with_indifferent_access.merge(slice(*self.class.exposed_derived_attributes)) + + redact output + end + + private + + # @param [Hash] output + # @return [Hash] + def redact(output) + if redact_all? + output.transform_values { self.class.redaction_mask } + else + self.class.redaction_filter.filter(output) + end + end + + def redact_all? + self.class.redact_all + end + + class << self + # @param [] attributes + def expose!(*attributes) + attrs = attributes.flatten.map(&:to_sym) + + new_attrs = exposed_derived_attributes | attrs + + exposed_derived_attributes new_attrs + end + + # @api private + # @param [Class] subclass + # @return [void] + def inherited(subclass) + super + + subclass.section_name subclass.name.demodulize.underscore.to_sym + end + + # @param [] attributes + # @return [void] + def redact!(*attributes) + attrs = attributes.flatten.map(&:to_sym) + + new_attrs = redacted_attributes | attrs + + redacted_attributes new_attrs + + recompile_redaction_filter! + end + + # @return [void] + def redact_all! + redact_all true + redact! *attribute_names + end + + # @return [ActiveSupport::ParameterFilter] + def redaction_filter + @redaction_filter ||= compile_redaction_filter + end + + # @return [] + def strong_params + @strong_params ||= compile_strong_params + end + + private + + # @return [ActiveSupport::ParameterFilter] + def compile_redaction_filter + ActiveSupport::ParameterFilter.new(redacted_attributes, mask: redaction_mask) + end + + # @return [] + def compile_strong_params + attribute_names.map(&:to_sym) - skipped_strong_params + end + + # @return [void] + def recompile_redaction_filter! + @redaction_filter = compile_redaction_filter + end + + # @return [void] + def recompile_strong_params! + @strong_params = compile_strong_params + end + end + end +end diff --git a/api/app/services/setting_sections/email.rb b/api/app/services/setting_sections/email.rb new file mode 100644 index 0000000000..8e67210a71 --- /dev/null +++ b/api/app/services/setting_sections/email.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module SettingSections + # Settings related to the email subsystem. + # + # @see DynamicMailer + # @see SettingSections::Secrets#smtp_settings_password + class Email < Base + DELIVERY_METHODS = %w[sendmail].freeze + + attribute :from_address, :string, default: "do-not-reply@manifoldapp.org" + attribute :from_name, :string, default: "Manifold Scholarship" + attribute :closing, :string, default: "Sincerely,\nThe Manifold Team" + attribute :delivery_method, :string, default: "sendmail" + attribute :reply_to_address, :string, default: "do-not-reply@manifoldapp.org" + attribute :reply_to_name, :string, default: "Manifold Scholarship" + + attribute :sendmail_settings_location, :string + attribute :sendmail_settings_arguments, :string + + attribute :smtp_settings_address, :string + attribute :smtp_settings_port, :integer + attribute :smtp_settings_user_name, :string + + validates :delivery_method, inclusion: { in: DELIVERY_METHODS } + end +end diff --git a/api/app/services/setting_sections/general.rb b/api/app/services/setting_sections/general.rb new file mode 100644 index 0000000000..bbc7db2e5f --- /dev/null +++ b/api/app/services/setting_sections/general.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module SettingSections + # The general section for settings that affect interactions and usage of Manifold. + # + # @see Settings + class General < Base + DEFAULT_RESTRICTED_ACCESS_HEADING = "Access to this project is restricted." + DEFAULT_RESTRICTED_ACCESS_BODY = "Only users granted permission may view this project's texts, resources, and other content." + + attribute :installation_name, :string, default: "Manifold" + attribute :head_title, :string, default: "Manifold Scholarship" + attribute :head_description, :string, default: "Transforming scholarly publications into living digital works" + attribute :social_share_message, default: "Shared from Manifold Scholarship" + + attribute :all_standalone, :boolean, default: false + attribute :library_disabled, :boolean, default: false + attribute :restricted_access, :boolean, default: false + attribute :restricted_access_heading, :string, default: DEFAULT_RESTRICTED_ACCESS_HEADING + attribute :restricted_access_body, :string, default: DEFAULT_RESTRICTED_ACCESS_BODY + + attribute :disable_engagement, :boolean, default: false + attribute :disable_reading_groups, :boolean, default: false + attribute :disable_internal_analytics, :boolean, default: false + + attribute :contact_email, :string + attribute :copyright, :string + attribute :default_publisher, :string + attribute :default_publisher_place, :string + attribute :facebook, :string + attribute :home_redirect_url, :string + attribute :library_redirect_url, :string + attribute :press_site, :string + attribute :terms_url, :string + attribute :twitter, :string + + delegate :restricted_access_body_formatted, to: :parent, allow_nil: true + + # This gets doubly exposed in the serializer. + expose! :restricted_access_body_formatted + + # @return [String] + def default_restricted_access_heading + restricted_access_heading.presence || DEFAULT_RESTRICTED_ACCESS_HEADING + end + + # @return [String] + def default_restricted_access_body + restricted_access_body.presence || DEFAULT_RESTRICTED_ACCESS_BODY + end + end +end diff --git a/api/app/services/setting_sections/ingestion.rb b/api/app/services/setting_sections/ingestion.rb new file mode 100644 index 0000000000..d8563c07c8 --- /dev/null +++ b/api/app/services/setting_sections/ingestion.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module SettingSections + # Settings related to the {Ingestion} process. + class Ingestion < Base + attribute :global_styles, :string, default: "" + attribute :mammoth_style_map, :string, default: "" + end +end diff --git a/api/app/services/setting_sections/integrations.rb b/api/app/services/setting_sections/integrations.rb new file mode 100644 index 0000000000..34783f8a1e --- /dev/null +++ b/api/app/services/setting_sections/integrations.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SettingSections + # Information about various integrations for this Manifold installation. + # + # @see SettingSections::Secrets + class Integrations < Base + attribute :facebook_app_id, :string + attribute :ga_four_tracking_id, :string + attribute :google_client_email, :string + attribute :google_client_id, :string + attribute :google_private_key_id, :string + attribute :google_project_id, :string + attribute :twitter_access_token, :string + attribute :twitter_app_id, :string + + # Used by OAuth provider, but not exposed in the API. + attribute :google_oauth_client_id, :string + end +end diff --git a/api/app/services/setting_sections/secrets.rb b/api/app/services/setting_sections/secrets.rb new file mode 100644 index 0000000000..91675c75d8 --- /dev/null +++ b/api/app/services/setting_sections/secrets.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SettingSections + # Secrets for the Manifold installation. + # + # These will all be redacted when being sent to the API. + # + # @see SettingSections::Integrations + class Secrets < Base + attribute :facebook_app_secret, :string + attribute :google_private_key, :string + attribute :smtp_settings_password, :string + attribute :twitter_app_secret, :string + attribute :twitter_access_token_secret, :string + + # Used by OAuth provider, but not exposed in the API. + attribute :google_oauth_client_secret, :string + + redact_all! + end +end diff --git a/api/app/services/setting_sections/serialization.rb b/api/app/services/setting_sections/serialization.rb new file mode 100644 index 0000000000..d49dbc33aa --- /dev/null +++ b/api/app/services/setting_sections/serialization.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module SettingSections + module Serialization + extend ActiveSupport::Concern + + module ClassMethods + # Define a typed attribute that serializes the section + # using optional redaction and additional exposures on + # derived attributes for the given section. + # + # @param [Symbol] name + # @param [Dry::Types::Type] type + # @return [void] + def typed_section_attribute(name, type) + typed_attribute(name, type) do |object, _params| + object.public_send(name).to_serialized_response + end + end + end + end +end diff --git a/api/app/services/setting_sections/theme.rb b/api/app/services/setting_sections/theme.rb new file mode 100644 index 0000000000..86236f7d4d --- /dev/null +++ b/api/app/services/setting_sections/theme.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SettingSections + # Settings related to theming the Manifold installation. + class Theme < Base + DEFAULT_STRING_DATA_USE_COPY = <<~HEREDOC + ## Internal Analytics + Manifold stores anonymous data about what pages users access and how much time they spend on those pages. There is no personally identifiable information stored in relation to usage data. + + ## Annotations and Comments + When you create a highlight, annotate a text, or write a comment, Manifold stores it in the database. + + ## Reading Groups + Manifold stores basic information about each reading group, the content that has been collected in the group, and the group's members. + HEREDOC + + attribute :string_signup_terms_header, :string, default: "First things first..." + attribute :string_signup_terms_one, :string, default: "When you create an account, we will collect and store your name and email address for account management purposes." + attribute :string_signup_terms_two, :string, default: "This site will also store the annotations and highlights you create on texts, and it will keep track of content that you've starred. Depending on its configuration, this site may store anonymous data on how the site is being used." + attribute :string_data_use_header, :string, default: "What data does Manifold store about me?" + attribute :string_data_use_copy, :string, default: DEFAULT_STRING_DATA_USE_COPY + attribute :string_cookies_banner_header, :string, default: "Manifold uses cookies" + attribute :string_cookies_banner_copy, :string, default: "We use cookies to analyze our traffic. Please decide if you are willing to accept cookies from our website. You can change this setting anytime in [Privacy Settings](/privacy)." + + attribute :logo_styles, :string + attribute :typekit_id, :string + attribute :header_offset, :string + attribute :top_bar_text, :string + attribute :top_bar_url, :string + attribute :top_bar_color, :string + attribute :top_bar_mode, :string + attribute :accent_color, :string + attribute :header_foreground_color, :string + attribute :header_foreground_active_color, :string + attribute :header_background_color, :string + end +end diff --git a/api/app/services/setting_sections/types.rb b/api/app/services/setting_sections/types.rb new file mode 100644 index 0000000000..5a35571a74 --- /dev/null +++ b/api/app/services/setting_sections/types.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module SettingSections + module Types + include Dry.Types + + Section = Coercible::String.enum(*SettingSections::NAMES.map(&:to_s)) + end +end diff --git a/api/app/services/settings_service/adjust_google_config.rb b/api/app/services/settings_service/adjust_google_config.rb index a0511148c0..72b1f21680 100644 --- a/api/app/services/settings_service/adjust_google_config.rb +++ b/api/app/services/settings_service/adjust_google_config.rb @@ -1,34 +1,49 @@ -# Service object to format settings as we want +# frozen_string_literal: true + module SettingsService + # Rearrange settings provided in an unprefixed JSON structure into + # a shape that can be cleanly merged into {Settings}. + # + # @see SettingSections::Integrations + # @see SettingSections::Secrets + # @see SettingsService::ReadFromEnv#read_from_file + # @see Updaters::Settings#adjust_google_config class AdjustGoogleConfig < ActiveInteraction::Base hash :config, strip: false + # This corresponds to the name of a {SectionSettings::Base} section + # as keys, and a list of unprefixed keys expected to be found in the input hash. + # + # They will be transformed so that each one is prefixed with `google_`, and stored + # under the corresponding section name. + # + # @api private GOOGLE_KEYS = { secrets: %w(private_key), integrations: %w(private_key_id project_id client_email client_id) }.freeze - # @return [void] - def execute - adjust_google_config! config - end - - private - - def setting_key_for_google(key) - GOOGLE_KEYS.detect do |setting_key, keys| - return setting_key if key.to_s.in? keys + # A remapping of {GOOGLE_KEYS} to make the lookup simpler. + # + # @api private + SETTING_KEYS = GOOGLE_KEYS.each_with_object({}.with_indifferent_access) do |(setting_key, config_keys), mapping| + config_keys.each do |config_key| + mapping[config_key] = setting_key end - end + end.freeze + + # @return [ActiveSupport::HashWithIndifferentAccess] + def execute + output = GOOGLE_KEYS.keys.index_with { {} } - def adjust_google_config!(attributes) - attributes.each_with_object({}) do |(k, v), out| - setting_key = setting_key_for_google(k) + config.each_with_object(output) do |(k, v), out| + setting_key = SETTING_KEYS[k] - next attributes.delete(k) if setting_key.nil? + # :nocov: + next if setting_key.nil? + # :nocov: - out[setting_key] ||= {} - out[setting_key]["google_#{k}".to_sym] = v + out[setting_key][:"google_#{k}"] = v end end end diff --git a/api/app/services/settings_service/read_from_env.rb b/api/app/services/settings_service/read_from_env.rb index dd5ef179b8..1401b923a8 100644 --- a/api/app/services/settings_service/read_from_env.rb +++ b/api/app/services/settings_service/read_from_env.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module SettingsService # Read settings from `ENV` and transform into a suitable hash # that can be merged into {Settings}. @@ -47,12 +49,14 @@ def parse_value(section, setting, value) # The value is potentially stored in the filesystem. def read_from_file(setting, value) config_path = Rails.application.root.join("..", value) + return unless config_path.file? case setting when :google_service - data = JSON.parse(File.read(config_path)).to_h - SettingsService::AdjustGoogleConfig.run! config: data + config = JSON.parse(config_path.read).to_h + + SettingsService::AdjustGoogleConfig.run! config: config end end end diff --git a/api/app/services/settings_service/update_from_env.rb b/api/app/services/settings_service/update_from_env.rb index a4baa95285..26dc3c468f 100644 --- a/api/app/services/settings_service/update_from_env.rb +++ b/api/app/services/settings_service/update_from_env.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module SettingsService # After reading settings from `ENV`, merge them into # their respective attributes on {Settings}. # - # @see [Settings::ReadFromEnv] + # @see Settings::ReadFromEnv class UpdateFromEnv < ActiveInteraction::Base - object :settings, default: proc { Settings.instance } + record :settings, default: proc { Settings.instance } # @return [void] def execute @@ -14,13 +16,13 @@ def execute settings.merge_settings_into! section, section_settings end - unless settings.save - errors.add :base, settings.errors.full_messages.to_sentence + return if settings.save - return false - end + # :nocov: + errors.add :base, settings.errors.full_messages.to_sentence - nil + return false + # :nocov: end end end diff --git a/api/app/services/settings_service/update_oauth_providers.rb b/api/app/services/settings_service/update_oauth_providers.rb index 13c2e9444a..93e82070a2 100644 --- a/api/app/services/settings_service/update_oauth_providers.rb +++ b/api/app/services/settings_service/update_oauth_providers.rb @@ -1,13 +1,25 @@ +# frozen_string_literal: true + module SettingsService + # Update the OAuth providers in ManifoldEnv after saving. + # + # @see ManifoldEnv.oauth class UpdateOauthProviders < ActiveInteraction::Base - object :settings, default: proc { Settings.instance } + record :settings, default: proc { Settings.instance } + # A mapping of provider names to a pair of setting keys + # in the {SettingSections::Integrations integrations} + # and {SettingSections::Secrets secrets} sections + # respectively. + # + # @api private PROVIDER_MAPPING = { facebook: %i(facebook_app_id facebook_app_secret), google: %i(google_oauth_client_id google_oauth_client_secret), twitter: %i(twitter_app_id twitter_app_secret) }.freeze + # @return [void] def execute PROVIDER_MAPPING.each do |provider_name, (app_id_key, secret_key)| provider = ManifoldEnv.oauth[provider_name] diff --git a/api/app/services/utility/enhanced_store_model.rb b/api/app/services/utility/enhanced_store_model.rb index f036bdb009..fb24002fe9 100644 --- a/api/app/services/utility/enhanced_store_model.rb +++ b/api/app/services/utility/enhanced_store_model.rb @@ -4,30 +4,61 @@ module Utility module EnhancedStoreModel extend ActiveSupport::Concern + include Sliceable + included do include StoreModel::Model - end - # @param [#to_s] attr - # @return [Object] - def [](attr) - public_send(attr) + prepend EnhancedBrackets end - # @param [#to_s] attr - # @param [Object] - # @return [void] - def []=(attr, value) - public_send(:"#{attr}=", value) + delegate :dig, :fetch, to: :indifferent_hash + + # @return [ActiveSupport::HashWithIndifferentAccess] + def indifferent_hash + as_json.with_indifferent_access end - # @param [{ Symbol => Object }] attrs + # @param [StoreModel::Base, { Symbol => Object }] attrs # @return [void] def merge!(attrs) - return unless attrs.is_a?(Hash) + case attrs + when self.class + merge!(attrs.as_json) + when Hash + attrs.each do |attr, value| + self[attr] = value + end + end + + return self + end + + alias safe_assign_attributes merge! + + # This module needs to be prepended _over_ `StoreModel::Model`. + # + # @api private + module EnhancedBrackets + # @param [#to_s] attr + # @return [Object] + def [](attr) + if has_attribute?(attr) + super + else + unknown_attributes[attr.to_s] + end + end - attrs.each do |attr, value| - self[attr] = value + # @param [#to_s] attr + # @param [Object] value + # @return [void] + def []=(attr, value) + if has_attribute?(attr) + super + else + unknown_attributes[attr.to_s] = value + end end end end diff --git a/api/app/services/validator/stylesheet.rb b/api/app/services/validator/stylesheet.rb index 5d162311be..dd3e939f2f 100644 --- a/api/app/services/validator/stylesheet.rb +++ b/api/app/services/validator/stylesheet.rb @@ -1,10 +1,8 @@ -require "memoist" +# frozen_string_literal: true module Validator - # This class takes an CSS string input and validates to ensure it follows our CSS - # rules. + # This class takes an CSS string input and validates to ensure it follows our CSS rules. class Stylesheet - extend Memoist def initialize(config = nil) @@ -31,7 +29,7 @@ def validate(css) # creates a string that looks like `"\\25cf "` by converting the character to its # ordinal value, casting that to a hexadecimal string (`to_s(16)`), and then # rjustifying that string so it is zerofilled 4-width, which is what CSS expects. - # rubocop:disable Style/CharacterLiteral, Style/RescueStandardError + # rubocop:disable Style/RescueStandardError def encode_css_for_parser(string) parts = string.chars.map do |c| c.encode("binary", "utf-8") @@ -40,7 +38,7 @@ def encode_css_for_parser(string) end parts.join end - # rubocop:enable Style/CharacterLiteral, Style/RescueStandardError + # rubocop:enable Style/RescueStandardError # Removes disallowed declarations from declarations # @param declarations [String] @@ -96,15 +94,14 @@ def create_inverted_declarations(declarations) # @param css [String] # @return [String] def extract_at_rules(css) - out = "" + out = "".dup css.each_line do |line| - # rubocop:disable Style/DoubleNegation - if !!(line =~ /^(\s*)@(.*);(\s*)$/) + case line + when /^(\s*)@(.*);(\s*)$/ @out << line else out << line end - # rubocop:enable Style/DoubleNegation end out end @@ -121,7 +118,7 @@ def output # Reset the validator state def reset - @out = "" + @out = "".dup @parser = CssParser::Parser.new end @@ -376,7 +373,6 @@ def allowed_selector?(selector) pattern = Regexp.union(@config.exclusions.selectors) (sel =~ pattern).nil? end - end class InvalidCondition < KeyError diff --git a/api/spec/models/settings_spec.rb b/api/spec/models/settings_spec.rb index 0223bce96e..5225828316 100644 --- a/api/spec/models/settings_spec.rb +++ b/api/spec/models/settings_spec.rb @@ -1,32 +1,50 @@ -require 'rails_helper' +# frozen_string_literal: true RSpec.describe Settings, type: :model do + let_it_be(:instance, refind: true) { described_class.instance } - it "sets general settings correctly" do - s = Settings.instance() - s.general = { a: "a" } - expect(s.general["a"]).to eq "a" + it "sets unknown general settings correctly" do + expect do + instance.general = { a: ?a } + end.to change { instance.general[?a] }.from(nil).to(?a) + .and change { instance.general.unknown_attributes.include?(?a) }.from(false).to(true) end - it "sets ingestion settings correctly" do - s = Settings.instance - s.ingestion = { mammoth_style_map: "foo bar" } - s.save - s.reload - expect(s.ingestion[:mammoth_style_map]).to eq "foo bar" + it "persists directly assigned hash settings correctly" do + expect do + instance.ingestion = { mammoth_style_map: "foo bar" } + instance.save! + end.to change { instance.reload.ingestion[:mammoth_style_map] }.to("foo bar") end - it "has default values" do - s = Settings.instance() - expect(s.general["installation_name"]).to eq "Manifold" + it "handles shallow merges" do + expect do + instance.general = { a: "a" } + instance.merge_settings_into! :general, b: 'b' + end.to change { instance.general[?a] }.from(nil).to(?a) + .and change { instance.general[?b] }.from(nil).to(?b) + .and change { instance.general.unknown_attributes.keys & %w[a b] }.from([]).to(%w[a b]) end - it "it shallow merges new general settings into existing ones" do - s = Settings.instance() - s.general = { a: "a" } - s.merge_settings_into! :general, b: 'b' - expect(s.general[:a]).to eq ("a") - expect(s.general[:b]).to eq ("b") - end + context "when detecting whether to update from the environment" do + subject { described_class } + + context "when the var is truthy" do + before do + stub_env "MANAGE_SETTINGS_FROM_ENV", ?1 + end + + it { is_expected.to be_manage_from_env } + it { is_expected.to be_update_from_environment } + end + context "when the var is unset / blank" do + before do + stub_env "MANAGE_SETTINGS_FROM_ENV", "" + end + + it { is_expected.not_to be_manage_from_env } + it { is_expected.not_to be_update_from_environment } + end + end end diff --git a/api/spec/operations/utility/booleanize_spec.rb b/api/spec/operations/utility/booleanize_spec.rb new file mode 100644 index 0000000000..7859cef087 --- /dev/null +++ b/api/spec/operations/utility/booleanize_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe Utility::Booleanize, type: :operation do + let(:operation) { described_class.new } + + it "parses a variety of inputs correctly", :aggregate_failures do + expect(operation.call(true)).to eq true + expect(operation.call(?t)).to eq true + expect(operation.call(?f)).to eq false + expect(operation.call(0)).to eq false + expect(operation.call(?0)).to eq false + expect(operation.call("")).to eq false + expect(operation.call("no")).to eq false + expect(operation.call("yes")).to eq true + expect(operation.call([])).to eq true + end +end diff --git a/api/spec/services/settings/adjust_google_config_spec.rb b/api/spec/services/settings/adjust_google_config_spec.rb deleted file mode 100644 index 3ffceb7f4a..0000000000 --- a/api/spec/services/settings/adjust_google_config_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'rails_helper' - -RSpec.describe SettingsService::AdjustGoogleConfig do - let(:config_path) { "spec/data/sample_config/google_service.json" } - let(:adjusted_config) { - { - integrations: { - google_project_id: "manifold-test", - google_private_key_id: "123abc", - google_client_email: "manifold@manifold.app", - google_client_id: "9000" - }, - secrets: { - google_private_key: "shhhhhhhh" - } - } - - } - - it 'correctly formats the config' do - data = JSON.parse File.read(config_path) - expect(SettingsService::AdjustGoogleConfig.run!(config: data)).to eq adjusted_config - end - -end diff --git a/api/spec/services/settings_service/adjust_google_config_spec.rb b/api/spec/services/settings_service/adjust_google_config_spec.rb new file mode 100644 index 0000000000..9a7726a3a7 --- /dev/null +++ b/api/spec/services/settings_service/adjust_google_config_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe SettingsService::AdjustGoogleConfig, interaction: true do + let(:config_path) { "spec/data/sample_config/google_service.json" } + + let(:expected_config) do + { + integrations: { + google_project_id: "manifold-test", + google_private_key_id: "123abc", + google_client_email: "manifold@manifold.app", + google_client_id: "9000" + }, + secrets: { + google_private_key: "shhhhhhhh", + }, + } + end + + let_input!(:config) { JSON.parse Rails.root.join(config_path).read } + + it "correctly formats the config" do + perform_within_expectation! + + expect(@outcome.result).to eq expected_config + end +end diff --git a/api/spec/services/settings_service/read_from_env_spec.rb b/api/spec/services/settings_service/read_from_env_spec.rb new file mode 100644 index 0000000000..709cff2403 --- /dev/null +++ b/api/spec/services/settings_service/read_from_env_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.describe SettingsService::ReadFromEnv, interaction: true do + context "when reading from the special `:config` section" do + let(:google_service) { Rails.root.join("spec", "data", "sample_config", "google_service.json").to_s } + + let(:expected_settings) do + { + integrations: { + google_project_id: "manifold-test", + google_private_key_id: "123abc", + google_client_email: "manifold@manifold.app", + google_client_id: "9000" + }, + secrets: { + google_private_key: "shhhhhhhh", + }, + } + end + + before do + stub_env("MANIFOLD_SETTING_CONFIG_GOOGLE_SERVICE", google_service) + + # Unknown configs are silently ignored. + stub_env("MANIFOLD_SETTING_CONFIG_SOME_UNKNOWN_SERVICE", "never/seen/path") + end + + it "parses and reads the google config" do + perform_within_expectation! + + expect(@outcome.result).to include_json(expected_settings) + end + end + + context "when reading standard environment overrides" do + let(:smtp_settings_password) { "12356" } + let(:unknown_value) { "some unknown value" } + let(:other) { "never seen" } + + let(:expected_settings) do + { + invalid: { + other: other, + }, + secrets: { + smtp_settings_password: smtp_settings_password, + unknown_value: unknown_value, + } + } + end + + before do + stub_env("MANIFOLD_SETTING_SECRETS_SMTP_SETTINGS_PASSWORD", smtp_settings_password) + stub_env("MANIFOLD_SETTING_SECRETS_UNKNOWN_VALUE", unknown_value) + stub_env("MANIFOLD_SETTING_INVALID_OTHER", other) + end + + it "pulls out the expected settings" do + perform_within_expectation! + + expect(@outcome.result).to include_json(expected_settings) + end + end +end diff --git a/api/spec/services/settings_service/update_from_env_spec.rb b/api/spec/services/settings_service/update_from_env_spec.rb new file mode 100644 index 0000000000..6c666ff422 --- /dev/null +++ b/api/spec/services/settings_service/update_from_env_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe SettingsService::UpdateFromEnv, interaction: true do + let_it_be(:settings_instance, refind: true) { Settings.instance } + + let_input!(:settings) { settings_instance } + + let(:smtp_settings_password) { "12356" } + let(:unknown_value) { "some unknown value" } + + before do + stub_env("MANIFOLD_SETTING_SECRETS_SMTP_SETTINGS_PASSWORD", smtp_settings_password) + # Unknown values will be handled automatically and stored quietly. + stub_env("MANIFOLD_SETTING_SECRETS_UNKNOWN_VALUE", unknown_value) + # The following will be silently ignored. + stub_env("MANIFOLD_SETTING_INVALID_OTHER", "never seen") + end + + it "merges in new settings from the environment" do + perform_within_expectation! do |e| + e.to change { settings.reload.secrets.smtp_settings_password }.to(smtp_settings_password) + .and change { settings.reload.secrets.unknown_attributes["unknown_value"] }.from(nil).to(unknown_value) + end + end +end diff --git a/api/spec/support/helpers/stubbed_env.rb b/api/spec/support/helpers/stubbed_env.rb new file mode 100644 index 0000000000..e20a68aa65 --- /dev/null +++ b/api/spec/support/helpers/stubbed_env.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TestHelpers + module StubENV + def stub_env(key_or_hash, value = nil) + stubbed_hash = + if key_or_hash.kind_of?(Hash) + key_or_hash + else + { key_or_hash => value } + end + + stub_const("ENV", ENV.to_h.merge(stubbed_hash)) + end + end +end + +RSpec.configure do |config| + config.include TestHelpers::StubENV +end From a2974d0ccfdab690e867c4f608fbc90992349b00 Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Wed, 21 Feb 2024 22:24:41 -0800 Subject: [PATCH 02/12] [B] Correct logic for `manifold_analytics_enabled` There was an obvious typo in the original logic that is being fixed in a separate commit to make it more clear what's happening. It should now read from the proper section setting. --- api/app/models/settings.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/app/models/settings.rb b/api/app/models/settings.rb index 52a978190a..3202ae82d3 100644 --- a/api/app/models/settings.rb +++ b/api/app/models/settings.rb @@ -43,6 +43,13 @@ def google_analytics_enabled alias google_analytics_enabled? google_analytics_enabled + # @return [Boolean] + def manifold_analytics_enabled + !general.disable_internal_analytics? + end + + alias manifold_analytics_enabled? manifold_analytics_enabled + # @!endgroup SECTIONS.each do |section| @@ -88,7 +95,7 @@ def calculated(current_user = nil) manifold_version: self.class.manifold_version, require_terms_and_conditions: Page.by_purpose(:terms_and_conditions).exists?, google_analytics_enabled: google_analytics_enabled, - manifold_analytics_enabled: integrations["disable_internal_analytics"] != true + manifold_analytics_enabled: manifold_analytics_enabled, } end From 54d20758b23dd8d418f8dca9f147fddf029c606c Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Wed, 21 Feb 2024 22:27:40 -0800 Subject: [PATCH 03/12] [C] Downgrade unused dalli dependency * This gets rid of an annoying deprecation message that shows up every time the app / console boots. --- api/Gemfile | 2 +- api/Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/Gemfile b/api/Gemfile index 75e96cf496..0521469eb9 100644 --- a/api/Gemfile +++ b/api/Gemfile @@ -28,7 +28,7 @@ gem "crass", "~> 1.0.5" gem "csl-styles", "~> 1.0" gem "cssbeautify" gem "css_parser", "~> 1.0" -gem "dalli", "~> 3.2.3" +gem "dalli", "2.7.11" gem "data_uri", "~> 0.1.0" gem "dotenv-rails", "~> 2.0" gem "draper", "~> 3.1" diff --git a/api/Gemfile.lock b/api/Gemfile.lock index 50033b2787..e1afcf2934 100644 --- a/api/Gemfile.lock +++ b/api/Gemfile.lock @@ -152,7 +152,7 @@ GEM css_parser (1.14.0) addressable cssbeautify (0.2.0) - dalli (3.2.6) + dalli (2.7.11) data_uri (0.1.0) database_cleaner-active_record (2.1.0) activerecord (>= 5.a) @@ -832,7 +832,7 @@ DEPENDENCIES csl-styles (~> 1.0) css_parser (~> 1.0) cssbeautify - dalli (~> 3.2.3) + dalli (= 2.7.11) data_uri (~> 0.1.0) database_cleaner-active_record (~> 2.1.0) database_cleaner-redis (~> 2.0) From ccbcd8d3c4e05b0d00e887e39fd614f56265176f Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Thu, 22 Feb 2024 05:16:45 -0800 Subject: [PATCH 04/12] [C] Normalize request specs * Behavior is inconsistent between swagger specs and normal request specs, causing order-of-operation problems when trying to use `let_it_be` / optimizations on certain records. This ensures all requests load the normalized contexts for further improvements and refinements when testing requests. --- api/spec/api_docs/examples/create.rb | 5 ++- api/spec/api_docs/examples/destroy.rb | 5 ++- api/spec/api_docs/examples/index.rb | 5 ++- api/spec/api_docs/examples/show.rb | 5 ++- api/spec/api_docs/examples/update.rb | 5 ++- api/spec/authorizers/flag_authorizer_spec.rb | 12 +++---- api/spec/factories/reading_group.rb | 10 ++++++ api/spec/models/ingestion_spec.rb | 9 +++-- api/spec/rails_helper.rb | 10 ++++++ api/spec/requests/action_callouts_spec.rb | 5 +-- .../requests/api/v1/action_callouts_spec.rb | 3 +- api/spec/requests/api/v1/annotations_spec.rb | 26 +++++++------- api/spec/requests/api/v1/comments_spec.rb | 20 ++++++----- .../requests/api/v1/reading_group_spec.rb | 2 ++ api/spec/requests/categories_spec.rb | 10 +----- api/spec/requests/comments_spec.rb | 7 +--- api/spec/requests/contacts_controller_spec.rb | 5 +-- api/spec/requests/content_blocks_spec.rb | 5 +-- api/spec/requests/email_confirmations_spec.rb | 5 --- api/spec/requests/entitlement_imports_spec.rb | 5 --- api/spec/requests/entitlements_spec.rb | 5 +-- api/spec/requests/errors_spec.rb | 35 ++++++++----------- api/spec/requests/events_spec.rb | 7 +--- api/spec/requests/export_targets_spec.rb | 5 +-- api/spec/requests/flags_spec.rb | 11 +----- api/spec/requests/ingestion_spec.rb | 16 ++++----- .../journal_issues_controller_spec.rb | 16 ++++----- api/spec/requests/makers_spec.rb | 6 +--- .../me/relationships/annotations_spec.rb | 5 +-- .../relationships/favorite_projects_spec.rb | 9 +---- .../me/relationships/favorites_spec.rb | 11 ++---- .../me/relationships/reading_groups_spec.rb | 17 +++------ api/spec/requests/me_spec.rb | 4 +-- .../unsubscribe_controller_spec.rb | 6 +--- api/spec/requests/oauth_spec.rb | 7 ++-- api/spec/requests/pages_spec.rb | 7 +--- api/spec/requests/passwords_spec.rb | 13 +++---- .../requests/pending_entitlements_spec.rb | 5 --- .../collection_projects_controller_spec.rb | 9 ++--- api/spec/requests/project_collections_spec.rb | 6 +--- .../requests/project_exportations_spec.rb | 5 +-- api/spec/requests/projects/ingestions_spec.rb | 14 ++++---- .../relationships/action_callouts_spec.rb | 6 +--- .../relationships/content_blocks_spec.rb | 8 ++--- .../projects/relationships/events_spec.rb | 8 ++--- .../permissions_controller_spec.rb | 9 +++-- .../relationships/resource_imports_spec.rb | 28 ++++++--------- .../projects/relationships/resources_spec.rb | 8 ++--- .../relationships/text_categories_spec.rb | 14 ++------ .../relationships/twitter_queries_spec.rb | 7 ++-- .../uncollected_resources_spec.rb | 6 +--- api/spec/requests/projects_spec.rb | 5 +-- .../reading_group_memberships_spec.rb | 26 ++++++-------- .../reading_group_memberships_spec.rb | 13 ++----- api/spec/requests/reading_groups_spec.rb | 29 +++++---------- .../requests/resource_collections_spec.rb | 11 +----- api/spec/requests/resources_spec.rb | 7 ++-- api/spec/requests/search_results_spec.rb | 6 ++-- api/spec/requests/stylesheets_spec.rb | 21 ++++------- api/spec/requests/subjects_spec.rb | 6 +--- api/spec/requests/tags_spec.rb | 6 +--- .../relationships/annotations_spec.rb | 6 ++-- api/spec/requests/text_sections_spec.rb | 6 +--- api/spec/requests/texts/ingestions_spec.rb | 14 ++++---- .../text_sections_controller_spec.rb | 7 +--- api/spec/requests/texts_spec.rb | 6 +--- api/spec/requests/tokens_spec.rb | 5 +-- api/spec/requests/users_spec.rb | 5 --- .../support/requests/authenticated_request.rb | 23 +++++++----- api/spec/support/requests/param_helpers.rb | 8 +++-- api/spec/support/requests/simple_auth.rb | 2 ++ .../shared_examples/orderable_record.rb | 8 ++--- 72 files changed, 238 insertions(+), 454 deletions(-) diff --git a/api/spec/api_docs/examples/create.rb b/api/spec/api_docs/examples/create.rb index fb6f2af7dd..a84f22c49a 100644 --- a/api/spec/api_docs/examples/create.rb +++ b/api/spec/api_docs/examples/create.rb @@ -1,7 +1,6 @@ -shared_examples_for "an API create request" do |options| - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "an API create request" do |options| api_spec_helper = APIDocs::Helpers::Request.new(options, :create) let(:body) { json_structure_from_factory(api_spec_helper.factory, type: :request) } if api_spec_helper.response_body? diff --git a/api/spec/api_docs/examples/destroy.rb b/api/spec/api_docs/examples/destroy.rb index 6aa3e17091..13b1f567ca 100644 --- a/api/spec/api_docs/examples/destroy.rb +++ b/api/spec/api_docs/examples/destroy.rb @@ -1,7 +1,6 @@ -shared_examples_for "an API destroy request" do |options| - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "an API destroy request" do |options| api_spec_helper = APIDocs::Helpers::Request.new(options, :destroy) let(:resource_instance) do diff --git a/api/spec/api_docs/examples/index.rb b/api/spec/api_docs/examples/index.rb index f373ccfa35..3c4d06158a 100644 --- a/api/spec/api_docs/examples/index.rb +++ b/api/spec/api_docs/examples/index.rb @@ -1,7 +1,6 @@ -shared_examples_for "an API index request" do |options| - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "an API index request" do |options| api_spec_helper = APIDocs::Helpers::Request.new(options, :index) get api_spec_helper.summary do diff --git a/api/spec/api_docs/examples/show.rb b/api/spec/api_docs/examples/show.rb index 28a11d5451..4fe886a3e9 100644 --- a/api/spec/api_docs/examples/show.rb +++ b/api/spec/api_docs/examples/show.rb @@ -1,7 +1,6 @@ -shared_examples_for "an API show request" do |options| - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "an API show request" do |options| api_spec_helper = APIDocs::Helpers::Request.new(options, :show) if api_spec_helper.instantiate_before_test? diff --git a/api/spec/api_docs/examples/update.rb b/api/spec/api_docs/examples/update.rb index 59e72e7e86..bf780b7a7d 100644 --- a/api/spec/api_docs/examples/update.rb +++ b/api/spec/api_docs/examples/update.rb @@ -1,7 +1,6 @@ -shared_examples_for "an API update request" do |options| - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "an API update request" do |options| api_spec_helper = APIDocs::Helpers::Request.new(options, :update) let(:resource_instance) do diff --git a/api/spec/authorizers/flag_authorizer_spec.rb b/api/spec/authorizers/flag_authorizer_spec.rb index fc665f1115..db189c2d31 100644 --- a/api/spec/authorizers/flag_authorizer_spec.rb +++ b/api/spec/authorizers/flag_authorizer_spec.rb @@ -1,25 +1,25 @@ -require 'rails_helper' +# frozen_string_literal: true RSpec.describe "Flag Abilities", :authorizer do - let(:creator) { FactoryBot.create(:user, :reader) } - let(:object) { FactoryBot.create(:flag, creator: creator) } + let_it_be(:creator, refind: true) { FactoryBot.create(:user, :reader) } + let_it_be(:object, refind: true) { FactoryBot.create(:flag, creator: creator) } context 'when the subject is an admin' do - let(:subject) { FactoryBot.create(:user, :admin) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user, :admin) } abilities = { create: true, read: false, update: false, delete: true } the_subject_behaves_like "instance abilities", Flag, abilities end context 'when the subject is a reader' do - let(:subject) { FactoryBot.create(:user) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user) } abilities = { create: true, read: false, update: false, delete: false } the_subject_behaves_like "instance abilities", Flag, abilities end context 'when the subject is the resource creator' do - let(:subject) { creator } + let_it_be(:subject, refind: true) { creator } abilities = { create: true, read: false, update: false, delete: true } the_subject_behaves_like "instance abilities", Flag, abilities diff --git a/api/spec/factories/reading_group.rb b/api/spec/factories/reading_group.rb index 41a9add5f5..fae90e01df 100644 --- a/api/spec/factories/reading_group.rb +++ b/api/spec/factories/reading_group.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + FactoryBot.define do factory :reading_group do transient do @@ -12,5 +14,13 @@ create :reading_group_membership, reading_group: reading_group, user: user end end + + trait :is_private do + privacy { "private" } + end + + trait :is_public do + privacy { "public" } + end end end diff --git a/api/spec/models/ingestion_spec.rb b/api/spec/models/ingestion_spec.rb index 8a1b2aa2c5..8fd26e1692 100644 --- a/api/spec/models/ingestion_spec.rb +++ b/api/spec/models/ingestion_spec.rb @@ -1,15 +1,14 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe Ingestion, type: :model do - include_context("authenticated request") include_context("param helpers") - let(:attributes) { + let(:attributes) do { - source: markdown_source_params + source: markdown_source_params, } - } + end let(:ingestion) do ingestion = Ingestion.new(creator: admin) diff --git a/api/spec/rails_helper.rb b/api/spec/rails_helper.rb index afd2c58959..a40f3d667a 100644 --- a/api/spec/rails_helper.rb +++ b/api/spec/rails_helper.rb @@ -6,6 +6,7 @@ require "rspec/rails" require "test_prof/recipes/rspec/let_it_be" +require "test_prof/recipes/rspec/factory_default" require "webmock/rspec" require "dry/system/stubs" require "closure_tree/test/matcher" @@ -36,6 +37,11 @@ ActiveJob::Uniqueness.test_mode! +TestProf::FactoryDefault.configure do |config| + config.preserve_attributes = true + config.preserve_traits = true +end + RSpec.configure do |config| config.include TestHelpers config.extend WithModel @@ -72,6 +78,10 @@ clear_enqueued_jobs end + config.after do + RequestStore.clear! + end + config.before(:suite) do ManifoldApi::Container.enable_stubs! end diff --git a/api/spec/requests/action_callouts_spec.rb b/api/spec/requests/action_callouts_spec.rb index 8d30bd5ddf..7bbf481add 100644 --- a/api/spec/requests/action_callouts_spec.rb +++ b/api/spec/requests/action_callouts_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Action Callout API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:action_callout) { FactoryBot.create(:action_callout) } let(:path) { api_v1_action_callout_path(action_callout) } let(:api_response) { JSON.parse(response.body) } diff --git a/api/spec/requests/api/v1/action_callouts_spec.rb b/api/spec/requests/api/v1/action_callouts_spec.rb index 4ceb2adef1..55be55ea93 100644 --- a/api/spec/requests/api/v1/action_callouts_spec.rb +++ b/api/spec/requests/api/v1/action_callouts_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "swagger_helper" RSpec.describe "Action Callouts", type: :request do @@ -54,5 +56,4 @@ description: conditional_requirements end end - end diff --git a/api/spec/requests/api/v1/annotations_spec.rb b/api/spec/requests/api/v1/annotations_spec.rb index 5220ac3763..bc942de6a5 100644 --- a/api/spec/requests/api/v1/annotations_spec.rb +++ b/api/spec/requests/api/v1/annotations_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "swagger_helper" RSpec.describe "Annotations", type: :request do @@ -11,9 +13,9 @@ end describe "for a text section" do - let!(:parent) { FactoryBot.create(:text_section) } - let!(:text_section_id) { parent.id } - let!(:text_id) { parent.text.id } + let_it_be(:parent, refind: true) { FactoryBot.create(:text_section) } + let_it_be(:text_section_id) { parent.id } + let_it_be(:text_id) { parent.text.id } path "/texts/{text_id}/relationships/text_sections/{text_section_id}/annotations" do include_examples "an API index request", @@ -40,9 +42,9 @@ end describe "for me" do - let!(:text) { FactoryBot.create :text } - let!(:text_section) { FactoryBot.create(:text_section, text: text) } - let!(:annotation) do + let_it_be(:text, refind: true) { FactoryBot.create :text } + let_it_be(:text_section, refind: true) { FactoryBot.create(:text_section, text: text) } + let_it_be(:annotation, refind: true) do FactoryBot.create(:annotation, creator: admin, text_section: text_section) end @@ -67,9 +69,9 @@ end describe "for a reading group" do - let!(:parent) { FactoryBot.create(:reading_group) } - let!(:annotation) { FactoryBot.create(:annotation, reading_group: parent) } - let!(:reading_group_id) { parent.id } + let_it_be(:parent, refind: true) { FactoryBot.create(:reading_group) } + let_it_be(:annotation, refind: true) { FactoryBot.create(:annotation, reading_group: parent) } + let_it_be(:reading_group_id) { parent.id } path "/reading_groups/{reading_group_id}/relationships/annotations" do include_examples "an API index request", @@ -82,9 +84,9 @@ end context "when managing flagging" do - let!(:annotation) { FactoryBot.create(:annotation, creator: admin) } - let!(:annotation_id) { annotation.id } - let!(:flag) { FactoryBot.create(:flag, creator: admin, flaggable: annotation) } + let_it_be(:annotation, refind: true) { FactoryBot.create(:annotation, creator: admin) } + let_it_be(:annotation_id) { annotation.id } + let_it_be(:flag, refind: true) { FactoryBot.create(:flag, creator: admin, flaggable: annotation) } path "/annotations/{annotation_id}/relationships/flags" do include_examples "an API create request", diff --git a/api/spec/requests/api/v1/comments_spec.rb b/api/spec/requests/api/v1/comments_spec.rb index 8f2989bfbf..1b157fe8ec 100644 --- a/api/spec/requests/api/v1/comments_spec.rb +++ b/api/spec/requests/api/v1/comments_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require "swagger_helper" RSpec.describe "Comments", type: :request do context "for an annotation" do - let(:parent) { FactoryBot.create(:annotation) } - let(:resource) { FactoryBot.create(:comment, subject: parent) } - let(:annotation_id) { parent.id } + let_it_be(:parent, refind: true) { FactoryBot.create(:annotation) } + let_it_be(:resource, refind: true) { FactoryBot.create(:comment, subject: parent) } + let_it_be(:annotation_id) { parent.id } path "/annotations/{annotation_id}/relationships/comments/{id}" do include_examples "an API show request", parent: "annotation", model: Comment, url_parameters: [:annotation_id] @@ -19,9 +21,9 @@ end context "for a resource" do - let(:parent) { FactoryBot.create(:resource) } - let(:resource) { FactoryBot.create(:comment, subject: parent) } - let(:resource_id) { parent.id } + let_it_be(:parent, refind: true) { FactoryBot.create(:resource) } + let_it_be(:resource, refind: true) { FactoryBot.create(:comment, subject: parent) } + let_it_be(:resource_id) { parent.id } path "/resources/{resource_id}/relationships/comments/{id}" do include_examples "an API show request", parent: "resource", model: Comment, url_parameters: [:resource_id] @@ -42,9 +44,9 @@ describe "when taking the form of a flag" do context "when attached to a comment" do - let!(:comment) { FactoryBot.create(:comment, creator: admin) } - let!(:comment_id) { comment.id } - let!(:flag) { FactoryBot.create(:flag, flaggable: comment, creator: admin) } + let_it_be(:comment, refind: true) { FactoryBot.create(:comment, creator: admin) } + let_it_be(:comment_id) { comment.id } + let_it_be(:flag, refind: true) { FactoryBot.create(:flag, flaggable: comment, creator: admin) } path "/comments/{comment_id}/relationships/flags" do include_examples "an API create request", diff --git a/api/spec/requests/api/v1/reading_group_spec.rb b/api/spec/requests/api/v1/reading_group_spec.rb index ebb7d32ef5..0aa8a6951c 100644 --- a/api/spec/requests/api/v1/reading_group_spec.rb +++ b/api/spec/requests/api/v1/reading_group_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "swagger_helper" RSpec.describe "Reading Group", type: :request do diff --git a/api/spec/requests/categories_spec.rb b/api/spec/requests/categories_spec.rb index e72a50d1e6..b98ed3f04b 100644 --- a/api/spec/requests/categories_spec.rb +++ b/api/spec/requests/categories_spec.rb @@ -1,12 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Categories API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - describe "sends a category" do - let(:category) { FactoryBot.create(:category, role: Category::ROLE_TEXT) } describe "the response" do @@ -17,10 +12,7 @@ end end - - describe "updates a text" do - it_should_behave_like "orderable api requests" do let(:path) { "api_v1_category_path" } let!(:object_a) { FactoryBot.create(:category, position: 1) } diff --git a/api/spec/requests/comments_spec.rb b/api/spec/requests/comments_spec.rb index 8a4d74e43d..b4b0f4a3ca 100644 --- a/api/spec/requests/comments_spec.rb +++ b/api/spec/requests/comments_spec.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.describe "Comments API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let_it_be(:annotation, refind: true) { FactoryBot.create(:annotation) } let_it_be(:resource, refind: true) { FactoryBot.create(:resource) } let_it_be(:comment_a, refind: true) { FactoryBot.create(:comment, creator: reader, subject: annotation) } @@ -104,6 +98,7 @@ context "when the user is an admin" do let(:headers) { admin_headers } + it("returns a saved comment") do post path, headers: headers, params: params api_response = JSON.parse(response.body) diff --git a/api/spec/requests/contacts_controller_spec.rb b/api/spec/requests/contacts_controller_spec.rb index 7ee39263a4..2fcc93b0f5 100644 --- a/api/spec/requests/contacts_controller_spec.rb +++ b/api/spec/requests/contacts_controller_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Contacts API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:headers) { anonymous_headers } let(:valid_params) do { attributes: { diff --git a/api/spec/requests/content_blocks_spec.rb b/api/spec/requests/content_blocks_spec.rb index 69749af3ad..c80f219067 100644 --- a/api/spec/requests/content_blocks_spec.rb +++ b/api/spec/requests/content_blocks_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "ContentBlocks API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:content_block) { FactoryBot.create(:markdown_block) } let(:path) { api_v1_content_block_path(content_block) } let(:api_response) { JSON.parse(response.body) } diff --git a/api/spec/requests/email_confirmations_spec.rb b/api/spec/requests/email_confirmations_spec.rb index 8417f05e8c..f34b7c2147 100644 --- a/api/spec/requests/email_confirmations_spec.rb +++ b/api/spec/requests/email_confirmations_spec.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.describe "Email Confirmations" do - include_context("authenticated request") - include_context("param helpers") - let!(:user) { FactoryBot.create :user, password: password, password_confirmation: password } context "GET /api/v1/email_confirmations/:user_id" do diff --git a/api/spec/requests/entitlement_imports_spec.rb b/api/spec/requests/entitlement_imports_spec.rb index 69dd2ee925..9ee02c4fce 100644 --- a/api/spec/requests/entitlement_imports_spec.rb +++ b/api/spec/requests/entitlement_imports_spec.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.describe "Entitlement Imports API", type: :request do - include_context("authenticated request") - include_context("param helpers") - context "when fetching imports" do let(:path) { api_v1_entitlement_imports_path } diff --git a/api/spec/requests/entitlements_spec.rb b/api/spec/requests/entitlements_spec.rb index 07efc74d5f..837c7b4a7a 100644 --- a/api/spec/requests/entitlements_spec.rb +++ b/api/spec/requests/entitlements_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Entitlements API", type: :request do - include_context "authenticated request" - include_context "param helpers" - let(:request_method) { raise "must set request method" } let!(:target_user) { FactoryBot.create :user } diff --git a/api/spec/requests/errors_spec.rb b/api/spec/requests/errors_spec.rb index 94cd5bedb6..130da0dc41 100644 --- a/api/spec/requests/errors_spec.rb +++ b/api/spec/requests/errors_spec.rb @@ -1,29 +1,24 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "API Error Handling", type: :request do + context "when requesting a non-existing route" do + let(:api_response) { JSON.parse(response.body) } - include_context("authenticated request") - include_context("param helpers") + it "conforms to expectations" do + expect do + get "/api/v1/rambo/bananas" + end.to execute_safely - context "404 requests" do + api_response = JSON.parse(response.body) - before(:each) { get "/api/v1/rambo/bananas" } - let(:api_response) { JSON.parse(response.body) } + aggregate_failures do + expect(api_response).to include_json( + errors: [ + { id: "API_ERROR", status: 404 }, + ], + ) - describe "contains a structured error response" do - describe "errors" do - it "is an array" do - expect(api_response["errors"]).to be_instance_of Array - end - describe "it's first error" do - let(:error) { api_response["errors"].first } - it "has an ID of API_ERROR" do - expect(error["id"]).to eq "API_ERROR" - end - it "has a 404 status" do - expect(error["status"]).to eq 404 - end - end + expect(response).to have_http_status(:not_found) end end end diff --git a/api/spec/requests/events_spec.rb b/api/spec/requests/events_spec.rb index f43c831ea9..23c2ae1519 100644 --- a/api/spec/requests/events_spec.rb +++ b/api/spec/requests/events_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Events API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:event) { FactoryBot.create(:event) } describe "destroys an event" do @@ -32,4 +28,3 @@ end end end - diff --git a/api/spec/requests/export_targets_spec.rb b/api/spec/requests/export_targets_spec.rb index 7c532559ea..b9c0617738 100644 --- a/api/spec/requests/export_targets_spec.rb +++ b/api/spec/requests/export_targets_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "ExportTargets API", type: :request do - include_context "authenticated request" - include_context "param helpers" - let(:request_method) { raise "must set request method" } def expect_making_the_request(method: request_method, path: request_path, headers: admin_headers, **options) diff --git a/api/spec/requests/flags_spec.rb b/api/spec/requests/flags_spec.rb index 30ceeffb6a..a6f019bef9 100644 --- a/api/spec/requests/flags_spec.rb +++ b/api/spec/requests/flags_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Flags API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:comment) { FactoryBot.create(:comment, creator: reader) } describe "flags a comment" do @@ -32,9 +28,7 @@ reloaded_comment = Comment.find(comment.id) expect(reloaded_comment.flags_count).to eq 1 end - end - end describe "unflags a comment" do @@ -64,9 +58,6 @@ reloaded_comment = Comment.find(comment.id) expect(reloaded_comment.flags_count).to eq 0 end - end - end - end diff --git a/api/spec/requests/ingestion_spec.rb b/api/spec/requests/ingestion_spec.rb index cda88644fc..2a61a82273 100644 --- a/api/spec/requests/ingestion_spec.rb +++ b/api/spec/requests/ingestion_spec.rb @@ -1,22 +1,18 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Ingestions API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - - let(:attributes) { + let(:attributes) do { source: markdown_source_params, ingestionType: "epub" } - } - let(:valid_params) { + end + + let(:valid_params) do build_json_payload(attributes: attributes) - } + end describe "creates an ingestion" do - let(:project) { FactoryBot.create(:project) } let(:path) { api_v1_project_relationships_ingestions_path(project) } let(:api_response) { JSON.parse(response.body) } diff --git a/api/spec/requests/journals/relationships/journal_issues_controller_spec.rb b/api/spec/requests/journals/relationships/journal_issues_controller_spec.rb index 98916b3643..9641970d4f 100644 --- a/api/spec/requests/journals/relationships/journal_issues_controller_spec.rb +++ b/api/spec/requests/journals/relationships/journal_issues_controller_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Journal JournalIssues API", type: :request do - include_context("authenticated request") - include_context("param helpers") - - let(:journal) { FactoryBot.create(:journal) } + let_it_be(:journal, refind: true) { FactoryBot.create(:journal) } describe "creates a journal issue" do let(:path) { api_v1_journal_relationships_journal_issues_path(journal) } @@ -14,7 +11,7 @@ context "when a project is provided" do let(:project) { FactoryBot.create(:project) } - let(:params) { + let(:params) do { attributes: { number: 1 }, relationships: { @@ -26,7 +23,7 @@ } } } - } + end describe "the response" do it "has a 201 CREATED status code" do @@ -37,11 +34,11 @@ end context "when no project is provided" do - let(:params) { + let(:params) do { attributes: { number: "1", pendingSlug: "test" } } - } + end describe "the response" do it "has a 201 CREATED status code" do @@ -50,7 +47,6 @@ end end end - end end end diff --git a/api/spec/requests/makers_spec.rb b/api/spec/requests/makers_spec.rb index a3dd37166b..fa52b0dae8 100644 --- a/api/spec/requests/makers_spec.rb +++ b/api/spec/requests/makers_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Makers API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:maker) { FactoryBot.create(:maker) } describe "sends a list of makers" do diff --git a/api/spec/requests/me/relationships/annotations_spec.rb b/api/spec/requests/me/relationships/annotations_spec.rb index 3d313f169a..7e06d74712 100644 --- a/api/spec/requests/me/relationships/annotations_spec.rb +++ b/api/spec/requests/me/relationships/annotations_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "My Annotations API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:another_user) { FactoryBot.create(:user) } let(:text) { FactoryBot.create(:text) } let(:text_section) { FactoryBot.create(:text_section, text: text) } diff --git a/api/spec/requests/me/relationships/favorite_projects_spec.rb b/api/spec/requests/me/relationships/favorite_projects_spec.rb index 8d5813d15d..eedd1836b1 100644 --- a/api/spec/requests/me/relationships/favorite_projects_spec.rb +++ b/api/spec/requests/me/relationships/favorite_projects_spec.rb @@ -1,15 +1,10 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "My Favorite Projects API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:path) { api_v1_me_relationships_favorite_projects_path } # Index action describe "sends the user's favorite projects" do - context "when the user is not authenticated" do it "has a 401 status code" do get path @@ -18,7 +13,6 @@ end context "when the user is authenticated" do - let(:favorite_project) { FactoryBot.create(:project) } let(:favorite) { reader.favorite(favorite_project) } @@ -40,5 +34,4 @@ end end end - end diff --git a/api/spec/requests/me/relationships/favorites_spec.rb b/api/spec/requests/me/relationships/favorites_spec.rb index 64144ed429..58459c6dc0 100644 --- a/api/spec/requests/me/relationships/favorites_spec.rb +++ b/api/spec/requests/me/relationships/favorites_spec.rb @@ -1,16 +1,12 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "My Favorites API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:another_user) { FactoryBot.create(:user) } let(:unfavorited_project) { FactoryBot.create(:project) } let(:favorite_project) { FactoryBot.create(:project) } let(:reader_favorite) { reader.favorite(favorite_project) } let(:not_my_favorite) { another_user.favorite(favorite_project) } - let(:params) { + let(:params) do relationships = { favoritable: { data: { @@ -20,10 +16,9 @@ } } build_json_payload(relationships: relationships) - } + end describe "sends my favorites" do - let(:path) { api_v1_me_relationships_favorites_path } context "when the user is not authenticated" do diff --git a/api/spec/requests/me/relationships/reading_groups_spec.rb b/api/spec/requests/me/relationships/reading_groups_spec.rb index f93f79d9b2..6fa478ab12 100644 --- a/api/spec/requests/me/relationships/reading_groups_spec.rb +++ b/api/spec/requests/me/relationships/reading_groups_spec.rb @@ -1,32 +1,27 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "My Reading Groups API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:another_user) { FactoryBot.create(:user) } describe "sends my reading groups" do - let(:path) { api_v1_me_relationships_reading_groups_path } context "when the user is not authenticated" do before(:each) { get path } + it "has a 200 status code" do expect(response).to have_http_status(200) end end context "when the user is a reader" do - - before(:each) { + before do get path, headers: reader_headers - } + end + let(:api_response) { JSON.parse(response.body) } describe "the response" do - it "includes an array of data" do expect(api_response["data"]).to be_instance_of Array end @@ -34,9 +29,7 @@ it "has a 200 status code" do expect(response).to have_http_status(200) end - end end end - end diff --git a/api/spec/requests/me_spec.rb b/api/spec/requests/me_spec.rb index 5ecafe765e..7544fb243f 100644 --- a/api/spec/requests/me_spec.rb +++ b/api/spec/requests/me_spec.rb @@ -1,8 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Me API", type: :request do - include_context("authenticated request") - include_context("param helpers") let(:path) { api_v1_me_path } describe "updates the current user" do diff --git a/api/spec/requests/notification_preferences/relationships/unsubscribe_controller_spec.rb b/api/spec/requests/notification_preferences/relationships/unsubscribe_controller_spec.rb index 707a6029fd..a92c2dba79 100644 --- a/api/spec/requests/notification_preferences/relationships/unsubscribe_controller_spec.rb +++ b/api/spec/requests/notification_preferences/relationships/unsubscribe_controller_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "NotificationPreferences Unsubscribe API", type: :request do - - include_context("authenticated request") - include_context("param helpers") let(:user) { FactoryBot.create(:user) } let(:token) { UnsubscribeToken.generate user } @@ -16,5 +13,4 @@ end end end - end diff --git a/api/spec/requests/oauth_spec.rb b/api/spec/requests/oauth_spec.rb index 1614f2d717..12f92f7371 100644 --- a/api/spec/requests/oauth_spec.rb +++ b/api/spec/requests/oauth_spec.rb @@ -1,10 +1,10 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Oauth", type: :request do describe "responds with a list of projects" do before(:each) { get "/auth/google_oauth2/callback" } - describe "the response" do + describe "the response" do it "has a non-blank body" do expect(response.body.blank?).to be false end @@ -13,9 +13,6 @@ get api_v1_projects_path expect(response).to have_http_status(200) end - - end end - end diff --git a/api/spec/requests/pages_spec.rb b/api/spec/requests/pages_spec.rb index 0dc0e1197c..052e8bd95f 100644 --- a/api/spec/requests/pages_spec.rb +++ b/api/spec/requests/pages_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Pages API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - describe "sends a page" do describe "the response" do it "has a 200 status code" do @@ -13,5 +9,4 @@ end end end - end diff --git a/api/spec/requests/passwords_spec.rb b/api/spec/requests/passwords_spec.rb index 5e53e0fda3..5cf3369397 100644 --- a/api/spec/requests/passwords_spec.rb +++ b/api/spec/requests/passwords_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Passwords API", type: :request do - - include_context("param helpers") - - let(:user) { FactoryBot.create(:user) } + let_it_be(:user, refind: true) { FactoryBot.create(:user) } describe "reset password request" do describe "the response" do @@ -28,13 +25,14 @@ end describe "update password request" do - let(:update_params) { + let(:update_params) do { password: "testtest1234", password_confirmation: "testtest1234", reset_token: user.reset_password_token } - } + end + describe "the response" do before(:each) { user.generate_reset_token @@ -51,5 +49,4 @@ end end end - end diff --git a/api/spec/requests/pending_entitlements_spec.rb b/api/spec/requests/pending_entitlements_spec.rb index d9ffdbbaa2..941bd6ae70 100644 --- a/api/spec/requests/pending_entitlements_spec.rb +++ b/api/spec/requests/pending_entitlements_spec.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.describe "Pending Entitlements API", type: :request do - include_context("authenticated request") - include_context("param helpers") - context "when fetching entitlements" do let(:filter) do {} diff --git a/api/spec/requests/project_collections/relationships/collection_projects_controller_spec.rb b/api/spec/requests/project_collections/relationships/collection_projects_controller_spec.rb index da57076c5a..39f0783837 100644 --- a/api/spec/requests/project_collections/relationships/collection_projects_controller_spec.rb +++ b/api/spec/requests/project_collections/relationships/collection_projects_controller_spec.rb @@ -1,11 +1,8 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "ProjectCollection CollectionProject API", type: :request do - include_context("authenticated request") - include_context("param helpers") - - let(:project_collection) { FactoryBot.create(:project_collection) } - let(:project) { FactoryBot.create(:project) } + let_it_be(:project_collection, refind: true) { FactoryBot.create(:project_collection) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } describe "creates a new CollectionProject for the ProjectCollection" do let(:path) { api_v1_project_collection_relationships_collection_projects_path(project_collection) } diff --git a/api/spec/requests/project_collections_spec.rb b/api/spec/requests/project_collections_spec.rb index c086d29d12..c2165c3b1d 100644 --- a/api/spec/requests/project_collections_spec.rb +++ b/api/spec/requests/project_collections_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Collections API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:project_collection) do pc = FactoryBot.create(:project_collection) pc.projects << FactoryBot.create(:project) @@ -34,7 +31,6 @@ included = JSON.parse(response.body).dig("included").select { |record| record["type"] == "projects" } expect(included.length).to eq 1 end - end end diff --git a/api/spec/requests/project_exportations_spec.rb b/api/spec/requests/project_exportations_spec.rb index 52c80e92a1..e2146ef039 100644 --- a/api/spec/requests/project_exportations_spec.rb +++ b/api/spec/requests/project_exportations_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "ProjectExportations API", type: :request do - include_context "authenticated request" - include_context "param helpers" - let(:request_method) { raise "must set request method" } def expect_making_the_request(method: request_method, path: request_path, headers: admin_headers, **options) diff --git a/api/spec/requests/projects/ingestions_spec.rb b/api/spec/requests/projects/ingestions_spec.rb index f3463caf57..d90fe820df 100644 --- a/api/spec/requests/projects/ingestions_spec.rb +++ b/api/spec/requests/projects/ingestions_spec.rb @@ -1,21 +1,19 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Ingestions API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let!(:project) { FactoryBot.create(:project, draft: false) } let(:project_id) { project.id } - let!(:attributes) { + let!(:attributes) do { source: markdown_source_params, ingestionType: "epub" } - } - let!(:valid_params) { + end + + let!(:valid_params) do build_json_payload(attributes: attributes) - } + end let!(:path) { api_v1_project_ingestions_path(project) } diff --git a/api/spec/requests/projects/relationships/action_callouts_spec.rb b/api/spec/requests/projects/relationships/action_callouts_spec.rb index 4325cb97da..0c1e1411f9 100644 --- a/api/spec/requests/projects/relationships/action_callouts_spec.rb +++ b/api/spec/requests/projects/relationships/action_callouts_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project ActionCallout API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:project) { FactoryBot.create(:project) } describe "sends a list of project call to actions" do @@ -50,5 +47,4 @@ end end end - end diff --git a/api/spec/requests/projects/relationships/content_blocks_spec.rb b/api/spec/requests/projects/relationships/content_blocks_spec.rb index ec63c5d492..9da14e3181 100644 --- a/api/spec/requests/projects/relationships/content_blocks_spec.rb +++ b/api/spec/requests/projects/relationships/content_blocks_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project ContentBlocks API", type: :request do - include_context("authenticated request") - include_context("param helpers") - - let(:project) { FactoryBot.create(:project) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } describe "sends a list of project content blocks" do let(:path) { api_v1_project_relationships_content_blocks_path(project) } @@ -50,5 +47,4 @@ end end end - end diff --git a/api/spec/requests/projects/relationships/events_spec.rb b/api/spec/requests/projects/relationships/events_spec.rb index 7497b779a5..900419a8ce 100644 --- a/api/spec/requests/projects/relationships/events_spec.rb +++ b/api/spec/requests/projects/relationships/events_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Events API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:project) { FactoryBot.create(:project) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } before(:each) do FactoryBot.create(:event, project: project, event_type: EventType[:tweet]) @@ -30,5 +27,4 @@ end end end - end diff --git a/api/spec/requests/projects/relationships/permissions_controller_spec.rb b/api/spec/requests/projects/relationships/permissions_controller_spec.rb index b1bf6d23ed..71a60e7bb7 100644 --- a/api/spec/requests/projects/relationships/permissions_controller_spec.rb +++ b/api/spec/requests/projects/relationships/permissions_controller_spec.rb @@ -1,10 +1,9 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Permissions API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:project) { FactoryBot.create(:project) } - let(:user) { FactoryBot.create(:user, :editor) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } + let_it_be(:user, refind: true) { FactoryBot.create(:user, :editor) } + let(:params) { build_json_payload(attributes: { role_names: %w[project_editor] }, relationships: { user: { data: { id: user.id, type: "users" } } }) } describe "sends a list of project permissions" do diff --git a/api/spec/requests/projects/relationships/resource_imports_spec.rb b/api/spec/requests/projects/relationships/resource_imports_spec.rb index ad755dd3e9..e319e9e2bb 100644 --- a/api/spec/requests/projects/relationships/resource_imports_spec.rb +++ b/api/spec/requests/projects/relationships/resource_imports_spec.rb @@ -1,24 +1,23 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Resource Import API", type: :request do + let_it_be(:csv_path) do + Rails.root.join("spec", "data", "resource_import", "resources.csv") + end - include_context("authenticated request") - include_context("param helpers") - csv_path = Rails.root.join('spec', 'data','resource_import','resources.csv') - - let(:attributes) { + let(:attributes) do { source: "attached_data", data: file_param(csv_path, "text/csv", "resources.csv") } - } + end - let(:valid_params) { + let(:valid_params) do build_json_payload(attributes: attributes) - } + end - let(:project) { FactoryBot.create(:project) } - let(:resource_import) { FactoryBot.create(:resource_import, project: project) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } + let_it_be(:resource_import, refind: true) { FactoryBot.create(:resource_import, project: project) } describe "creates a resource_import model" do @@ -33,7 +32,6 @@ resource_import = ResourceImport.find api_response["data"]["id"] expect(resource_import.creator.id).to eq(admin.id) end - end describe "updates a resource_import model" do @@ -55,7 +53,6 @@ @resource_import.reload expect(@resource_import.state_machine.in_state?(:parsed)).to be true end - end describe "sends a single resource import model" do @@ -84,10 +81,5 @@ end end end - end - - end - - diff --git a/api/spec/requests/projects/relationships/resources_spec.rb b/api/spec/requests/projects/relationships/resources_spec.rb index 0abdb5342c..317e85a080 100644 --- a/api/spec/requests/projects/relationships/resources_spec.rb +++ b/api/spec/requests/projects/relationships/resources_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Resources API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:project) { FactoryBot.create(:project) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } describe "sends a list of project resources" do let(:path) { api_v1_project_relationships_resources_path(project) } @@ -48,5 +45,4 @@ end end end - end diff --git a/api/spec/requests/projects/relationships/text_categories_spec.rb b/api/spec/requests/projects/relationships/text_categories_spec.rb index df8f652430..850f275aec 100644 --- a/api/spec/requests/projects/relationships/text_categories_spec.rb +++ b/api/spec/requests/projects/relationships/text_categories_spec.rb @@ -1,12 +1,9 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Text Categories API", type: :request do + let_it_be(:project, refind: true) { FactoryBot.create(:project) } + let_it_be(:text_category, refind: true) { FactoryBot.create(:category, project: project) } - include_context("authenticated request") - include_context("param helpers") - - let(:project) { FactoryBot.create(:project) } - let(:text_category) { FactoryBot.create(:text_category) } let(:path) { api_v1_project_relationships_text_categories_path(project) } describe "sends project text categories" do @@ -19,7 +16,6 @@ end describe "creates a new project text category" do - let(:post_model) { { attributes: { title: "A new hope" } } } context "when the user is an admin" do @@ -42,10 +38,6 @@ expect(response).to have_http_status(403) end end - end - - end - end diff --git a/api/spec/requests/projects/relationships/twitter_queries_spec.rb b/api/spec/requests/projects/relationships/twitter_queries_spec.rb index 1004fdddbc..7ba7c992b9 100644 --- a/api/spec/requests/projects/relationships/twitter_queries_spec.rb +++ b/api/spec/requests/projects/relationships/twitter_queries_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Twitter Queries API", type: :request do - include_context("authenticated request") - include_context("param helpers") - - let(:project) { FactoryBot.create(:project) } + let_it_be(:project, refind: true) { FactoryBot.create(:project) } describe "sends a list of a project's twitter queries" do let(:path) { api_v1_project_relationships_twitter_queries_path(project) } diff --git a/api/spec/requests/projects/relationships/uncollected_resources_spec.rb b/api/spec/requests/projects/relationships/uncollected_resources_spec.rb index 1ac92a5455..d088019063 100644 --- a/api/spec/requests/projects/relationships/uncollected_resources_spec.rb +++ b/api/spec/requests/projects/relationships/uncollected_resources_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Project Uncollected Resorces API", type: :request do - - include_context("authenticated request") - include_context("param helpers") let(:project) { FactoryBot.create(:project) } describe "sends a list of uncollected project resources" do @@ -15,5 +12,4 @@ end end end - end diff --git a/api/spec/requests/projects_spec.rb b/api/spec/requests/projects_spec.rb index 58c8951dbd..83edb3fc28 100644 --- a/api/spec/requests/projects_spec.rb +++ b/api/spec/requests/projects_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Projects API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:project) { FactoryBot.create(:project, draft: false) } describe "responds with a list of projects" do diff --git a/api/spec/requests/reading_group_memberships_spec.rb b/api/spec/requests/reading_group_memberships_spec.rb index 3f16d2633c..cd130aed86 100644 --- a/api/spec/requests/reading_group_memberships_spec.rb +++ b/api/spec/requests/reading_group_memberships_spec.rb @@ -1,27 +1,25 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Reading Group Memberships API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:reading_group) { FactoryBot.create(:reading_group) } describe "creates a reading_group" do let (:path) { api_v1_reading_group_memberships_path } - let(:attributes) { - { - } - } - let(:relationships) { + + let(:attributes) do + {} + end + + let(:relationships) do { user: { data: { id: reader.id } }, reading_group: { data: { id: reading_group.id } }, } - } + end - let(:valid_params) { + let(:valid_params) do build_json_payload(attributes: attributes, relationships: relationships) - } + end it "has a 201 CREATED status code when the membership is for the authenticated user" do post path, headers: reader_headers, params: valid_params @@ -37,7 +35,6 @@ post path, headers: another_reader_headers, params: valid_params expect(response).to have_http_status(403) end - end describe "deletes a reading_group membership" do @@ -45,7 +42,6 @@ let(:path) { api_v1_reading_group_membership_path(reading_group_membership) } context "when the user is an admin" do - let(:headers) { admin_headers } it "has a 204 NO CONTENT status code" do @@ -55,7 +51,6 @@ end context "when the user belongs to the membership" do - let(:headers) { reader_headers } it "has a 204 NO CONTENT status code" do @@ -65,7 +60,6 @@ end context "when the user does not belong to the membership" do - let(:headers) { another_reader_headers } it "has a 403 FORBIDDEN status code" do diff --git a/api/spec/requests/reading_groups/relationships/reading_group_memberships_spec.rb b/api/spec/requests/reading_groups/relationships/reading_group_memberships_spec.rb index b158e86eea..423e230cbf 100644 --- a/api/spec/requests/reading_groups/relationships/reading_group_memberships_spec.rb +++ b/api/spec/requests/reading_groups/relationships/reading_group_memberships_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Reading Group Memberships API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:members_per_group) { 3 } before(:each) do @@ -18,7 +14,6 @@ describe "sends a list of reading group memberships" do describe "the response" do - let(:headers) { reader_headers } before(:each) do @@ -38,10 +33,6 @@ data = JSON.parse(response.body)["data"] expect(data.length).to eq members_per_group end - - end + end end - - - end diff --git a/api/spec/requests/reading_groups_spec.rb b/api/spec/requests/reading_groups_spec.rb index f9127c6fe9..9024ac5439 100644 --- a/api/spec/requests/reading_groups_spec.rb +++ b/api/spec/requests/reading_groups_spec.rb @@ -1,16 +1,10 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Reading Groups API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - - let(:reading_group) { FactoryBot.create(:reading_group, creator: reader) } + let_it_be(:reading_group, refind: true) { FactoryBot.create(:reading_group, creator: reader) } describe "responds with a list of reading groups" do - describe "the response" do - before(:each) { get api_v1_reading_groups_path, headers: headers } context "when the user is a reading group owner" do @@ -31,63 +25,58 @@ describe "sends a reading group" do describe "the response" do - context "when the id is provided" do before(:each) { get api_v1_reading_group_path(reading_group), headers: headers } - context "when the user is the reading group owner" do + context "when the user is the reading group owner" do let(:headers) { reader_headers } + it "has a 200 status code" do expect(response).to have_http_status(200) end end context "when the user is not in the reading group" do - let(:headers) { another_reader_headers } + it "has a 403 status code" do expect(response).to have_http_status(403) end end context "when the user is an admin" do - let(:headers) { admin_headers } + it "has a 200 status code" do expect(response).to have_http_status(200) end end - end context "when the invitation code is provided" do - before(:each) { get api_v1_reading_group_path(reading_group.invitation_code), headers: headers } context "when the user is not in the reading group" do - let(:headers) { another_reader_headers } + it "has a 200 status code" do expect(response).to have_http_status(200) end end end - end end describe "updates a reading group" do - let(:path) { api_v1_reading_group_path(reading_group) } context "when the user is the reading group owner" do - let(:headers) { reader_headers } - let(:metadata) { + let(:metadata) do { name: "This is a new name" } - } + end describe "the response" do context "body" do diff --git a/api/spec/requests/resource_collections_spec.rb b/api/spec/requests/resource_collections_spec.rb index eef822c8be..ad3e1f0436 100644 --- a/api/spec/requests/resource_collections_spec.rb +++ b/api/spec/requests/resource_collections_spec.rb @@ -1,18 +1,12 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Resource Collections API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:collection) { FactoryBot.create(:resource_collection) } describe "updates a collection" do - let(:path) { api_v1_resource_collection_path(collection) } context "when the user is an admin" do - let(:headers) { admin_headers } describe "the response" do @@ -29,7 +23,6 @@ end describe "destroys a collection" do - let(:path) { api_v1_resource_collection_path(collection) } context "when the user is an admin" do @@ -43,7 +36,6 @@ end context "when the user is a reader" do - let(:headers) { reader_headers } it "has a 403 FORBIDDEN status code" do @@ -53,5 +45,4 @@ end end end - end diff --git a/api/spec/requests/resources_spec.rb b/api/spec/requests/resources_spec.rb index af74b6b522..8252d66763 100644 --- a/api/spec/requests/resources_spec.rb +++ b/api/spec/requests/resources_spec.rb @@ -1,10 +1,7 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Resources API", type: :request do - include_context("authenticated request") - include_context("param helpers") - - let(:resource) { FactoryBot.create(:resource) } + let_it_be(:resource, refind: true) { FactoryBot.create(:resource) } describe "updates a resource" do let(:path) { api_v1_resource_path(resource) } diff --git a/api/spec/requests/search_results_spec.rb b/api/spec/requests/search_results_spec.rb index 3bba6a9b23..871bd5e772 100644 --- a/api/spec/requests/search_results_spec.rb +++ b/api/spec/requests/search_results_spec.rb @@ -1,8 +1,8 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Search Results API", elasticsearch: true, type: :request do - let!(:bovary) { FactoryBot.create :project, title: "Madame Bovary", description: "The force will be with you, always" } - let!(:babble) { FactoryBot.create :project, title: "Madame Babble", description: "Peace be with you" } + let_it_be(:bovary, refind: true) { FactoryBot.create :project, title: "Madame Bovary", description: "The force will be with you, always" } + let_it_be(:babble, refind: true) { FactoryBot.create :project, title: "Madame Babble", description: "Peace be with you" } before do bovary && babble diff --git a/api/spec/requests/stylesheets_spec.rb b/api/spec/requests/stylesheets_spec.rb index 8dce31b4a1..8f97a9855a 100644 --- a/api/spec/requests/stylesheets_spec.rb +++ b/api/spec/requests/stylesheets_spec.rb @@ -1,25 +1,21 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Stylesheets API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - - let(:attributes) { + let(:attributes) do { name: "Rambo Stylez", rawStyles: ".some-class {\n font-weight: bold;\n}", position: 1 } - } - let(:valid_params) { + end + + let(:valid_params) do build_json_payload(attributes: attributes) - } + end - let(:text) { FactoryBot.create(:text) } + let_it_be(:text, refind: true) { FactoryBot.create(:text) } describe "creates an stylesheet" do - let(:path) { api_v1_text_relationships_stylesheets_path(text) } let(:api_response) { JSON.parse(response.body) } @@ -63,7 +59,6 @@ end describe "destroys a stylesheet" do - let(:stylesheet) { FactoryBot.create(:stylesheet, text: text, creator: admin, position: 1) } let(:path) { api_v1_stylesheet_path(stylesheet) } @@ -89,7 +84,6 @@ end describe "sends a single stylesheet" do - let(:stylesheet) { FactoryBot.create(:stylesheet, text: text, creator: admin, position: 1) } let(:path) { api_v1_stylesheet_path(stylesheet) } @@ -111,5 +105,4 @@ end end end - end diff --git a/api/spec/requests/subjects_spec.rb b/api/spec/requests/subjects_spec.rb index fdafa7370b..b8cfe4493b 100644 --- a/api/spec/requests/subjects_spec.rb +++ b/api/spec/requests/subjects_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Subject API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:subject_a) { FactoryBot.create(:subject) } let(:subject_b) { FactoryBot.create(:subject, name: "Rowan") } diff --git a/api/spec/requests/tags_spec.rb b/api/spec/requests/tags_spec.rb index ec258c2cef..ac42f63e6b 100644 --- a/api/spec/requests/tags_spec.rb +++ b/api/spec/requests/tags_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Tag API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - before(:each) { 5.times { FactoryBot.create(:tag, name: Faker::Creature::Dog.unique.breed) } } describe "responds with a list of tags" do diff --git a/api/spec/requests/text_sections/relationships/annotations_spec.rb b/api/spec/requests/text_sections/relationships/annotations_spec.rb index 98280a3b70..bd61b5cb6e 100644 --- a/api/spec/requests/text_sections/relationships/annotations_spec.rb +++ b/api/spec/requests/text_sections/relationships/annotations_spec.rb @@ -1,8 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Text Section Annotations API", type: :request do - include_context("authenticated request") - include_context("param helpers") let(:project) { FactoryBot.create(:project) } let(:text) { FactoryBot.create(:text, project: project) } @@ -23,6 +21,7 @@ } } end + let(:collection_params) do { attributes: FactoryBot.build(:collection_annotation).attributes, @@ -36,6 +35,7 @@ } } end + let(:path) { api_v1_text_relationships_text_section_annotations_path(text_id: text.id, text_section_id: text_section) } describe "access to public annotations" do diff --git a/api/spec/requests/text_sections_spec.rb b/api/spec/requests/text_sections_spec.rb index 5a3246ac4e..d1f93e9f80 100644 --- a/api/spec/requests/text_sections_spec.rb +++ b/api/spec/requests/text_sections_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Text Section API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let!(:text) { FactoryBot.create(:text) } let!(:text_section) { FactoryBot.create(:text_section) } diff --git a/api/spec/requests/texts/ingestions_spec.rb b/api/spec/requests/texts/ingestions_spec.rb index 4e4a9a35ae..c4af8abbd5 100644 --- a/api/spec/requests/texts/ingestions_spec.rb +++ b/api/spec/requests/texts/ingestions_spec.rb @@ -1,21 +1,19 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Text Ingestions API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let!(:text) { FactoryBot.create(:text) } let(:text_id) { text.id } - let!(:attributes) { + let!(:attributes) do { source: markdown_source_params, ingestionType: "epub" } - } - let!(:valid_params) { + end + + let!(:valid_params) do build_json_payload(attributes: attributes) - } + end let!(:path) { api_v1_text_ingestions_path(text) } diff --git a/api/spec/requests/texts/relationships/text_sections_controller_spec.rb b/api/spec/requests/texts/relationships/text_sections_controller_spec.rb index be7fd94efc..07b2d23fa4 100644 --- a/api/spec/requests/texts/relationships/text_sections_controller_spec.rb +++ b/api/spec/requests/texts/relationships/text_sections_controller_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Text Text Sections API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - let(:text) { FactoryBot.create(:text) } let(:path) { api_v1_text_relationships_text_sections_path(text) } @@ -16,5 +12,4 @@ end end end - end diff --git a/api/spec/requests/texts_spec.rb b/api/spec/requests/texts_spec.rb index ff759574b1..51068900a3 100644 --- a/api/spec/requests/texts_spec.rb +++ b/api/spec/requests/texts_spec.rb @@ -1,10 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Texts API", type: :request do - - include_context("authenticated request") - include_context("param helpers") - describe "sends a list of texts" do let(:path) { api_v1_texts_path } before(:each) { get path } diff --git a/api/spec/requests/tokens_spec.rb b/api/spec/requests/tokens_spec.rb index df1b8ab125..37a0c5d0ac 100644 --- a/api/spec/requests/tokens_spec.rb +++ b/api/spec/requests/tokens_spec.rb @@ -1,9 +1,6 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Tokens API", type: :request do - include_context("authenticated request") - include_context("param helpers") - describe "creates a token" do let(:path) { api_v1_tokens_path } let(:params) { { email: reader.email, password: password } } diff --git a/api/spec/requests/users_spec.rb b/api/spec/requests/users_spec.rb index 9e70f4e961..6a80721714 100644 --- a/api/spec/requests/users_spec.rb +++ b/api/spec/requests/users_spec.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.describe "Users API", type: :request do - include_context("authenticated request") - include_context("param helpers") - let(:first_name) { "John" } let(:attributes) do diff --git a/api/spec/support/requests/authenticated_request.rb b/api/spec/support/requests/authenticated_request.rb index f208e8ad59..2c681c3bbc 100644 --- a/api/spec/support/requests/authenticated_request.rb +++ b/api/spec/support/requests/authenticated_request.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "rails_helper" - RSpec.shared_context "authenticated request" do def token(user, _password) AuthToken.encode_user(user) @@ -31,6 +29,7 @@ def get_user_token(user_type) User.find_by(email: public_send("#{role}_email")) || FactoryBot.create( :user, role.to_sym, + :with_confirmed_email, email: public_send("#{role}_email"), password: password, password_confirmation: password, @@ -42,15 +41,19 @@ def get_user_token(user_type) let(:"#{role}_auth") { build_bearer_token public_send(:"#{role}_token") } end - let(:another_reader_email) { "another-reader@castironcoding.com" } - let(:another_reader) { FactoryBot.create(:user, :reader, email: another_reader_email, password: password, password_confirmation: password) } + let_it_be(:another_reader_email) { "another-reader@castironcoding.com" } + let_it_be(:another_reader, refind: true) do + User.find_by(email: another_reader_email) || FactoryBot.create( + :user, :reader, email: another_reader_email, password: password, password_confirmation: password + ) + end let(:another_reader_token) { token(another_reader, password) } let(:another_reader_headers) { build_headers(another_reader_token) } - let(:author_email) { "project_author@castironcoding.com" } - let(:authored_project) { FactoryBot.create :project } - let(:author) do - FactoryBot.create(:user, email: author_email, password: password, password_confirmation: password).tap do |author| + let_it_be(:author_email) { "project_author@castironcoding.com" } + let_it_be(:authored_project, refind: true) { FactoryBot.create :project } + let_it_be(:author, refind: true) do + User.find_by(email: author_email) || FactoryBot.create(:user, :with_confirmed_email, email: author_email, password: password, password_confirmation: password).tap do |author| author.add_role :project_author, authored_project end end @@ -58,3 +61,7 @@ def get_user_token(user_type) let(:author_headers) { build_headers author_token } let(:author_auth) { build_bearer_token author_headers } end + +RSpec.configure do |config| + config.include_context "authenticated request", type: :request +end diff --git a/api/spec/support/requests/param_helpers.rb b/api/spec/support/requests/param_helpers.rb index 55450a9785..24a788b8c4 100644 --- a/api/spec/support/requests/param_helpers.rb +++ b/api/spec/support/requests/param_helpers.rb @@ -1,8 +1,6 @@ -require "rails_helper" -require "base64" +# frozen_string_literal: true RSpec.shared_context "param helpers" do - def json_structure_from_factory(factory_name, **options) build_json_structure(options.merge({ attributes: FactoryBot.attributes_for(factory_name) })) end @@ -94,3 +92,7 @@ def put_temporary_tus_file(filename, mime_type:, data:, id: SecureRandom.hex) ) end end + +RSpec.configure do |config| + config.include_context "param helpers", type: :request +end diff --git a/api/spec/support/requests/simple_auth.rb b/api/spec/support/requests/simple_auth.rb index 6c5673e63a..398ec53b72 100644 --- a/api/spec/support/requests/simple_auth.rb +++ b/api/spec/support/requests/simple_auth.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.shared_context "simple auth request" do let(:current_user) { FactoryBot.create :user } diff --git a/api/spec/support/shared_examples/orderable_record.rb b/api/spec/support/shared_examples/orderable_record.rb index 0f5456b94c..2676e68c71 100644 --- a/api/spec/support/shared_examples/orderable_record.rb +++ b/api/spec/support/shared_examples/orderable_record.rb @@ -1,9 +1,6 @@ -require "rails_helper" - -shared_examples_for "orderable api requests" do - include_context("authenticated request") - include_context("param helpers") +# frozen_string_literal: true +RSpec.shared_examples_for "orderable api requests" do let(:path) { raise "Must be overridden" } let!(:object_a) { raise "Must be overridden" } let!(:object_b) { raise "Must be overridden" } @@ -21,6 +18,5 @@ put __send__(path, object_a.id), headers: admin_headers, params: valid_params expect(api_response["data"]["attributes"]["position"]).to eq 2 end - end end From 19cea8ba33a950686bd3358942ed4bc1dd21b10e Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Thu, 22 Feb 2024 05:21:34 -0800 Subject: [PATCH 05/12] [F] Implement new measures for dealing with spam * Add akismet integration for detecting spam * Add new setting for disabling spam detection globally * Add ability to disable public reading groups * Require users to be trusted / established when commenting, creating a public annotation, or creating a public reading group --- api/app/authorizers/annotation_authorizer.rb | 24 ++- api/app/authorizers/application_authorizer.rb | 55 ++++++ api/app/authorizers/comment_authorizer.rb | 17 +- .../authorizers/reading_group_authorizer.rb | 10 + .../controllers/concerns/authentication.rb | 9 +- api/app/models/annotation.rb | 12 +- api/app/models/comment.rb | 8 +- api/app/models/reading_group.rb | 13 +- api/app/models/settings.rb | 2 +- api/app/models/user.rb | 12 ++ api/app/operations/spam_mitigation/check.rb | 11 ++ api/app/operations/spam_mitigation/submit.rb | 11 ++ api/app/serializers/v1/setting_serializer.rb | 3 + api/app/services/setting_sections/general.rb | 4 + api/app/services/setting_sections/secrets.rb | 1 + .../services/spam_mitigation/akismet/api.rb | 28 +++ .../spam_mitigation/akismet/config.rb | 14 ++ .../spam_mitigation/akismet/endpoints/base.rb | 98 ++++++++++ .../akismet/endpoints/comment_check.rb | 32 ++++ .../akismet/endpoints/submit_spam.rb | 18 ++ .../akismet/params/accepts_user.rb | 38 ++++ .../spam_mitigation/akismet/params/base.rb | 113 ++++++++++++ .../akismet/params/comment_check.rb | 23 +++ api/app/services/spam_mitigation/checker.rb | 56 ++++++ .../services/spam_mitigation/integrations.rb | 20 ++ api/app/services/spam_mitigation/submitter.rb | 44 +++++ api/app/validators/spam_validator.rb | 25 +++ api/config/locales/en.yml | 3 + .../authorizers/annotation_authorizer_spec.rb | 122 ++++++++---- .../authorizers/comment_authorizer_spec.rb | 102 +++++++--- .../reading_group_authorizer_spec.rb | 14 +- api/spec/factories/user.rb | 8 + api/spec/models/annotation_spec.rb | 85 ++++----- api/spec/models/comment_spec.rb | 8 +- api/spec/models/reading_group_spec.rb | 78 +++++--- .../operations/spam_mitigation/check_spec.rb | 123 +++++++++++++ .../operations/spam_mitigation/submit_spec.rb | 36 ++++ api/spec/requests/comments_spec.rb | 27 +++ api/spec/requests/reading_groups_spec.rb | 117 +++++++++++- .../relationships/annotations_spec.rb | 174 +++++++++++++----- .../spam_mitigation/akismet/config_spec.rb | 23 +++ api/spec/services/spam_mitigation/foo.rb | 0 api/spec/support/helpers/akismet.rb | 98 ++++++++++ api/spec/support/helpers/operations.rb | 25 +++ .../support/matchers/have_an_error_of_type.rb | 11 ++ .../shared_examples/model_spam_detection.rb | 84 +++++++++ 46 files changed, 1619 insertions(+), 220 deletions(-) create mode 100644 api/app/operations/spam_mitigation/check.rb create mode 100644 api/app/operations/spam_mitigation/submit.rb create mode 100644 api/app/services/spam_mitigation/akismet/api.rb create mode 100644 api/app/services/spam_mitigation/akismet/config.rb create mode 100644 api/app/services/spam_mitigation/akismet/endpoints/base.rb create mode 100644 api/app/services/spam_mitigation/akismet/endpoints/comment_check.rb create mode 100644 api/app/services/spam_mitigation/akismet/endpoints/submit_spam.rb create mode 100644 api/app/services/spam_mitigation/akismet/params/accepts_user.rb create mode 100644 api/app/services/spam_mitigation/akismet/params/base.rb create mode 100644 api/app/services/spam_mitigation/akismet/params/comment_check.rb create mode 100644 api/app/services/spam_mitigation/checker.rb create mode 100644 api/app/services/spam_mitigation/integrations.rb create mode 100644 api/app/services/spam_mitigation/submitter.rb create mode 100644 api/app/validators/spam_validator.rb create mode 100644 api/spec/operations/spam_mitigation/check_spec.rb create mode 100644 api/spec/operations/spam_mitigation/submit_spec.rb create mode 100644 api/spec/services/spam_mitigation/akismet/config_spec.rb create mode 100644 api/spec/services/spam_mitigation/foo.rb create mode 100644 api/spec/support/helpers/akismet.rb create mode 100644 api/spec/support/helpers/operations.rb create mode 100644 api/spec/support/matchers/have_an_error_of_type.rb create mode 100644 api/spec/support/shared_examples/model_spam_detection.rb diff --git a/api/app/authorizers/annotation_authorizer.rb b/api/app/authorizers/annotation_authorizer.rb index 77a249c82d..36576e7558 100644 --- a/api/app/authorizers/annotation_authorizer.rb +++ b/api/app/authorizers/annotation_authorizer.rb @@ -1,9 +1,8 @@ -class AnnotationAuthorizer < ApplicationAuthorizer +# frozen_string_literal: true - # There are cases where all users can CRUD annotations. - def self.default(_able, _user, _options = {}) - true - end +# @see Annotation +class AnnotationAuthorizer < ApplicationAuthorizer + requires_trusted_or_established_user! # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def creatable_by?(user, _options = {}) @@ -46,6 +45,15 @@ def readable_by?(user, _options = {}) private + def trusted_or_established_user?(user) + user&.created?(resource) || super + end + + # Only public annotations need reputation to create. + def requires_reputation_to_create? + annotation_is_public? + end + def user_can_notate_text?(user) resource&.text&.notatable_by? user end @@ -74,4 +82,10 @@ def user_is_not_in_reading_group?(user) resource.reading_group.users.exclude? user end + class << self + # There are cases where all users can CRUD annotations. + def default(_able, _user, _options = {}) + true + end + end end diff --git a/api/app/authorizers/application_authorizer.rb b/api/app/authorizers/application_authorizer.rb index 68352e88b6..da6fc2be04 100644 --- a/api/app/authorizers/application_authorizer.rb +++ b/api/app/authorizers/application_authorizer.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + # Other authorizers should subclass this one +# +# @abstract class ApplicationAuthorizer < Authority::Authorizer include ActiveSupport::Configurable include SerializableAuthorization @@ -32,6 +36,13 @@ def known_user?(user) user.role.present? end + # @param [User] user + def trusted_or_established_user?(user) + return false if user.blank? + + user.established? || user.trusted? + end + # @param [User] user # @param [#projects] resource def resource_belongs_to_updatable_project?(user, resource) @@ -196,5 +207,49 @@ def has_role?(user, role, on: :any) user.has_cached_role? role, actual_on end + # Authorizers that should override their create check + # in order to ensure that a user has been trusted or + # established in the instance in order to create the + # associated resource. + # + # @return [void] + def requires_trusted_or_established_user! + include RequiresTrustedOrEstablishedUser + end + end + + # @api private + module RequiresTrustedOrEstablishedUser + extend ActiveSupport::Concern + + included do + prepend RequiresTrustedOrEstablishedUser::WrapperMethods + end + + # Override this in authorizers where only certain models + # require a reputation in order to create them. + # + # For instance, public reading groups. + # @abstract + def requires_reputation_to_create? + true + end + + # Boolean complement of {#requires_reputation_to_create?}, + # defined for legibility. + def requires_no_reputation_to_create? + !requires_reputation_to_create? + end + + # This module gets prepended during inclusion + # + # @api private + module WrapperMethods + def creatable_by?(user, options = {}) + return false unless requires_no_reputation_to_create? || trusted_or_established_user?(user) + + super + end + end end end diff --git a/api/app/authorizers/comment_authorizer.rb b/api/app/authorizers/comment_authorizer.rb index 64e4e4ff85..ff3561de05 100644 --- a/api/app/authorizers/comment_authorizer.rb +++ b/api/app/authorizers/comment_authorizer.rb @@ -1,11 +1,11 @@ +# frozen_string_literal: true + +# @see Comment class CommentAuthorizer < ApplicationAuthorizer + requires_trusted_or_established_user! expose_abilities [:read_deleted] - def self.default(_able, _user, _options = {}) - true - end - def creatable_by?(user, options = {}) return comment_is_on_readable_annotation_for?(user) if comment_is_on_annotation? @@ -44,4 +44,13 @@ def comment_is_on_annotation? resource.on_annotation? end + def trusted_or_established_user?(user) + user&.created?(resource) || super + end + + class << self + def default(_able, _user, _options = {}) + true + end + end end diff --git a/api/app/authorizers/reading_group_authorizer.rb b/api/app/authorizers/reading_group_authorizer.rb index 2b26a2a2d4..d1896d9cd7 100644 --- a/api/app/authorizers/reading_group_authorizer.rb +++ b/api/app/authorizers/reading_group_authorizer.rb @@ -1,4 +1,9 @@ +# frozen_string_literal: true + +# @see ReadingGroup class ReadingGroupAuthorizer < ApplicationAuthorizer + requires_trusted_or_established_user! + def creatable_by?(user, _options = {}) return false unless known_user?(user) return false if reading_groups_disabled? @@ -24,6 +29,11 @@ def readable_by?(user, _options = {}) private + # Only public reading groups need reputation to create. + def requires_reputation_to_create? + resource.public? + end + def moderator?(user) user.has_role?(:moderator, resource) end diff --git a/api/app/controllers/concerns/authentication.rb b/api/app/controllers/concerns/authentication.rb index 8abd6023de..2c1c7eee28 100644 --- a/api/app/controllers/concerns/authentication.rb +++ b/api/app/controllers/concerns/authentication.rb @@ -14,16 +14,17 @@ module Authentication CURRENT_USER_PRELOADS = %w(roles favorites).freeze + # @param [User, nil] + attr_reader :current_user + private def load_current_user @current_user = User.preload(CURRENT_USER_PRELOADS).find(decoded_auth_token[:user_id]) rescue JWT::DecodeError nil - end - - def current_user - @current_user + else + RequestStore[:current_user] = @current_user end # This method gets the current user based on the user_id included diff --git a/api/app/models/annotation.rb b/api/app/models/annotation.rb index 5d548b046c..78b7f9d348 100644 --- a/api/app/models/annotation.rb +++ b/api/app/models/annotation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # An annotation captures a highlighted or annotated range. This model is currently # a likely candidate for refactoring into a "range" model that can be used by various # other manifold records. @@ -14,10 +16,10 @@ class Annotation < ApplicationRecord include SearchIndexable # Constants - TYPE_ANNOTATION = "annotation".freeze - TYPE_HIGHLIGHT = "highlight".freeze - TYPE_RESOURCE = "resource".freeze - TYPE_COLLECTION = "resource_collection".freeze + TYPE_ANNOTATION = "annotation" + TYPE_HIGHLIGHT = "highlight" + TYPE_RESOURCE = "resource" + TYPE_COLLECTION = "resource_collection" ANNOTATION_FORMATS = [ TYPE_ANNOTATION, TYPE_HIGHLIGHT, @@ -65,7 +67,7 @@ class Annotation < ApplicationRecord presence: true, inclusion: { in: ANNOTATION_FORMATS } validate :valid_subject? - validates :body, presence: true, if: :annotation? + validates :body, presence: true, spam: { type: "annotation", if: :public?, on: :create }, if: :annotation? # Delegations delegate :id, to: :project, allow_nil: true, prefix: true diff --git a/api/app/models/comment.rb b/api/app/models/comment.rb index 8e8b5c10be..95e999e394 100644 --- a/api/app/models/comment.rb +++ b/api/app/models/comment.rb @@ -1,6 +1,7 @@ +# frozen_string_literal: true + # A comment is about a subject. class Comment < ApplicationRecord - # Closure Tree has_closure_tree order: "sort_order", numeric_order: true, dependent: :destroy @@ -41,10 +42,8 @@ class Comment < ApplicationRecord delegate :project, to: :subject - # Validations - validates :body, :subject, presence: true + validates :body, presence: true, spam: { on: :create, type: "comment" } - # Callbacks after_commit :enqueue_comment_notifications, on: [:create] after_commit :trigger_event_creation, on: [:create] @@ -87,5 +86,4 @@ def trigger_event_creation def enqueue_comment_notifications Notifications::EnqueueCommentNotificationsJob.perform_later id end - end diff --git a/api/app/models/reading_group.rb b/api/app/models/reading_group.rb index fdfabcc0df..110d35f1d6 100644 --- a/api/app/models/reading_group.rb +++ b/api/app/models/reading_group.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # A reading group is a cohort of users who are collaboratively consuming Manifold content. class ReadingGroup < ApplicationRecord include Authority::Abilities @@ -41,9 +43,11 @@ class ReadingGroup < ApplicationRecord delegate :annotations_count, :highlights_count, :comments_count, :memberships_count, to: :reading_group_count validates :privacy, inclusion: { in: %w(public private anonymous) } - validates :name, presence: true + validates :name, presence: true, spam: { if: :public?, on: :create, type: "title" } validates :invitation_code, uniqueness: true, presence: true + validate :maybe_prevent_public_group_creation!, on: :create, if: :public? + before_validation :ensure_invitation_code before_validation :upcase_invitation_code after_save :ensure_creator_membership @@ -109,6 +113,13 @@ def ensure_creator_membership end end + private + + # @return [void] + def maybe_prevent_public_group_creation! + errors.add :base, :public_reading_groups_disabled if Settings.current.public_reading_groups_disabled? + end + class << self def build_keyword_scope(value) escaped = value.gsub("%", "\\%") diff --git a/api/app/models/settings.rb b/api/app/models/settings.rb index 3202ae82d3..d452d3cea0 100644 --- a/api/app/models/settings.rb +++ b/api/app/models/settings.rb @@ -30,7 +30,7 @@ class Settings < ApplicationRecord has_formatted_attributes :string_data_use_copy, include_wrap: false, container: :theme has_formatted_attributes :string_cookies_banner_copy, include_wrap: false, container: :theme - delegate :default_restricted_access_heading, :default_restricted_access_body, to: :general + delegate :default_restricted_access_heading, :default_restricted_access_body, :public_reading_groups_disabled?, to: :general after_update :update_oauth_providers! diff --git a/api/app/models/user.rb b/api/app/models/user.rb index b999a1faaf..f727edbd94 100644 --- a/api/app/models/user.rb +++ b/api/app/models/user.rb @@ -105,6 +105,14 @@ def email_confirmed alias email_confirmed? email_confirmed + # This is used to check whether the user is permitted to create certain kinds + # of public content. + # + # For now, it is just based on whether or not they have confirmed their email. + def established? + email_confirmed? + end + def search_data { search_result_type: search_result_type, @@ -234,6 +242,10 @@ def sync_global_role! @synchronizing_global_role = false end + def trusted? + has_any_role?(:admin, :editor, :moderator) || has_role?(:project_editor, :any) + end + class << self # @param [#project] subject # @return [ActiveRecord::Relation] diff --git a/api/app/operations/spam_mitigation/check.rb b/api/app/operations/spam_mitigation/check.rb new file mode 100644 index 0000000000..13a826b838 --- /dev/null +++ b/api/app/operations/spam_mitigation/check.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SpamMitigation + # @see SpamMitigation::Checker + class Check + # @return [Dry::Monads::Success({ Symbol => Dry::Monads::Result })] + def call(...) + SpamMitigation::Checker.new(...).call + end + end +end diff --git a/api/app/operations/spam_mitigation/submit.rb b/api/app/operations/spam_mitigation/submit.rb new file mode 100644 index 0000000000..e23b7151de --- /dev/null +++ b/api/app/operations/spam_mitigation/submit.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SpamMitigation + # @see SpamMitigation::Submiter + class Submit + # @return [Dry::Monads::Success({ Symbol => Dry::Monads::Result })] + def call(...) + SpamMitigation::Submitter.new(...).call + end + end +end diff --git a/api/app/serializers/v1/setting_serializer.rb b/api/app/serializers/v1/setting_serializer.rb index d7ed4712bf..7c7b6ff567 100644 --- a/api/app/serializers/v1/setting_serializer.rb +++ b/api/app/serializers/v1/setting_serializer.rb @@ -26,7 +26,9 @@ class SettingSerializer < ManifoldSerializer restricted_access_body_formatted: Types::String, disable_engagement: Types::Bool, + disable_public_reading_groups: Types::Bool, disable_reading_groups: Types::Bool, + disable_spam_detection: Types::Bool, disable_internal_analytics: Types::Bool, contact_email: Types::Serializer::Email.optional, @@ -138,6 +140,7 @@ class SettingSerializer < ManifoldSerializer end typed_section_attribute :secrets, Types::Hash.schema( + akismet_api_key: Types::String, facebook_app_secret: Types::String, twitter_app_secret: Types::String, twitter_access_token_secret: Types::String, diff --git a/api/app/services/setting_sections/general.rb b/api/app/services/setting_sections/general.rb index bbc7db2e5f..1b93798c5f 100644 --- a/api/app/services/setting_sections/general.rb +++ b/api/app/services/setting_sections/general.rb @@ -20,7 +20,9 @@ class General < Base attribute :restricted_access_body, :string, default: DEFAULT_RESTRICTED_ACCESS_BODY attribute :disable_engagement, :boolean, default: false + attribute :disable_public_reading_groups, :boolean, default: false attribute :disable_reading_groups, :boolean, default: false + attribute :disable_spam_detection, :boolean, default: false attribute :disable_internal_analytics, :boolean, default: false attribute :contact_email, :string @@ -34,6 +36,8 @@ class General < Base attribute :terms_url, :string attribute :twitter, :string + alias public_reading_groups_disabled? disable_public_reading_groups + delegate :restricted_access_body_formatted, to: :parent, allow_nil: true # This gets doubly exposed in the serializer. diff --git a/api/app/services/setting_sections/secrets.rb b/api/app/services/setting_sections/secrets.rb index 91675c75d8..38a522b8b2 100644 --- a/api/app/services/setting_sections/secrets.rb +++ b/api/app/services/setting_sections/secrets.rb @@ -7,6 +7,7 @@ module SettingSections # # @see SettingSections::Integrations class Secrets < Base + attribute :akismet_api_key, :string attribute :facebook_app_secret, :string attribute :google_private_key, :string attribute :smtp_settings_password, :string diff --git a/api/app/services/spam_mitigation/akismet/api.rb b/api/app/services/spam_mitigation/akismet/api.rb new file mode 100644 index 0000000000..306779b20b --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/api.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + class API + include Dry::Monads[:result, :do] + + include Dry::Initializer[undefined: false].define -> do + option :config, SpamMitigation::Akismet::Config, default: proc { SpamMitigation::Akismet::Config.new } + end + + delegate :enabled?, to: :config + + # @return [Dry::Monads::Success(:spam)] + # @return [Dry::Monads::Success(:not_spam)] + # @return [Dry::Monads::Failure] + def comment_check(...) + SpamMitigation::Akismet::Endpoints::CommentCheck.new(...).call + end + + # @return [Dry::Monads::Success(void)] + # @return [Dry::Monads::Failure] + def submit_spam(...) + SpamMitigation::Akismet::Endpoints::SubmitSpam.new(...).call + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/config.rb b/api/app/services/spam_mitigation/akismet/config.rb new file mode 100644 index 0000000000..b309d54178 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/config.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + class Config < Types::FlexibleStruct + attribute? :api_key, Types::String.optional.default { Settings.current.secrets.akismet_api_key } + attribute? :blog, Types::String.default { Rails.application.config.manifold.url } + + def enabled? + api_key.present? && blog.present? + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/endpoints/base.rb b/api/app/services/spam_mitigation/akismet/endpoints/base.rb new file mode 100644 index 0000000000..ee07e72632 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/endpoints/base.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Endpoints + # @abstract + class Base + extend Dry::Core::ClassAttributes + extend Dry::Initializer + + include HTTParty + include Dry::Monads[:result, :do] + include SpamMitigation::Integrations + + base_uri "https://rest.akismet.com" + + option :config, SpamMitigation::Akismet::Config, default: proc { SpamMitigation::Akismet::Config.new } + option :user, Types.Instance(::User).optional, optional: true + + defines :endpoint, type: Types::String + defines :params_klass, type: Types::Class + + endpoint ?/ + + params_klass SpamMitigation::Akismet::Params::Base + + # @return [Dry::Monads::Result] + def call + yield check_enabled! + + response = yield run_request + + yield validate_response!(response) + + result = yield handle_response(response) + + Success result + end + + private + + # @return [Dry::Monads::Result] + def build_body + params = self.class.params_klass.new(raw_params) + + Success params.to_body + end + + # @return [Dry::Monads::Success(Hash)] + def build_request_options + body = yield build_body + + options = { body: body, } + + Success options + end + + # @see SpamMitigation::Integrations#check_integration! + # @return [Dry::Monads::Result] + def check_enabled! + check_integration! config.enabled? + end + + # @abstract + # @param [HTTParty::Response] response + # @return [Dry::Monads::Result] + def handle_response(response) + Success response + end + + # Retrieve the (publicly-accessible) options provided to this object's initializer. + # @return [{ Symbol => Object }] + def raw_params + self.class.dry_initializer.attributes(self) + end + + # @return [Dry::Monads::Result] + def run_request + request_options = yield build_request_options + + response = self.class.post(self.class.endpoint, **request_options) + + Success response + end + + # @param [HTTParty::Response] response + # @return [Dry::Monads::Result] + def validate_response!(response) + if response.success? + Success() + else + Failure[:invalid_response, response] + end + end + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/endpoints/comment_check.rb b/api/app/services/spam_mitigation/akismet/endpoints/comment_check.rb new file mode 100644 index 0000000000..eb87db1c56 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/endpoints/comment_check.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Endpoints + class CommentCheck < Base + endpoint "/1.1/comment-check" + + params_klass SpamMitigation::Akismet::Params::CommentCheck + + param :comment_content, Types::String + + option :comment_type, Types::String, default: proc { "comment" } + + def handle_response(response) + yield super + + case response.body + when /true/i + Success SPAM + when /false/i + Success NOT_SPAM + else + # :nocov: + Failure[:indeterminate_response, response] + # :nocov: + end + end + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/endpoints/submit_spam.rb b/api/app/services/spam_mitigation/akismet/endpoints/submit_spam.rb new file mode 100644 index 0000000000..e6618aa803 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/endpoints/submit_spam.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Endpoints + class SubmitSpam < Base + endpoint "/1.1/submit-spam" + + # @note Uses the same params as `comment-check`. + params_klass SpamMitigation::Akismet::Params::CommentCheck + + param :comment_content, Types::String + + option :comment_type, Types::String, default: proc { "comment" } + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/params/accepts_user.rb b/api/app/services/spam_mitigation/akismet/params/accepts_user.rb new file mode 100644 index 0000000000..583b320ee4 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/params/accepts_user.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Params + # Params that accept an optional user and extract information from them + module AcceptsUser + extend ActiveSupport::Concern + + included do + attribute? :user, Types.Instance(User).optional + end + + def has_user? + user.present? + end + + # @api private + # @return [void] + def extract_user_comment_author_info! + comment_author_info = { + comment_author: user.name, + comment_author_email: user.email, + }.compact_blank + + @body.merge! comment_author_info + end + + module ClassMethods + # @return [void] + def extracts_user_comment_author_info! + after_build :extract_user_comment_author_info!, if: :has_user? + end + end + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/params/base.rb b/api/app/services/spam_mitigation/akismet/params/base.rb new file mode 100644 index 0000000000..0d5100e655 --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/params/base.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Params + # @abstract + class Base < Types::FlexibleStruct + extend ActiveModel::Callbacks + extend Dry::Core::ClassAttributes + + attribute? :config, SpamMitigation::Akismet::Config.default { SpamMitigation::Akismet::Config.new } + + defines :sliced_attributes, type: Types::Array.of(Types::Symbol) + defines :sends_blog, type: Types::Bool + defines :sends_api_key, type: Types::Bool + + delegate :api_key, :blog, to: :config + + define_model_callbacks :build + + sends_api_key true + sends_blog false + sliced_attributes [].freeze + + before_build :add_api_key!, if: :sends_api_key? + before_build :add_blog!, if: :sends_blog? + + def initialize(...) + super + + @body = {}.with_indifferent_access + end + + def sends_api_key? + self.class.sends_api_key + end + + def sends_blog? + self.class.sends_blog + end + + # @return [ActiveSupport::HashWithIndifferentAccess] + def to_body + @body = {}.with_indifferent_access + + run_callbacks :build do + @body.merge!(sliced_attributes) + end + + return @body + ensure + @body = {}.with_indifferent_access + end + + private + + # @return [void] + def add_api_key! + @body[:api_key] = api_key + end + + # @return [void] + def add_blog! + @body[:blog] = blog + end + + def sliced_attributes + slice(*self.class.sliced_attributes) + end + + class << self + # @return [void] + def accepts_user! + include AcceptsUser + end + + # @return [void] + def sends_blog! + sends_blog true + end + + # @param [Symbol] name + # @param [Dry::Types::Type] type + # @return [void] + def sliced_attribute(name, type) + attribute(name, type) + + slice_attribute!(name) + end + + # @param [Symbol] name + # @param [Dry::Types::Type] type + # @return [void] + def sliced_attribute?(name, type) + attribute?(name, type) + + slice_attribute!(name) + end + + private + + # @param [Symbol] name + # @return [void] + def slice_attribute!(name) + current = sliced_attributes + + sliced_attributes (current | [name.to_sym]).freeze + end + end + end + end + end +end diff --git a/api/app/services/spam_mitigation/akismet/params/comment_check.rb b/api/app/services/spam_mitigation/akismet/params/comment_check.rb new file mode 100644 index 0000000000..4d15ecbe2d --- /dev/null +++ b/api/app/services/spam_mitigation/akismet/params/comment_check.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SpamMitigation + module Akismet + module Params + # Params for submitting comment-related requests. + # + # @see https://akismet.com/developers/detailed-docs/comment-check/ + # @see https://akismet.com/developers/detailed-docs/submit-spam-missed-spam/ + class CommentCheck < SpamMitigation::Akismet::Params::Base + accepts_user! + + sends_blog! + + extracts_user_comment_author_info! + + sliced_attribute :comment_content, Types::String + + sliced_attribute? :comment_type, Types::String.default { "comment" } + end + end + end +end diff --git a/api/app/services/spam_mitigation/checker.rb b/api/app/services/spam_mitigation/checker.rb new file mode 100644 index 0000000000..2e15e8d1a5 --- /dev/null +++ b/api/app/services/spam_mitigation/checker.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module SpamMitigation + # Check the spamminess of a given piece of content. + # + # Currently only supports Akismet, but this has been designed + # to support multiple providers. + # + # @see SpamMitigation::Check + class Checker + include SpamMitigation::Integrations + include Dry::Monads[:result, :do] + include Dry::Initializer[undefined: false].define -> do + param :content, Types::String + + option :user, Types.Instance(User).optional, default: proc { RequestStore[:current_user] } + + option :type, Types::String.optional, default: proc { "comment" } + end + + delegate :trusted?, to: :user, allow_nil: true, prefix: true + + # @return [Dry::Monads::Success(Boolean)] + def call + # Bypass detection entirely for users who have privileged access, + return Failure[:user_trusted] if user_trusted? + + return Failure[:spam_detection_disabled] if Settings.current.general.disable_spam_detection? + + checks = [check_with_akismet] + + is_spam = checks.any? do |result| + Dry::Matcher::ResultMatcher.(result) do |m| + m.success do |response| + response == SPAM + end + + m.failure do + false + end + end + end + + Success is_spam + end + + private + + # @return [Dry::Monads::Result] + def check_with_akismet + api = SpamMitigation::Akismet::API.new + + api.comment_check(content, comment_type: type, user: user) + end + end +end diff --git a/api/app/services/spam_mitigation/integrations.rb b/api/app/services/spam_mitigation/integrations.rb new file mode 100644 index 0000000000..fddfc35dd8 --- /dev/null +++ b/api/app/services/spam_mitigation/integrations.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SpamMitigation + module Integrations + extend ActiveSupport::Concern + + include Dry::Monads[:result] + + SPAM = :spam + + NOT_SPAM = :not_spam + + # @api private + # @param [Boolean] check + # @return [Dry::Monads::Result] + def check_integration!(check) + check.present? ? Success() : Failure[:integration_disabled] + end + end +end diff --git a/api/app/services/spam_mitigation/submitter.rb b/api/app/services/spam_mitigation/submitter.rb new file mode 100644 index 0000000000..c331f51202 --- /dev/null +++ b/api/app/services/spam_mitigation/submitter.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module SpamMitigation + # Submit content that _should_ be considered spam to supported providers. + # + # Currently only supports Akismet. + # + # @see SpamMitigation::Submit + class Submitter + extend ActiveModel::Callbacks + + include SpamMitigation::Integrations + include Dry::Monads[:result, :do] + include Dry::Initializer[undefined: false].define -> do + param :content, Types::String + + option :user, Types.Instance(User).optional, default: proc { RequestStore[:current_user] } + + option :type, Types::String.optional, default: proc { "comment" } + end + + STRATEGIES = %i[akismet].freeze + + # @return [Dry::Monads::Success({ Symbol => Dry::Monads::Result })] + def call + results = STRATEGIES.index_with do |strategy| + __send__(:"submit_with_#{strategy}") + end + + Success results + end + + private + + # @return [Dry::Monads::Result] + def submit_with_akismet + api = SpamMitigation::Akismet::API.new + + response = yield api.submit_spam(content, comment_type: type, user: user) + + Success response.body + end + end +end diff --git a/api/app/validators/spam_validator.rb b/api/app/validators/spam_validator.rb new file mode 100644 index 0000000000..2a65a8cb36 --- /dev/null +++ b/api/app/validators/spam_validator.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Check incoming content for spamminess based on external API determinations. +# +# This can be turned off with the {SettingSections::General general} setting `disable_spam_detection`. +# +# @see SpamMitigation::Check +# @see SpamMitigation::Checker +class SpamValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + # :nocov: + return if value.blank? || Settings.current.general.disable_spam_detection? + # :nocov: + + user = RequestStore[:current_user] || record.try(:creator) + + type = options[:type].presence || "comment" + + result = ManifoldApi::Container["spam_mitigation.check"].(value, type: type, user: user) + + is_spam = result.value_or(false) + + record.errors.add(attribute, :spam) if is_spam + end +end diff --git a/api/config/locales/en.yml b/api/config/locales/en.yml index b8dd61e5dc..df6e8f99b5 100644 --- a/api/config/locales/en.yml +++ b/api/config/locales/en.yml @@ -57,6 +57,9 @@ en: activerecord: attributes: errors: + messages: + public_reading_groups_disabled: "Public reading groups are disabled" + spam: is spam models: resource: attributes: diff --git a/api/spec/authorizers/annotation_authorizer_spec.rb b/api/spec/authorizers/annotation_authorizer_spec.rb index b867c17101..93b3cbea02 100644 --- a/api/spec/authorizers/annotation_authorizer_spec.rb +++ b/api/spec/authorizers/annotation_authorizer_spec.rb @@ -1,35 +1,37 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Annotation Abilities", :authorizer do let_it_be(:user) { FactoryBot.create(:user) } let_it_be(:creator) { FactoryBot.create(:user) } let_it_be(:project) { FactoryBot.create(:project, draft: false) } - let_it_be(:text) { FactoryBot.create(:text, project: project) } - let_it_be(:object) { FactoryBot.create(:annotation, creator: creator, private: false, text: text) } + let_it_be(:text) { FactoryBot.create(:text, project: project, creator: creator) } + let_it_be(:object) { FactoryBot.create(:annotation, :is_public, creator: creator, text: text) } context "when the subject is an anonymous user" do let_it_be(:subject) { anonymous_user } + abilities = { create: false, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Annotation, abilities end context "when the subject is an admin" do - let_it_be(:subject) { FactoryBot.create(:user, :admin) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user, :admin) } the_subject_behaves_like "instance abilities", Annotation, all: true end context "when the subject is an editor" do - let_it_be(:subject) { FactoryBot.create(:user, :editor) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user, :editor) } the_subject_behaves_like "instance abilities", Annotation, all: true end context "when the subject is a resource editor for the annotation's project" do let_it_be(:subject) do - user = FactoryBot.create(:user) - user.add_role :project_editor, project - user + FactoryBot.create(:user).tap do |user| + user.add_role :project_editor, project + end end context "when the annotation is a text annotation created by another user" do @@ -38,8 +40,8 @@ end context "when the annotation is a resource annotation created by another user" do - let_it_be(:object) do - FactoryBot.create(:resource_annotation, creator: creator, private: false, text: text) + let_it_be(:object, refind: true) do + FactoryBot.create(:resource_annotation, :is_public, creator: creator, text: text) end abilities = { create: true, read: true, update: true, delete: true } the_subject_behaves_like "instance abilities", Annotation, abilities @@ -48,35 +50,60 @@ context "when the subject is a reader" do context "when the annotation is a resource annotation created by another user" do - let_it_be(:object) do + let_it_be(:object, refind: true) do FactoryBot.create(:resource_annotation, creator: creator, private: false, text: text) end + let_it_be(:subject) { user } + abilities = { create: false, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Annotation, abilities end end context "when the subject is the resource creator" do - let_it_be(:subject) { creator } + let_it_be(:subject, refind: true) { creator } + abilities = { all: true } + the_subject_behaves_like "instance abilities", Annotation, abilities end context "when the annotation belongs to a private reading group" do - let_it_be(:reading_group) { FactoryBot.create(:reading_group, privacy: "private") } - let_it_be(:object) do - FactoryBot.create(:annotation, creator: creator, private: false, reading_group: reading_group, text: text) + let_it_be(:reading_group, refind: true) { FactoryBot.create(:reading_group, :is_private) } + + let_it_be(:object, refind: true) do + FactoryBot.create(:annotation, :is_public, creator: creator, reading_group: reading_group, text: text) end context "when the reader belongs to the annotation group" do - before(:each) do + before do FactoryBot.create(:reading_group_membership, reading_group: reading_group, user: user) + reading_group.reload end - let_it_be(:subject) { user } - abilities = { create: true, read: true, update: false, delete: false } - the_subject_behaves_like "instance abilities", Annotation, abilities + + let_it_be(:subject, refind: true) { user } + + context "with a confirmed email" do + before do + subject.mark_email_confirmed! + end + + abilities = { create: true, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Annotation, abilities + end + + context "with an unconfirmed email" do + before do + subject.prepare_email_confirmation! + end + + abilities = { create: false, read: true, update: false, delete: false } + + the_subject_behaves_like "instance abilities", Annotation, abilities + end end context "when the reader does not belong to the annotation group" do @@ -88,18 +115,37 @@ context "when the annotation belongs to a public reading group" do let_it_be(:reading_group) { FactoryBot.create(:reading_group, privacy: "public") } - let_it_be(:object) do - FactoryBot.create(:annotation, creator: creator, private: false, reading_group: reading_group, text: text) + let_it_be(:object, refind: true) do + FactoryBot.create(:annotation, :is_public, creator: creator, reading_group: reading_group, text: text) end - let_it_be(:subject) { user } + let_it_be(:subject, refind: true) { user } context "when the reader belongs to the annotation group" do - before(:each) do + before do FactoryBot.create(:reading_group_membership, reading_group: reading_group, user: user) + reading_group.reload end - abilities = { create: true, read: true, update: false, delete: false } - the_subject_behaves_like "instance abilities", Annotation, abilities + + context "with a confirmed email" do + before do + subject.mark_email_confirmed! + end + + abilities = { create: true, read: true, update: false, delete: false } + + the_subject_behaves_like "instance abilities", Annotation, abilities + end + + context "with an unconfirmed email" do + before do + subject.prepare_email_confirmation! + end + + abilities = { create: false, read: true, update: false, delete: false } + + the_subject_behaves_like "instance abilities", Annotation, abilities + end context "when reading groups are disabled" do let_it_be(:object) do @@ -108,16 +154,10 @@ abilities = { create: false, read: false, update: false, delete: true } - before(:all) do - settings = Settings.instance - settings.general[:disable_reading_groups] = true - settings.save - end - - after(:all) do + before do settings = Settings.instance - settings.general[:disable_reading_groups] = false - settings.save + settings.general.disable_reading_groups = true + settings.save! end the_subject_behaves_like "instance abilities", Annotation, abilities @@ -126,21 +166,27 @@ context "when the reader does not belong to the annotation group" do abilities = { create: false, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Annotation, abilities end end - context "when the subject is a reader" do + context "when the subject is a reader with a confirmed email address" do + before do + subject.mark_email_confirmed! + end + context "when annotation is public" do - let_it_be(:subject) { user } + let_it_be(:subject, refind: true) { user } + abilities = { create: true, read: true, update: false, delete: false } the_subject_behaves_like "instance abilities", Annotation, abilities end context "when annotation is private" do - let_it_be(:subject) { user } - let_it_be(:object) { FactoryBot.create(:annotation, creator: creator, private: true) } + let_it_be(:subject, refind: true) { user } + let_it_be(:object, refind: true) { FactoryBot.create(:annotation, :is_private, creator: creator) } abilities = { create: true, read: false, update: false, delete: false } diff --git a/api/spec/authorizers/comment_authorizer_spec.rb b/api/spec/authorizers/comment_authorizer_spec.rb index bdbb882057..3a53b9cfd7 100644 --- a/api/spec/authorizers/comment_authorizer_spec.rb +++ b/api/spec/authorizers/comment_authorizer_spec.rb @@ -1,64 +1,110 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Comment Abilities", :authorizer do - let(:creator) { FactoryBot.create(:user, :reader) } - let(:object) { FactoryBot.create(:comment, creator: creator) } + let_it_be(:creator, refind: true) { FactoryBot.create(:user, :reader) } + let_it_be(:object, refind: true) { FactoryBot.create(:comment, creator: creator) } context "when the subject is an admin" do - let(:subject) { FactoryBot.create(:user, :admin) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user, :admin) } the_subject_behaves_like "instance abilities", Comment, all: true end context "when the subject is an editor" do - let(:subject) { FactoryBot.create(:user, :editor) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user, :editor) } abilities = { create: true, read: true, update: false, delete: true } the_subject_behaves_like "instance abilities", Comment, abilities end context "when the subject is a reader" do - let(:subject) { FactoryBot.create(:user) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user) } - abilities = { create: true, read: true, update: false, delete: false } - the_subject_behaves_like "instance abilities", Comment, abilities + context "with a confirmed email" do + before do + subject.mark_email_confirmed! + end + + abilities = { create: true, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Comment, abilities + end + + context "with an unconfirmed email" do + before do + subject.prepare_email_confirmation! + end + + abilities = { create: false, read: true, update: false, delete: false } + the_subject_behaves_like "instance abilities", Comment, abilities + end end context "when the subject is the resource creator" do - let(:subject) { creator } + let_it_be(:subject, refind: true) { creator } the_subject_behaves_like "instance abilities", Comment, all: true end context "when the comment is on an annotation in a closed project with disabled engagement" do - let!(:project) { FactoryBot.create(:project, :with_restricted_access, disable_engagement: true ) } - let!(:user) { FactoryBot.create(:user) } - let!(:reading_group) { FactoryBot.create(:reading_group)} - let!(:reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: reading_group, user: user)} - let!(:entitlement) { FactoryBot.create :entitlement, :read_access, :for_reading_group, target: reading_group.reload, subject: project } - let(:text) { FactoryBot.create(:text, project: project) } - let(:annotation) { FactoryBot.create(:annotation, creator: user, reading_group: reading_group, text: text) } - let!(:object) { FactoryBot.build(:comment, creator: user, subject: annotation) } - let!(:subject) { user.reload } - # abilities = { create: true, read: true, update: true, delete: true } - the_subject_behaves_like "instance abilities", Comment, all: true + let_it_be(:project, refind: true) { FactoryBot.create(:project, :with_restricted_access, disable_engagement: true ) } + let_it_be(:user, refind: true) { FactoryBot.create(:user) } + let_it_be(:reading_group, refind: true) { FactoryBot.create(:reading_group) } + let_it_be(:reading_group_membership, refind: true) { FactoryBot.create(:reading_group_membership, reading_group: reading_group, user: user) } + let_it_be(:entitlement, refind: true) { FactoryBot.create :entitlement, :read_access, :for_reading_group, target: reading_group.reload, subject: project } + let_it_be(:text, refind: true) { FactoryBot.create(:text, project: project) } + let_it_be(:annotation, refind: true) { FactoryBot.create(:annotation, creator: user, reading_group: reading_group, text: text) } + let_it_be(:object, refind: true) { FactoryBot.create(:comment, creator: user, subject: annotation) } + let_it_be(:subject, refind: true) { user.reload } + + context "with a confirmed email" do + before do + subject.mark_email_confirmed! + end + + the_subject_behaves_like "instance abilities", Comment, all: true + end + + context "with an unconfirmed email" do + before do + subject.prepare_email_confirmation! + end + + the_subject_behaves_like "instance abilities", Comment, all: true + end end context "when the comment is for a project that has disabled engagement" do - let(:subject) { FactoryBot.create(:user) } - let(:project) { FactoryBot.create(:project, disable_engagement: true) } - let(:object) { FactoryBot.create(:comment, creator: creator, subject: comment_subject) } + let_it_be(:subject, refind: true) { FactoryBot.create(:user) } + let_it_be(:project, refind: true) { FactoryBot.create(:project, disable_engagement: true) } context "when the commment is on a resource" do - let(:comment_subject) { FactoryBot.create(:resource, project: project) } + let_it_be(:comment_subject, refind: true) { FactoryBot.create(:resource, project: project) } + let_it_be(:object, refind: true) { FactoryBot.create(:comment, creator: creator, subject: comment_subject) } + the_subject_behaves_like "instance abilities", Comment, none: true end context "when the commment is on an annotation" do - let(:text) { FactoryBot.create(:text, project: project) } - let(:text_section) { FactoryBot.create(:text_section, text: text) } - let(:comment_subject) { FactoryBot.create(:annotation, text_section: text_section) } - the_subject_behaves_like "instance abilities", Comment, { create: true, read: true, update: false, delete: false} + let_it_be(:text, refind: true) { FactoryBot.create(:text, project: project) } + let_it_be(:text_section, refind: true) { FactoryBot.create(:text_section, text: text) } + let_it_be(:comment_subject, refind: true) { FactoryBot.create(:annotation, text_section: text_section) } + let_it_be(:object, refind: true) { FactoryBot.create(:comment, creator: creator, subject: comment_subject) } + + context "with a confirmed email" do + before do + subject.mark_email_confirmed! + end + + the_subject_behaves_like "instance abilities", Comment, { create: true, read: true, update: false, delete: false} + end + + context "with an unconfirmed email" do + before do + subject.prepare_email_confirmation! + end + + the_subject_behaves_like "instance abilities", Comment, { create: false, read: true, update: false, delete: false} + end end end end diff --git a/api/spec/authorizers/reading_group_authorizer_spec.rb b/api/spec/authorizers/reading_group_authorizer_spec.rb index f2a7dc0bcb..9af58c7d65 100644 --- a/api/spec/authorizers/reading_group_authorizer_spec.rb +++ b/api/spec/authorizers/reading_group_authorizer_spec.rb @@ -1,4 +1,4 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe "Reading Group Abilities", :authorizer do let(:user) { FactoryBot.create :user } @@ -45,16 +45,10 @@ end context "when reading groups are disabled" do - before(:all) do + before do settings = Settings.instance - settings.general[:disable_reading_groups] = true - settings.save - end - - after(:all) do - settings = Settings.instance - settings.general[:disable_reading_groups] = false - settings.save + settings.general.disable_reading_groups = true + settings.save! end context "when the reading group was created by the reader" do diff --git a/api/spec/factories/user.rb b/api/spec/factories/user.rb index 2d63afb462..0cc2410c00 100644 --- a/api/spec/factories/user.rb +++ b/api/spec/factories/user.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + FactoryBot.define do factory :user do first_name { "John" } @@ -29,5 +31,11 @@ trait :reader do role { :reader } end + + trait :with_confirmed_email do + after(:create) do |user, _evaluator| + user.mark_email_confirmed! + end + end end end diff --git a/api/spec/models/annotation_spec.rb b/api/spec/models/annotation_spec.rb index f386bfdfa1..548b304329 100644 --- a/api/spec/models/annotation_spec.rb +++ b/api/spec/models/annotation_spec.rb @@ -1,16 +1,13 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe Annotation, type: :model do - before(:each) do - @annotation = FactoryBot.build(:annotation) - end - - it "has a valid annotation factory" do - expect(FactoryBot.build(:annotation)).to be_valid - end + let_it_be(:creator, refind: true) { FactoryBot.create :user } + let_it_be(:project, refind: true) { FactoryBot.create :project, creator: creator } + let_it_be(:text, refind: true) { FactoryBot.create :text, project: project, creator: creator } + let_it_be(:text_section) { FactoryBot.create :text_section, text: text } - it "has a valid resource annotation factory" do - expect(FactoryBot.build(:resource_annotation)).to be_valid + before(:each) do + @annotation = FactoryBot.build(:annotation, text_section: text_section, creator: creator) end it "knows what project it belongs to" do @@ -145,44 +142,44 @@ end describe "the with_read_ability scope" do - let(:private_group) { FactoryBot.create(:reading_group, privacy: "private") } - let(:private_group_member) do - user = FactoryBot.create(:user) - FactoryBot.create(:reading_group_membership, user: user, reading_group: private_group) - user + let_it_be(:private_group, refind: true) { FactoryBot.create(:reading_group, privacy: "private") } + let_it_be(:private_group_member, refind: true) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:reading_group_membership, user: user, reading_group: private_group) + end end - let(:public_group) { FactoryBot.create(:reading_group, privacy: "public") } - let(:public_group_member) do - user = FactoryBot.create(:user) - FactoryBot.create(:reading_group_membership, user: user, reading_group: public_group) - user + let_it_be(:public_group, refind: true) { FactoryBot.create(:reading_group, privacy: "public") } + let_it_be(:public_group_member, refind: true) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:reading_group_membership, user: user, reading_group: public_group) + end end - let(:anonymous_group) { FactoryBot.create(:reading_group, privacy: "anonymous") } - let(:anonymous_group_member) do - user = FactoryBot.create(:user) - FactoryBot.create(:reading_group_membership, user: user, reading_group: anonymous_group) - user + let_it_be(:anonymous_group, refind: true) { FactoryBot.create(:reading_group, privacy: "anonymous") } + let_it_be(:anonymous_group_member, refind: true) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:reading_group_membership, user: user, reading_group: anonymous_group) + end end - let(:all_groups_member) do - all_groups_member = FactoryBot.create(:user) - FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: private_group) - FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: public_group) - FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: anonymous_group) - all_groups_member + let_it_be(:all_groups_member, refind: true) do + FactoryBot.create(:user).tap do |all_groups_member| + FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: private_group) + FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: public_group) + FactoryBot.create(:reading_group_membership, user: all_groups_member, reading_group: anonymous_group) + end end - let(:private_group_annotation) { FactoryBot.create(:annotation, private: false, reading_group: private_group) } - let(:public_group_annotation) { FactoryBot.create(:annotation, private: false, reading_group: public_group) } - let(:anonymous_group_annotation) { FactoryBot.create(:annotation, private: false, reading_group: anonymous_group) } - let(:public_annotation) { FactoryBot.create(:annotation, private: false, reading_group: nil) } - let(:private_annotation) { FactoryBot.create(:annotation, private: true, reading_group: nil) } - let(:resource_annotation) { FactoryBot.create(:annotation, private: false, format: Annotation::TYPE_RESOURCE, resource: FactoryBot.create(:resource))} - let(:all_groups_member_private_annotation) { FactoryBot.create(:annotation, private: true, reading_group: nil, creator: all_groups_member) } + let_it_be(:private_group_annotation, refind: true) { FactoryBot.create(:annotation, private: false, reading_group: private_group) } + let_it_be(:public_group_annotation, refind: true) { FactoryBot.create(:annotation, private: false, reading_group: public_group) } + let_it_be(:anonymous_group_annotation, refind: true) { FactoryBot.create(:annotation, private: false, reading_group: anonymous_group) } + let_it_be(:public_annotation, refind: true) { FactoryBot.create(:annotation, private: false, reading_group: nil) } + let_it_be(:private_annotation, refind: true) { FactoryBot.create(:annotation, private: true, reading_group: nil) } + let_it_be(:resource_annotation, refind: true) { FactoryBot.create(:annotation, private: false, format: Annotation::TYPE_RESOURCE, resource: FactoryBot.create(:resource))} + let_it_be(:all_groups_member_private_annotation, refind: true) { FactoryBot.create(:annotation, private: true, reading_group: nil, creator: all_groups_member) } - let(:reader) { FactoryBot.create(:user) } + let_it_be(:reader, refind: true) { FactoryBot.create(:user) } shared_examples_for "a readable annotation" do |label, annotation_sym, exclude_public = false| it "when the annotation is a #{label}" do @@ -201,8 +198,8 @@ context "when filtering, the annotation" do context "when the user is not in any groups" do - let(:user) { reader } - let(:private_group_annotation_by_former_group_member) do + let_it_be(:user, refind: true) { reader } + let_it_be(:private_group_annotation_by_former_group_member, refind: true) do FactoryBot.create(:annotation, private: false, reading_group: private_group, creator: user) end let(:public_group_annotation_by_former_group_member) do @@ -320,4 +317,10 @@ end end end + + context "when detecting spam" do + it_behaves_like "a model with spam detection" do + let(:instance) { FactoryBot.build :annotation } + end + end end diff --git a/api/spec/models/comment_spec.rb b/api/spec/models/comment_spec.rb index 87e35511db..e51b0ae2ac 100644 --- a/api/spec/models/comment_spec.rb +++ b/api/spec/models/comment_spec.rb @@ -1,4 +1,4 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe Comment, type: :model do it "has a valid comment factory" do @@ -57,4 +57,10 @@ end end end + + context "when detecting spam" do + it_behaves_like "a model with spam detection" do + let(:instance) { FactoryBot.build :comment } + end + end end diff --git a/api/spec/models/reading_group_spec.rb b/api/spec/models/reading_group_spec.rb index cad75f37e5..8e767aff7a 100644 --- a/api/spec/models/reading_group_spec.rb +++ b/api/spec/models/reading_group_spec.rb @@ -1,59 +1,83 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe ReadingGroup, type: :model do - it "has a valid factory" do - reading_group = FactoryBot.build(:reading_group) - expect(reading_group).to be_valid - end - - it "can be persisted" do - expect(FactoryBot.create(:reading_group).persisted?).to be true - end + let_it_be(:creator, refind: true) { FactoryBot.create_default :user } + let_it_be(:project, refind: true) { FactoryBot.create_default :project, creator: creator } + let_it_be(:text, refind: true) { FactoryBot.create_default :text, project: project, creator: creator } + let_it_be(:text_section) { FactoryBot.create_default :text_section, text: text } it "has an invitation_code after it's saved" do reading_group = FactoryBot.create(:reading_group) - expect(reading_group.invitation_code.present?).to be true + + expect(reading_group).to be_invitation_code end - it "always has an upper case invitation code" do + it "always has an upper case invitation code", :aggregate_failures do reading_group = FactoryBot.create(:reading_group, invitation_code: "aaaccc123") + expect(reading_group.invitation_code).to eq "AAACCC123" expect(reading_group.reload.invitation_code).to eq "AAACCC123" end context "when it is destroyed" do - before(:each) do - @public_annotation = FactoryBot.create(:annotation, reading_group: reading_group, private: false) - @private_annotation = FactoryBot.create(:annotation, reading_group: reading_group, private: true) - end + let(:public_annotation) { FactoryBot.create(:annotation, :is_public, reading_group: reading_group) } + let(:private_annotation) { FactoryBot.create(:annotation, :is_private, reading_group: reading_group) } context "when it is public" do let(:reading_group) { FactoryBot.create(:reading_group, privacy: "public") } it "ensures that child annotations are public" do - reading_group.destroy - expect(@public_annotation.reload.reading_group).to be nil - expect(@public_annotation.reload.private).to be false - expect(@private_annotation.reload.private).to be false + expect do + reading_group.destroy + end.to change { public_annotation.reload.reading_group }.to(nil) + .and change { private_annotation.reload.reading_group }.to(nil) + .and keep_the_same { public_annotation.reload.private } + .and change { private_annotation.reload.private }.from(true).to(false) end end context "when it is private" do let(:reading_group) { FactoryBot.create(:reading_group, privacy: "private") } it "ensures that child annotations are private" do - reading_group.reload.destroy - expect(@public_annotation.reload.reading_group).to be nil - expect(@public_annotation.reload.private).to be true - expect(@private_annotation.reload.private).to be true + expect do + reading_group.destroy + end.to change { public_annotation.reload.reading_group }.to(nil) + .and change { private_annotation.reload.reading_group }.to(nil) + .and change { public_annotation.reload.private }.from(false).to(true) + .and keep_the_same { private_annotation.reload.private } end end context "when it is anonymous" do let(:reading_group) { FactoryBot.create(:reading_group, privacy: "anonymous") } it "ensures that child annotations are private" do - reading_group.reload.destroy - expect(@public_annotation.reload.reading_group).to be nil - expect(@public_annotation.reload.private).to be true - expect(@private_annotation.reload.private).to be true + expect do + reading_group.destroy + end.to change { public_annotation.reload.reading_group }.to(nil) + .and change { private_annotation.reload.reading_group }.to(nil) + .and change { public_annotation.reload.private }.from(false).to(true) + .and keep_the_same { private_annotation.reload.private } + end + end + end + + context "when detecting spam" do + context "with a non-public reading group" do + before do + akismet_enabled! + + akismet_stub_comment_check!(situation: :spam) + end + + let(:instance) { FactoryBot.build :reading_group, :is_private } + + subject { instance } + + it { is_expected.not_to have_an_error_of_type(:spam) } + end + + context "with a public reading group" do + it_behaves_like "a model with spam detection" do + let(:instance) { FactoryBot.build :reading_group, :is_public } end end end diff --git a/api/spec/operations/spam_mitigation/check_spec.rb b/api/spec/operations/spam_mitigation/check_spec.rb new file mode 100644 index 0000000000..d47487df2c --- /dev/null +++ b/api/spec/operations/spam_mitigation/check_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +RSpec.describe SpamMitigation::Check, type: :operation do + let(:content) { "this is a message" } + + let(:user) { nil } + + let(:type) { "comment" } + + let(:operation_args) do + [content] + end + + let(:operation_options) do + { + user: user, + type: type, + } + end + + context "when akismet is enabled" do + before do + akismet_enabled! + end + + context "when akismet returns any kind of error" do + before do + akismet_stub_comment_check!(situation: :error) + + # sanity check + expect(Settings.current.general).not_to be_disable_spam_detection + end + + it "allows the content to go through in lieu of a better current approach" do + expect_calling_the_operation.to succeed.with(false) + end + end + + context "when providing non-spammy content" do + before do + akismet_stub_comment_check!(situation: :not_spam) + end + + it "passes" do + expect_calling_the_operation.to succeed.with(false) + end + + context "when the user is trusted" do + let(:user) { FactoryBot.create(:user, :admin) } + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:user_trusted) + end + end + + context "when spam detection has been disabled globally" do + before do + spam_detection_disabled! + end + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:spam_detection_disabled) + end + end + end + + context "when providing something that should be flagged as spam" do + before do + akismet_stub_comment_check!(situation: :spam) + end + + it "is flagged as expected" do + expect_calling_the_operation.to succeed.with(true) + end + + context "when the user is trusted" do + let(:user) { FactoryBot.create(:user, :admin) } + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:user_trusted) + end + end + + context "when spam detection has been disabled globally" do + before do + spam_detection_disabled! + end + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:spam_detection_disabled) + end + end + end + end + + context "when akismet is disabled" do + before do + akismet_disabled! + end + + it "passes" do + expect_calling_the_operation.to succeed.with(false) + end + + context "when the user is trusted" do + let(:user) { FactoryBot.create(:user, :admin) } + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:user_trusted) + end + end + + context "when spam detection has been disabled globally" do + before do + spam_detection_disabled! + end + + it "is skipped for the right reason" do + expect_calling_the_operation.to monad_fail.with_key(:spam_detection_disabled) + end + end + end +end diff --git a/api/spec/operations/spam_mitigation/submit_spec.rb b/api/spec/operations/spam_mitigation/submit_spec.rb new file mode 100644 index 0000000000..dcbeb166e3 --- /dev/null +++ b/api/spec/operations/spam_mitigation/submit_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe SpamMitigation::Submit, type: :operation do + let(:content) { "this is a message" } + + let(:user) { nil } + + let(:type) { "comment" } + + let(:operation_args) do + [content] + end + + let(:operation_options) do + { + user: user, + type: type, + } + end + + context "when akismet is enabled" do + before do + akismet_enabled! + end + + context "when everything is behaving" do + before do + akismet_stub_submit_spam!(situation: :ok) + end + + it "passes" do + expect_calling_the_operation.to succeed.with(akismet: succeed.with(/thanks/i)) + end + end + end +end diff --git a/api/spec/requests/comments_spec.rb b/api/spec/requests/comments_spec.rb index b4b0f4a3ca..2e22d29b87 100644 --- a/api/spec/requests/comments_spec.rb +++ b/api/spec/requests/comments_spec.rb @@ -136,6 +136,33 @@ expect(response).to have_http_status(503) end + + context "when the comment is spammy" do + before do + akismet_enabled! + akismet_stub_comment_check!(situation: :spam) + end + + it "does not create a comment" do + expect do + post path, headers: headers, params: params + end.to keep_the_same(Comment, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + context "when the user is a reader with an unconfirmed email" do + let(:headers) { another_reader_headers } + + it "does not create a comment" do + expect do + post path, headers: headers, params: params + end.to keep_the_same(Comment, :count) + + expect(response).to have_http_status(:forbidden) + end end end end diff --git a/api/spec/requests/reading_groups_spec.rb b/api/spec/requests/reading_groups_spec.rb index 9024ac5439..633003d08b 100644 --- a/api/spec/requests/reading_groups_spec.rb +++ b/api/spec/requests/reading_groups_spec.rb @@ -122,7 +122,8 @@ let(:attributes) do { - name: "My Reading Group" + name: "My Reading Group", + privacy: "public", } end @@ -130,22 +131,130 @@ build_json_payload(attributes: attributes) end - it "has a 201 CREATED status code" do + def making_the_request post path, headers: reader_headers, params: valid_params + end + + it "creates the reading group" do + expect do + making_the_request + end.to change(ReadingGroup, :count).by(1) - expect(response).to have_http_status(201) + expect(response).to have_http_status(:created) end it "is rate-limited" do expect do 12.times do - post path, headers: reader_headers, params: valid_params + making_the_request end end.to change(ReadingGroup, :count).by(10) .and change(ThrottledRequest, :count).by(1) expect(response).to have_http_status(503) end + + context "when public reading groups are disabled" do + before do + settings = Settings.current + + settings.general.disable_public_reading_groups = true + + settings.save! + end + + context "with an anonymous reading group" do + let(:attributes) do + { + name: "My Reading Group", + privacy: "anonymous", + } + end + + it "gets created anyway" do + expect do + making_the_request + end.to change(ReadingGroup, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context "with a private reading group" do + let(:attributes) do + { + name: "My Reading Group", + privacy: "private", + } + end + + it "gets created anyway" do + expect do + making_the_request + end.to change(ReadingGroup, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context "with a public reading group" do + let(:attributes) do + { + name: "My Reading Group", + privacy: "public", + } + end + + it "does not create the reading group" do + expect do + making_the_request + end.to keep_the_same(ReadingGroup, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + context "with spam detection enabled" do + before do + akismet_enabled! + akismet_stub_comment_check!(situation: :spam) + end + + context "with a private reading group" do + let(:attributes) do + { + name: "My Reading Group", + privacy: "private", + } + end + + it "gets created anyway" do + expect do + making_the_request + end.to change(ReadingGroup, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context "with a public reading group" do + let(:attributes) do + { + name: "My Reading Group", + privacy: "public", + } + end + + it "does not create the reading group" do + expect do + making_the_request + end.to keep_the_same(ReadingGroup, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end end describe "deletes a reading_group" do diff --git a/api/spec/requests/text_sections/relationships/annotations_spec.rb b/api/spec/requests/text_sections/relationships/annotations_spec.rb index bd61b5cb6e..c7c0c7b0e8 100644 --- a/api/spec/requests/text_sections/relationships/annotations_spec.rb +++ b/api/spec/requests/text_sections/relationships/annotations_spec.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true RSpec.describe "Text Section Annotations API", type: :request do + let_it_be(:creator, refind: true) { FactoryBot.create(:user) } + let_it_be(:project, refind: true) { FactoryBot.create(:project, creator: creator) } + let_it_be(:text, refind: true) { FactoryBot.create(:text, project: project, creator: creator) } + let_it_be(:text_section) { FactoryBot.create(:text_section, text: text) } + let_it_be(:resource, refind: true) { FactoryBot.create(:resource, creator: creator, project: project) } + let_it_be(:collection, refind: true) { FactoryBot.create(:resource_collection, project: project) } - let(:project) { FactoryBot.create(:project) } - let(:text) { FactoryBot.create(:text, project: project) } - let(:text_section) { FactoryBot.create(:text_section, text: text) } - let(:resource) { FactoryBot.create(:resource, project: text_section.project) } - let(:collection) { FactoryBot.create(:resource_collection, project: text_section.project) } - let(:annotation_params) { { attributes: FactoryBot.attributes_for(:annotation) } } let(:resource_params) do { - attributes: FactoryBot.build(:resource_annotation).attributes, + attributes: FactoryBot.attributes_for(:resource_annotation, resource: resource, creator: creator), relationships: { resource: { data: { @@ -24,7 +24,7 @@ let(:collection_params) do { - attributes: FactoryBot.build(:collection_annotation).attributes, + attributes: FactoryBot.attributes_for(:collection_annotation, collection: collection, creator: creator), relationships: { resource_collection: { data: { @@ -39,74 +39,92 @@ let(:path) { api_v1_text_relationships_text_section_annotations_path(text_id: text.id, text_section_id: text_section) } describe "access to public annotations" do - let!(:my_public_reading_group) { FactoryBot.create(:reading_group, privacy: "public") } - let!(:my_private_reading_group) { FactoryBot.create(:reading_group, privacy: "private") } - let!(:my_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_public_reading_group, user: reader) } - let!(:my_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_private_reading_group, user: reader) } - let!(:other_reader_in_my_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_public_reading_group, user: another_reader) } - let!(:other_reader_in_my_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_private_reading_group, user: another_reader) } - - let!(:other_public_reading_group) { FactoryBot.create(:reading_group, privacy: "public") } - let!(:other_private_reading_group) { FactoryBot.create(:reading_group, privacy: "private") } - let!(:reader_in_other_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: other_public_reading_group, user: another_reader) } - let!(:reader_in_other_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: other_private_reading_group, user: another_reader) } + let_it_be(:my_public_reading_group) { FactoryBot.create(:reading_group, privacy: "public") } + let_it_be(:my_private_reading_group) { FactoryBot.create(:reading_group, privacy: "private") } + let_it_be(:my_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_public_reading_group, user: reader) } + let_it_be(:my_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_private_reading_group, user: reader) } + let_it_be(:other_reader_in_my_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_public_reading_group, user: another_reader) } + let_it_be(:other_reader_in_my_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: my_private_reading_group, user: another_reader) } + + let_it_be(:other_public_reading_group) { FactoryBot.create(:reading_group, privacy: "public") } + let_it_be(:other_private_reading_group) { FactoryBot.create(:reading_group, privacy: "private") } + let_it_be(:reader_in_other_public_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: other_public_reading_group, user: another_reader) } + let_it_be(:reader_in_other_private_reading_group_membership) { FactoryBot.create(:reading_group_membership, reading_group: other_private_reading_group, user: another_reader) } # Always Visible - let!(:my_private_annotation) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, private: true) } - let!(:my_public_annotation) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, private: false) } - let!(:my_annotation_in_my_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, reading_group: my_public_reading_group) } - let!(:my_annotation_in_my_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, reading_group: my_private_reading_group) } + let_it_be(:my_private_annotation) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, private: true) } + let_it_be(:my_public_annotation) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, private: false) } + let_it_be(:my_annotation_in_my_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, reading_group: my_public_reading_group) } + let_it_be(:my_annotation_in_my_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: reader, reading_group: my_private_reading_group) } # Always Hidden - let!(:other_private_annotation) { FactoryBot.create(:annotation, text_section: text_section, private: true) } - let!(:other_reader_annotation_in_other_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: other_private_reading_group) } + let_it_be(:other_private_annotation) { FactoryBot.create(:annotation, text_section: text_section, private: true) } + let_it_be(:other_reader_annotation_in_other_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: other_private_reading_group) } # Sometimes Visible - let!(:other_public_annotation) { FactoryBot.create(:annotation, text_section: text_section, private: false) } - let!(:other_reader_annotation_in_my_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: my_private_reading_group) } - let!(:other_reader_annotation_in_my_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: my_public_reading_group) } - let!(:other_reader_annotation_in_other_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: other_public_reading_group) } + let_it_be(:other_public_annotation) { FactoryBot.create(:annotation, text_section: text_section, private: false) } + let_it_be(:other_reader_annotation_in_my_private_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: my_private_reading_group) } + let_it_be(:other_reader_annotation_in_my_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: my_public_reading_group) } + let_it_be(:other_reader_annotation_in_other_public_reading_group) { FactoryBot.create(:annotation, text_section: text_section, creator: another_reader, reading_group: other_public_reading_group) } let(:api_response) { JSON.parse(response.body) } - let(:included_ids) { api_response["data"].map { |a| a["id"] } } + let(:included_ids) { api_response["data"].pluck("id") } let(:path) { api_v1_text_relationships_text_section_annotations_path(text_id: text.id, text_section_id: text_section) } - before(:each) { get path, headers: reader_headers } always_included = %w(my_private_annotation my_public_annotation my_annotation_in_my_public_reading_group my_annotation_in_my_private_reading_group) always_excluded = %w(other_private_annotation other_reader_annotation_in_other_private_reading_group) + def make_the_request! + expect do + get path, headers: reader_headers + end.to execute_safely + end + context "when the project has disabled engagement, visible annotations" do - let(:project) { FactoryBot.create(:project, disable_engagement: true) } + before do + project.update! disable_engagement: true + end included_annotations = always_included + %w(other_reader_annotation_in_my_private_reading_group other_reader_annotation_in_my_public_reading_group) excluded_annotations = always_excluded + %w(other_public_annotation other_reader_annotation_in_other_public_reading_group) included_annotations.each do |annotation| it "includes #{annotation}" do + make_the_request! + expect(included_ids).to include(send(annotation).id) end end + excluded_annotations.each do |annotation| it "excludes #{annotation}" do + make_the_request! + expect(included_ids).to_not include(send(annotation).id) end end end context "when the project has allowed engagement in the collection, visible annotations" do - let(:project) { FactoryBot.create(:project, disable_engagement: false) } + before do + project.update! disable_engagement: false + end included_annotations = always_included + %w(other_public_annotation other_reader_annotation_in_my_private_reading_group other_reader_annotation_in_my_public_reading_group other_reader_annotation_in_other_public_reading_group) - excluded_annotations = always_excluded + %w() + excluded_annotations = always_excluded included_annotations.each do |annotation| it "includes #{annotation}" do + make_the_request! + expect(included_ids).to include(send(annotation).id) end end excluded_annotations.each do |annotation| it "excludes #{annotation}" do + make_the_request! + expect(included_ids).to_not include(send(annotation).id) end end @@ -152,13 +170,25 @@ end end - describe "creates an annotation" do + context "when creating an annotation" do + let(:annotation_privacy) { :is_public } + + let(:annotation_params) do + { + attributes: FactoryBot.attributes_for(:annotation, annotation_privacy, creator: creator, text_section: text_section), + } + end + let(:path) { api_v1_text_section_relationships_annotations_path(text_section_id: text_section.id) } + let(:params) do + build_json_payload(annotation_params) + end + context "when the user is a reader" do it "has a 201 status code" do expect do - post path, headers: reader_headers, params: build_json_payload(annotation_params) + post path, headers: reader_headers, params: params end.to change(Annotation, :count).by(1) expect(response).to have_http_status(201) @@ -167,29 +197,81 @@ it "is rate-limited" do expect do 7.times do - post path, headers: reader_headers, params: build_json_payload(annotation_params) + post path, headers: reader_headers, params: params end end.to change(Annotation, :count).by(5) .and change(ThrottledRequest, :count).by(1) expect(response).to have_http_status(503) end + + context "with spam detection enabled" do + before do + akismet_enabled! + akismet_stub_comment_check!(situation: :spam) + end + + context "when the annotation is private" do + let(:annotation_privacy) { :is_private } + + it "gets created anyway" do + expect do + post path, headers: admin_headers, params: params + end.to change(Annotation, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context "when the annotation is public" do + let(:annotation_privacy) { :is_public } + + it "does not create the annotation" do + expect do + post path, headers: reader_headers, params: params + end.to keep_the_same(Annotation, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end end context "when the user is not authenticated" do - before(:each) { post path, params: build_json_payload(annotation_params) } - describe "the response" do - it "has a 401 status code" do - expect(response).to have_http_status(401) - end + it "is not authorized to create an annotation" do + expect do + post path, params: params + end.to keep_the_same(Annotation, :count) + + expect(response).to have_http_status(:unauthorized) end end context "when the user is an admin" do - before(:each) { post path, headers: admin_headers, params: build_json_payload(annotation_params) } - describe "the response" do - it "has a 201 status code" do - expect(response).to have_http_status(201) + it "creates the annotation" do + expect do + post path, headers: admin_headers, params: params + end.to change(Annotation, :count).by(1) + + expect(response).to have_http_status(:created) + end + + context "with spam detection enabled" do + before do + akismet_enabled! + akismet_stub_comment_check!(situation: :spam) + end + + context "when the annotation is public" do + let(:annotation_privacy) { :is_public } + + it "gets created anyway" do + expect do + post path, headers: admin_headers, params: params + end.to change(Annotation, :count).by(1) + + expect(response).to have_http_status(:created) + end end end end diff --git a/api/spec/services/spam_mitigation/akismet/config_spec.rb b/api/spec/services/spam_mitigation/akismet/config_spec.rb new file mode 100644 index 0000000000..7c9a1a6aa3 --- /dev/null +++ b/api/spec/services/spam_mitigation/akismet/config_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe SpamMitigation::Akismet::Config do + let(:instance) { described_class.new } + + subject { instance } + + context "when akismet is enabled" do + before do + akismet_enabled! + end + + it { is_expected.to be_enabled } + end + + context "when akismet is disabled" do + before do + akismet_disabled! + end + + it { is_expected.not_to be_enabled } + end +end diff --git a/api/spec/services/spam_mitigation/foo.rb b/api/spec/services/spam_mitigation/foo.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/spec/support/helpers/akismet.rb b/api/spec/support/helpers/akismet.rb new file mode 100644 index 0000000000..e5501999ef --- /dev/null +++ b/api/spec/support/helpers/akismet.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module TestHelpers + module Akismet + COMMENT_CHECK_URL = "https://rest.akismet.com/1.1/comment-check" + + SUBMIT_SPAM_URL = "https://rest.akismet.com/1.1/submit-spam" + + COMMON_HEADERS = { + "Content-Type" => "text/plain; charset=utf-8", + }.freeze + + AKISMET_ERROR_MESSAGE = "Something went wrong" + + # @param [Symbol] situation + # @return [void] + def akismet_stub_comment_check!(situation: :not_spam) + options = { headers: COMMON_HEADERS, status: 200 } + + case situation + when :not_spam + options[:body] = "false" + when :spam + options[:body] = "true" + else + options[:status] = 400 + options[:body] = AKISMET_ERROR_MESSAGE + end + + stub_request(:post, COMMENT_CHECK_URL).to_return(**options) + end + + # @param [Symbol] situation + # @return [void] + def akismet_stub_submit_spam!(situation: :ok) + options = { headers: COMMON_HEADERS, status: 200 } + + case situation + when :ok + options[:body] = "Thanks for making the web a better place." + else + options[:status] = 400 + options[:body] = AKISMET_ERROR_MESSAGE + end + + stub_request(:post, SUBMIT_SPAM_URL).to_return(**options) + end + + def spam_detection_enabled! + settings = Settings.current + + settings.general.disable_spam_detection = false + + settings.save! + end + + def spam_detection_disabled! + settings = Settings.current + + settings.general.disable_spam_detection = true + + settings.save! + end + + def akismet_enabled! + spam_detection_enabled! + + settings = Settings.current + + settings.secrets.akismet_api_key = "123456" + + settings.save! + end + + def akismet_disabled! + settings = Settings.current + + settings.secrets.akismet_api_key = nil + + settings.save! + end + end +end + +# We ensure that akismet is stubbed globally with the safest +# strategies, so as to not impact the creation of records that +# use the new validations. +RSpec.shared_context "akismet defaults" do + before do + akismet_stub_comment_check!(situation: :not_spam) + akismet_stub_submit_spam!(situation: :ok) + end +end + +RSpec.configure do |config| + config.include TestHelpers::Akismet + config.include_context "akismet defaults" +end diff --git a/api/spec/support/helpers/operations.rb b/api/spec/support/helpers/operations.rb new file mode 100644 index 0000000000..3a9f1c3695 --- /dev/null +++ b/api/spec/support/helpers/operations.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module TestHelpers + module Operations + # @return [Object] + def calling_the_operation + operation.call(*operation_args, **operation_options) + end + + def expect_calling_the_operation + expect(calling_the_operation) + end + end +end + +RSpec.shared_context "operation helpers" do + include TestHelpers::Operations + + let(:operation) { described_class.new } +end + +RSpec.configure do |config| + config.include TestHelpers::Operations, type: :operations + config.include_context "operation helpers", type: :operation +end diff --git a/api/spec/support/matchers/have_an_error_of_type.rb b/api/spec/support/matchers/have_an_error_of_type.rb new file mode 100644 index 0000000000..8d3fe286c8 --- /dev/null +++ b/api/spec/support/matchers/have_an_error_of_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :have_an_error_of_type do |expected| + match do + actual.validate + + actual.errors.any? do |error| + expected === error.type + end + end +end diff --git a/api/spec/support/shared_examples/model_spam_detection.rb b/api/spec/support/shared_examples/model_spam_detection.rb new file mode 100644 index 0000000000..b0e9d44a4a --- /dev/null +++ b/api/spec/support/shared_examples/model_spam_detection.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for "a model with spam detection" do + let_it_be(:admin) { FactoryBot.create :user, :admin } + + let(:instance) { raise "must provide an instance!" } + + before do + # sanity check + expect(instance).to be_new_record + end + + def instance_has_spam! + expect(instance).to have_an_error_of_type(:spam) + end + + def instance_has_no_spam! + expect(instance).not_to have_an_error_of_type(:spam) + end + + context "when akismet is disabled" do + before do + akismet_disabled! + + akismet_stub_comment_check!(situation: :spam) + end + + it "is not marked as spam no matter what" do + instance_has_no_spam! + end + end + + context "when akismet is enabled" do + before do + akismet_enabled! + end + + it "only checks on create" do + akismet_stub_comment_check!(situation: :not_spam) + + instance_has_no_spam! + + expect do + instance.save! + end.to execute_safely + + akismet_stub_comment_check!(situation: :spam) + + instance_has_no_spam! + end + + context "when the content is not spammy" do + before do + akismet_stub_comment_check!(situation: :not_spam) + end + + it "passes" do + instance_has_no_spam! + end + end + + context "when the content is spammy" do + before do + akismet_stub_comment_check!(situation: :spam) + end + + it "is marked as spam" do + instance_has_spam! + end + + context "when the creator is trusted" do + before do + skip "model does not have a creator, skipping" unless instance.respond_to?(:creator=) + + instance.creator = admin + end + + it "is not marked as spam" do + instance_has_no_spam! + end + end + end + end +end From baf6e5edb935cadcbf24cf838d5267415f4912cc Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Thu, 22 Feb 2024 06:14:39 -0800 Subject: [PATCH 06/12] [C] Improve test coverage * Ensure application is eager loaded before running test suite * Filter out development / testing-only services to make coverage results more meaningful for reviewing gaps --- api/app/services/system_upgrades.rb | 4 + api/spec/rails_helper.rb | 75 +++++++++++++++++++ .../system_upgrades/system_upgrades_spec.rb | 11 ++- api/spec/spec_helper.rb | 71 ------------------ 4 files changed, 86 insertions(+), 75 deletions(-) diff --git a/api/app/services/system_upgrades.rb b/api/app/services/system_upgrades.rb index ab283f5b0c..929112c723 100644 --- a/api/app/services/system_upgrades.rb +++ b/api/app/services/system_upgrades.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + module SystemUpgrades UPGRADES_ROOT = Rails.root.join("app", "services", "system_upgrades", "upgrades") class << self # @return [] def eager_load_upgrades! + # :nocov: return upgrades if Rails.env.test? Rails.application.eager_load! @@ -15,6 +18,7 @@ def eager_load_upgrades! end upgrades + # :nocov: end def upgrades diff --git a/api/spec/rails_helper.rb b/api/spec/rails_helper.rb index a40f3d667a..305d5b0883 100644 --- a/api/spec/rails_helper.rb +++ b/api/spec/rails_helper.rb @@ -2,6 +2,79 @@ ENV["RAILS_ENV"] ||= "test" require "spec_helper" +require "simplecov" + +SimpleCov.start "rails" do + project_name "Manifold" + + enable_coverage :branch + + add_filter "app/models/concerns/arel_helpers.rb" + add_filter "app/services/system_upgrades/upgrades" + add_filter "app/services/demonstration" + add_filter "app/services/importer" + add_filter "app/services/testing" + add_filter "lib/generators" + add_filter "lib/paperclip_migrator.rb" + add_filter "lib/patches/better_enums.rb" + add_filter "lib/patches/better_interactions.rb" + add_filter "lib/templates" + + add_group "Authorizers", %w[ + app/authorizers + ] + + add_group "Decorators", %w[ + app/decorators + ] + + add_group "Enums", %w[app/enums] + + add_group "Fingerprinting", %w[ + app/jobs/fingerprints + app/models/concerns/calculates_fingerprints.rb + app/models/concerns/stores_fingerprints.rb + app/services/collaborators/calculate_fingerprint.rb + app/services/fingerprints + app/services/fingerprints.rb + app/services/texts/calculate_fingerprint.rb + app/services/text_sections/calculate_fingerprint.rb + app/services/text_titles/calculate_fingerprint.rb + ] + + add_group "Ingestion", %w[ + app/models/ingestion.rb + app/models/ingestion_source.rb + app/services/ingestions + ] + + add_group "Packaging", %w[ + app/enums/core_media_type_kind.rb + app/enums/export_kind.rb + app/enums/referenced_path_strategy.rb + app/enums/source_node_kind.rb + app/jobs/packaging + app/jobs/text_exports/prune_job.rb + app/jobs/texts/automate_exports_job.rb + app/models/cached_external_source.rb + app/models/cached_external_source_link.rb + app/models/text_export.rb + app/models/text_export_status.rb + app/services/cached_external_sources + app/services/html_nodes + app/services/packaging + app/services/text_exports/prune.rb + app/services/texts/automate_exports.rb + app/services/epub_check.rb + ] + + add_group "Patches", %w[lib/patches] + + add_group "Serializers", %w[app/serializers] + + add_group "Services", %w[app/services] +end + require File.expand_path("../config/environment", __dir__) require "rspec/rails" @@ -42,6 +115,8 @@ config.preserve_traits = true end +Rails.application.eager_load! + RSpec.configure do |config| config.include TestHelpers config.extend WithModel diff --git a/api/spec/services/system_upgrades/system_upgrades_spec.rb b/api/spec/services/system_upgrades/system_upgrades_spec.rb index 682be64107..d8ca178c89 100644 --- a/api/spec/services/system_upgrades/system_upgrades_spec.rb +++ b/api/spec/services/system_upgrades/system_upgrades_spec.rb @@ -1,4 +1,4 @@ -require "rails_helper" +# frozen_string_literal: true RSpec.describe SystemUpgrades do class Test000100 < SystemUpgrades::AbstractVersion @@ -6,14 +6,17 @@ def perform! logger.debug "Test" end end + class Test000200 < SystemUpgrades::AbstractVersion def perform! logger.debug "Test" end end - it "retrieves and orders upgrade version files" do - expect(SystemUpgrades.eager_load_upgrades!).to match_array([Test000100, Test000200]) - end + it "retrieves and orders upgrade version files", :aggregate_failures do + upgrades = SystemUpgrades.eager_load_upgrades! + expect(upgrades).to include(Test000100) + expect(upgrades).to include(Test000200) + end end diff --git a/api/spec/spec_helper.rb b/api/spec/spec_helper.rb index d58a8a05f0..de48a6efef 100644 --- a/api/spec/spec_helper.rb +++ b/api/spec/spec_helper.rb @@ -1,75 +1,5 @@ # frozen_string_literal: true -require "simplecov" - -SimpleCov.start 'rails' do - project_name "Manifold" - - enable_coverage :branch - - add_filter "app/models/concerns/arel_helpers.rb" - add_filter "lib/generators/**/*.rb" - add_filter "lib/paperclip_migrator.rb" - add_filter "lib/patches/better_enums.rb" - add_filter "lib/patches/better_interactions.rb" - add_filter "lib/templates/**/*.rb" - add_filter "app/services/system_upgrades/upgrades/*.rb" - - add_group "Authorizers", %w[ - app/authorizers - ] - - add_group "Decorators", %w[ - app/decorators - ] - - add_group "Enums", %w[app/enums] - - add_group "Fingerprinting", %w[ - app/jobs/fingerprints/**/*.rb - app/models/concerns/calculates_fingerprints.rb - app/models/concerns/stores_fingerprints.rb - app/services/collaborators/calculate_fingerprint.rb - app/services/fingerprints/**/*.rb - app/services/fingerprints.rb - app/services/texts/calculate_fingerprint.rb - app/services/text_sections/calculate_fingerprint.rb - app/services/text_titles/calculate_fingerprint.rb - ] - - add_group "Ingestion", %w[ - app/models/ingestion.rb - app/models/ingestion_source.rb - app/services/ingestions - ] - - add_group "Packaging", %w[ - app/enums/core_media_type_kind.rb - app/enums/export_kind.rb - app/enums/referenced_path_strategy.rb - app/enums/source_node_kind.rb - app/jobs/packaging - app/jobs/text_exports/prune_job.rb - app/jobs/texts/automate_exports_job.rb - app/models/cached_external_source.rb - app/models/cached_external_source_link.rb - app/models/text_export.rb - app/models/text_export_status.rb - app/services/cached_external_sources - app/services/html_nodes - app/services/packaging - app/services/text_exports/prune.rb - app/services/texts/automate_exports.rb - app/services/epub_check.rb - ] - - add_group "Patches", %w[lib/patches] - - add_group "Serializers", %w[app/serializers] - - add_group "Services", %w[app/services] -end - # This file was generated by the `rails generate rspec:install` command. Conventionally, # all specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # The generated `.rspec` file contains `--require spec_helper` which will cause this @@ -87,7 +17,6 @@ # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| - config.before(:suite) do FactoryBot.reload end From 8d2efe6c81896defab7908c27ec1b8e525480b71 Mon Sep 17 00:00:00 2001 From: Alexa Grey Date: Thu, 22 Feb 2024 10:36:24 -0800 Subject: [PATCH 07/12] [F] Check for spam during update as well --- api/app/models/annotation.rb | 2 +- api/app/models/application_record.rb | 5 +++- api/app/models/comment.rb | 2 +- api/app/models/concerns/detects_spam.rb | 28 +++++++++++++++++++ api/app/models/reading_group.rb | 2 +- .../shared_examples/model_spam_detection.rb | 14 ++++++++-- 6 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 api/app/models/concerns/detects_spam.rb diff --git a/api/app/models/annotation.rb b/api/app/models/annotation.rb index 78b7f9d348..63bdac46ba 100644 --- a/api/app/models/annotation.rb +++ b/api/app/models/annotation.rb @@ -67,7 +67,7 @@ class Annotation < ApplicationRecord presence: true, inclusion: { in: ANNOTATION_FORMATS } validate :valid_subject? - validates :body, presence: true, spam: { type: "annotation", if: :public?, on: :create }, if: :annotation? + validates :body, presence: true, spam: { type: "annotation", if: :public? }, if: :annotation? # Delegations delegate :id, to: :project, allow_nil: true, prefix: true diff --git a/api/app/models/application_record.rb b/api/app/models/application_record.rb index 4fee34165d..8e5f59f53b 100644 --- a/api/app/models/application_record.rb +++ b/api/app/models/application_record.rb @@ -1,4 +1,6 @@ -# Base class for Manifold models to inherit from +# frozen_string_literal: true + +# @abstract Base class for Manifold models to inherit from class ApplicationRecord < ActiveRecord::Base self.abstract_class = true @@ -6,6 +8,7 @@ class ApplicationRecord < ActiveRecord::Base include ClassyEnum::ActiveRecord include ArelHelpers + include DetectsSpam include SliceWith include ValuesAt include WithAdvisoryLock::Concern diff --git a/api/app/models/comment.rb b/api/app/models/comment.rb index 95e999e394..bccb195e37 100644 --- a/api/app/models/comment.rb +++ b/api/app/models/comment.rb @@ -42,7 +42,7 @@ class Comment < ApplicationRecord delegate :project, to: :subject - validates :body, presence: true, spam: { on: :create, type: "comment" } + validates :body, presence: true, spam: { type: "comment" } after_commit :enqueue_comment_notifications, on: [:create] after_commit :trigger_event_creation, on: [:create] diff --git a/api/app/models/concerns/detects_spam.rb b/api/app/models/concerns/detects_spam.rb new file mode 100644 index 0000000000..c10573f280 --- /dev/null +++ b/api/app/models/concerns/detects_spam.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DetectsSpam + extend ActiveSupport::Concern + + # Check to see if this record has any attributes that + # get detected as spam. + # + # @see SpamValidator + def spam_detected? + return false if valid? + + errors.any? { |err| err.type == :spam } + end + + alias has_spam_detected? spam_detected? + + module ClassMethods + # @note This transforms a scope into an array, since we need to run + # each model through the {SpamValidator}. This can be a little slow, + # and care should be taken to make sure it won't run into issues with + # exceeding any rate limits on the spam detection providers. + # @return [] + def detected_as_spam + all.select(&:spam_detected?) + end + end +end diff --git a/api/app/models/reading_group.rb b/api/app/models/reading_group.rb index 110d35f1d6..844c4cdb2d 100644 --- a/api/app/models/reading_group.rb +++ b/api/app/models/reading_group.rb @@ -43,7 +43,7 @@ class ReadingGroup < ApplicationRecord delegate :annotations_count, :highlights_count, :comments_count, :memberships_count, to: :reading_group_count validates :privacy, inclusion: { in: %w(public private anonymous) } - validates :name, presence: true, spam: { if: :public?, on: :create, type: "title" } + validates :name, presence: true, spam: { if: :public?, type: "title" } validates :invitation_code, uniqueness: true, presence: true validate :maybe_prevent_public_group_creation!, on: :create, if: :public? diff --git a/api/spec/support/shared_examples/model_spam_detection.rb b/api/spec/support/shared_examples/model_spam_detection.rb index b0e9d44a4a..e23129e9a3 100644 --- a/api/spec/support/shared_examples/model_spam_detection.rb +++ b/api/spec/support/shared_examples/model_spam_detection.rb @@ -35,18 +35,26 @@ def instance_has_no_spam! akismet_enabled! end - it "only checks on create" do + it "it can catch spam on update" do akismet_stub_comment_check!(situation: :not_spam) instance_has_no_spam! expect do instance.save! - end.to execute_safely + end.to change(described_class, :count).by(1) akismet_stub_comment_check!(situation: :spam) - instance_has_no_spam! + instance_has_spam! + + expect(instance).to have_spam_detected + + expect(instance).to be_in described_class.where(id: instance.id).detected_as_spam + + expect do + instance.save! + end.to raise_error ActiveRecord::RecordInvalid, /spam/ end context "when the content is not spammy" do From 6007ee6c17a74d107c4c8716c58f9cce06bdd0aa Mon Sep 17 00:00:00 2001 From: Lauren Davidson <32903719+1aurend@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:29:35 -0800 Subject: [PATCH 08/12] [F] Add new settings for spam mitigation --- .../src/backend/containers/settings/Properties.js | 14 +++++++++++--- .../app/locale/en-US/json/backend/settings.json | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/client/src/backend/containers/settings/Properties.js b/client/src/backend/containers/settings/Properties.js index ef0c639191..f63cd204b9 100644 --- a/client/src/backend/containers/settings/Properties.js +++ b/client/src/backend/containers/settings/Properties.js @@ -211,11 +211,19 @@ export class SettingsPropertiesContainer extends PureComponent { /> + diff --git a/client/src/config/app/locale/en-US/json/backend/settings.json b/client/src/config/app/locale/en-US/json/backend/settings.json index 0bb6040c12..3d7af7b9ad 100644 --- a/client/src/config/app/locale/en-US/json/backend/settings.json +++ b/client/src/config/app/locale/en-US/json/backend/settings.json @@ -53,7 +53,11 @@ "public_comments_label": "Disable Public Annotations and Comments", "public_comments_instructions": "When on, this setting will prevent users from commenting or annotating publicly. All reading groups will behave as private reading groups.", "reading_groups_label": "Disable Reading Groups", - "reading_groups_instructions": "When on, this setting will prevent users from creating or joining reading groups." + "reading_groups_instructions": "When on, this setting will prevent users from creating or joining reading groups.", + "public_reading_groups_label": "Disable Public Reading Groups", + "public_reading_groups_instructions": "When on, this setting will prevent users from creating public reading groups.", + "spam_detection_label": "Disable Spam Detection", + "spam_detection_instructions": "Do not use Akisment for spam detection and blocking." }, "theme": { "header": "Theme Settings", From 6f88abdef614b622292c33519a245a12f7f922b2 Mon Sep 17 00:00:00 2001 From: Lauren Davidson <32903719+1aurend@users.noreply.github.com> Date: Thu, 22 Feb 2024 11:57:47 -0800 Subject: [PATCH 09/12] [F] Update RG UI to handle disablePublicReadingGroups --- .../reading-group/forms/Group/index.js | 56 +++++++++++++------ .../reading-group/headings/Groups/index.js | 31 +++++++--- .../containers/PublicReadingGroups/Wrapper.js | 15 +++++ client/src/helpers/router/navigation.js | 11 +++- 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/client/src/frontend/components/reading-group/forms/Group/index.js b/client/src/frontend/components/reading-group/forms/Group/index.js index e6e9614b82..ccfdd2bbfd 100644 --- a/client/src/frontend/components/reading-group/forms/Group/index.js +++ b/client/src/frontend/components/reading-group/forms/Group/index.js @@ -8,16 +8,26 @@ import config from "config"; import memoize from "lodash/memoize"; import withConfirmation from "hoc/withConfirmation"; import { ClassNames } from "@emotion/react"; +import { connect } from "react-redux"; +import { select } from "utils/entityUtils"; import * as Styled from "./styles"; class ReadingGroupForm extends React.PureComponent { static displayName = "ReadingGroup.Forms.GroupSettings"; + static mapStateToProps = state => { + return { + allowPublic: !select(requests.settings, state.entityStore).attributes + .general.disablePublicReadingGroups + }; + }; + static propTypes = { mode: PropTypes.oneOf(["new", "edit"]), group: PropTypes.object, onSuccess: PropTypes.func.isRequired, - t: PropTypes.func + t: PropTypes.func, + allowPublic: PropTypes.bool.isRequired }; static defaultProps = { @@ -108,8 +118,33 @@ class ReadingGroupForm extends React.PureComponent { this.setState({ courseEnabled: newEvent.target.value === "true" }); }; + get privacyOptions() { + const { t, allowPublic } = this.props; + const publicOption = allowPublic + ? [ + { + label: t("forms.reading_group.privacy_options.public"), + value: "public" + } + ] + : []; + + return [ + ...publicOption, + { + label: t("forms.reading_group.privacy_options.private"), + value: "private" + }, + { + label: t("forms.reading_group.privacy_options.anonymous"), + value: "anonymous" + } + ]; + } + render() { const { group, onSuccess, t } = this.props; + return ( @@ -245,4 +267,6 @@ class ReadingGroupForm extends React.PureComponent { } } -export default withConfirmation(withTranslation()(ReadingGroupForm)); +export default withConfirmation( + withTranslation()(connect(ReadingGroupForm.mapStateToProps)(ReadingGroupForm)) +); diff --git a/client/src/frontend/components/reading-group/headings/Groups/index.js b/client/src/frontend/components/reading-group/headings/Groups/index.js index a183455308..db1932dd43 100644 --- a/client/src/frontend/components/reading-group/headings/Groups/index.js +++ b/client/src/frontend/components/reading-group/headings/Groups/index.js @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import lh from "helpers/linkHandler"; import { Navigation, Title } from "../parts"; +import { useFromStore } from "hooks"; import * as Styled from "./styles"; function GroupsHeading({ currentUser }) { @@ -21,24 +22,36 @@ function GroupsHeading({ currentUser }) { } ]; + const { + attributes: { + general: { disablePublicReadingGroups } + } + } = useFromStore("settings", "select"); + return (
- {!currentUser && ( + {!currentUser ? ( - )} - {currentUser && ( + ) : ( <> - <Navigation - ariaLabel={t("navigation.reading_group.label")} - links={links} - layout="flex" - padLinks - /> + {disablePublicReadingGroups ? ( + <Title + title={t("navigation.reading_group.my_groups")} + icon="annotationGroup24" + /> + ) : ( + <Navigation + ariaLabel={t("navigation.reading_group.label")} + links={links} + layout="flex" + padLinks + /> + )} <Styled.CreateButton to={lh.link("frontendMyReadingGroupsNew")} className="button-tertiary" diff --git a/client/src/frontend/containers/PublicReadingGroups/Wrapper.js b/client/src/frontend/containers/PublicReadingGroups/Wrapper.js index 98e986894e..bd5d4f3fc1 100644 --- a/client/src/frontend/containers/PublicReadingGroups/Wrapper.js +++ b/client/src/frontend/containers/PublicReadingGroups/Wrapper.js @@ -1,8 +1,23 @@ import React from "react"; import PropTypes from "prop-types"; import { childRoutes } from "helpers/router"; +import { useFromStore } from "hooks"; +import { useHistory, useLocation } from "react-router-dom"; +import lh from "helpers/linkHandler"; function PublicReadingGroupsContainer({ route }) { + const { + attributes: { + general: { disablePublicReadingGroups } + } + } = useFromStore("settings", "select"); + + const history = useHistory(); + const { pathname } = useLocation(); + + if (disablePublicReadingGroups && pathname === "/groups") + history.push(lh.link("frontendMyReadingGroups")); + return childRoutes(route); } diff --git a/client/src/helpers/router/navigation.js b/client/src/helpers/router/navigation.js index 05a4f4006b..05b2a723f8 100644 --- a/client/src/helpers/router/navigation.js +++ b/client/src/helpers/router/navigation.js @@ -3,9 +3,14 @@ import memoize from "lodash/memoize"; // Labels reference i18n keys in /shared/page-titles.json. class Navigation { - static frontend = memoize((authentication, settings) => { + static frontend(authentication, settings) { if (settings.attributes.general.libraryDisabled) return []; + const hideRGs = + settings.attributes.general.disableReadingGroups || + (!authentication.currentUser && + settings.attributes.general.disablePublicReadingGroups); + return [ { label: "titles.home", @@ -40,12 +45,12 @@ class Navigation { } ] }, - !settings.attributes.general.disableReadingGroups && { + !hideRGs && { label: "titles.groups", route: "frontendPublicReadingGroups" } ].filter(x => x); - }); + } static backend = memoize(() => { return [ From bb244f1b415c3ea0cef7e2e932be0863dc7df722 Mon Sep 17 00:00:00 2001 From: Lauren Davidson <32903719+1aurend@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:45:57 -0800 Subject: [PATCH 10/12] [C] Add established to user serializer --- api/app/serializers/v1/concerns/user_attributes.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/app/serializers/v1/concerns/user_attributes.rb b/api/app/serializers/v1/concerns/user_attributes.rb index 7a47f127f6..888c072989 100644 --- a/api/app/serializers/v1/concerns/user_attributes.rb +++ b/api/app/serializers/v1/concerns/user_attributes.rb @@ -35,6 +35,9 @@ module UserAttributes typed_attribute :email_confirmation_sent_at, Types::DateTime.optional.meta(read_only: true), private: true typed_attribute :email_confirmed_at, Types::DateTime.optional.meta(read_only: true), private: true typed_attribute :email_confirmed, Types::Bool.meta(read_only: true) + typed_attribute :established, Types::Bool.meta(read_only: true) do |object| + object.established? + end typed_attribute :nickname, Types::String typed_attribute :first_name, Types::String From 9b963152f45272a2702b9336236b075a89f92279 Mon Sep 17 00:00:00 2001 From: Lauren Davidson <32903719+1aurend@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:47:31 -0800 Subject: [PATCH 11/12] [F] Add not established warning to user drawer + edit profile --- .../components/layout/DrawerHeader/index.js | 5 ++-- .../components/layout/DrawerHeader/styles.js | 2 ++ .../__snapshots__/ManageProjects-test.js.snap | 2 +- client/src/backend/containers/users/Edit.js | 2 ++ .../__tests__/__snapshots__/Edit-test.js.snap | 2 +- .../locale/en-US/json/backend/records.json | 3 ++- .../app/locale/en-US/json/frontend/forms.json | 3 ++- .../EditProfileForm/Greeting/index.js | 27 ++++++++++++------- .../EditProfileForm/Greeting/styles.js | 6 +++++ .../sign-in-up/EditProfileForm/index.js | 2 +- 10 files changed, 38 insertions(+), 16 deletions(-) diff --git a/client/src/backend/components/layout/DrawerHeader/index.js b/client/src/backend/components/layout/DrawerHeader/index.js index 3b140b10c0..c9cbb652d9 100644 --- a/client/src/backend/components/layout/DrawerHeader/index.js +++ b/client/src/backend/components/layout/DrawerHeader/index.js @@ -15,7 +15,8 @@ export default class DrawerEntityHeader extends PureComponent { buttons: PropTypes.array, icon: PropTypes.string, buttonLayout: PropTypes.oneOf(["stack", "inline"]), - small: PropTypes.bool + small: PropTypes.bool, + instructionsAreWarning: PropTypes.bool }; static defaultProps = { @@ -50,7 +51,7 @@ export default class DrawerEntityHeader extends PureComponent { </Styled.TitleWrapper> )} {this.props.instructions && ( - <Styled.Instructions> + <Styled.Instructions $warning={this.props.instructionsAreWarning}> {this.props.instructions} </Styled.Instructions> )} diff --git a/client/src/backend/components/layout/DrawerHeader/styles.js b/client/src/backend/components/layout/DrawerHeader/styles.js index 514abfd0f5..f6fe56bd13 100644 --- a/client/src/backend/components/layout/DrawerHeader/styles.js +++ b/client/src/backend/components/layout/DrawerHeader/styles.js @@ -32,6 +32,8 @@ export const Instructions = styled.span` font-size: 17px; display: inline-block; margin-block-start: 12px; + + ${({ $warning }) => $warning && `color: var(--error-color);`} `; export const ButtonGroup = styled.div` diff --git a/client/src/backend/containers/project-collection/__tests__/__snapshots__/ManageProjects-test.js.snap b/client/src/backend/containers/project-collection/__tests__/__snapshots__/ManageProjects-test.js.snap index 3869bf1ec9..25db70ea6a 100644 --- a/client/src/backend/containers/project-collection/__tests__/__snapshots__/ManageProjects-test.js.snap +++ b/client/src/backend/containers/project-collection/__tests__/__snapshots__/ManageProjects-test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`backend/containers/project-collection/ManageProjects matches the snapshot when rendered 1`] = `"<div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\"></div><div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\"></div><header class=\\"css-ibt74g-Header ewz6knw5\\"><h2 class=\\" css-4532mb-TitleWrapper ewz6knw4\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-ijjefu-Icon ewz6knw3 svg-icon--BECollectionManual64\\" width=\\"44\\" height=\\"44\\" viewBox=\\"0 0 64 64\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M33.0042,49.9968 L31.0042,49.9968 L31.0042,37.6356 L33.0042,37.6356 L33.0042,49.9968 Z M38.1758,42.8162 L38.1758,44.8162 L25.8326,44.8162 L25.8326,42.8162 L38.1758,42.8162 Z M9.99999997,46.833 L15.017,46.833 L15.017,48.833 L7.99999996,48.833 L7.99999996,19.723 L56,19.723 L56,48.833 L48.983,48.833 L48.983,46.833 L54,46.833 L54,21.723 L9.99999997,21.723 L9.99999997,46.833 Z M13.0112,15.6977 L13.0112,13.6977 L50.9888,13.6977 L50.9888,15.6977 L13.0112,15.6977 Z M17.0224,9.67219997 L17.0224,7.67219996 L46.9776,7.67219996 L46.9776,9.67219997 L17.0224,9.67219997 Z M31.878,56.3278 C24.976932,56.3278 19.3831,50.7258412 19.3831,43.8162 C19.3831,36.9065589 24.976932,31.3046 31.878,31.3046 C38.779068,31.3046 44.3729,36.9065589 44.3729,43.8162 C44.3729,50.7258412 38.779068,56.3278 31.878,56.3278 Z M31.878,54.3278 C37.673848,54.3278 42.3729,49.6219211 42.3729,43.8162 C42.3729,38.0104789 37.673848,33.3046 31.878,33.3046 C26.082152,33.3046 21.3831,38.0104789 21.3831,43.8162 C21.3831,49.6219211 26.082152,54.3278 31.878,54.3278 Z\\"></path></svg><span class=\\"css-183oh9-Title ewz6knw2\\">A Project Collection</span></h2><span class=\\"css-16muib0-Instructions ewz6knw1\\">project_collections.manage_projects_instructions</span></header><div id=\\"entities-list-1\\" class=\\"entity-list\\"><div class=\\"entity-list__contents-wrapper\\"><div class=\\"entity-list__search entity-list-search\\"><form role=\\"search\\"><div class=\\"entity-list-search__keyword-row\\"><button class=\\"entity-list-search__search-button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--search16\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M7.00000003,3 C4.79086102,3 3.00000001,4.79086101 3.00000001,7.00000002 C3.00000001,9.20913903 4.79086102,11 7.00000003,11 C9.20912238,11 10.999973,9.20916605 11.0000001,7.00005001 C10.9995314,4.79106172 9.20889466,3.00044165 7.00000003,3 Z M10.8715096,10.1643028 L13.9971535,13.2899467 L13.2900467,13.9970535 L10.1644164,10.8714232 C9.30247365,11.5768009 8.20065857,12.0000001 7.00000003,12.0000001 C4.23857626,12.0000001 2,9.76142379 2,7.00000002 C2,4.23857625 4.23857626,1.99999999 7.0001,2 C9.76123985,2.00055206 11.999448,4.23876021 12.0000001,6.99990005 C12.0000001,8.2005577 11.5768322,9.30236028 10.8715096,10.1643028 Z\\"></path></svg><span class=\\"screen-reader-text\\">search.title</span></button><div class=\\"entity-list-search__keyword-input-wrapper\\"><label for=\\"1\\" class=\\"screen-reader-text\\">search.instructions</label><input class=\\"entity-list-search__keyword-input\\" id=\\"1\\" type=\\"text\\" placeholder=\\"Search...\\" value=\\"\\"></div><button type=\\"reset\\" class=\\"entity-list-search__text-button\\">actions.reset</button><button class=\\"entity-list-search__text-button entity-list-search__text-button--foregrounded\\" type=\\"button\\" aria-controls=\\"1-content\\" id=\\"1-label\\">glossary.option_title_case_other</button></div></form><div id=\\"1-content\\" role=\\"region\\" aria-labelledby=\\"1-label\\" class=\\"collapse__content collapse__content--hidden\\"><div><div class=\\"entity-list-search__options entity-list-search__options--horizontal\\"><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-11kticw-SelectLabel e1pyro013\\">filters.labels.filter_results</div><div class=\\"rel\\"><label for=\\"1-filter-0\\" class=\\"screen-reader-text\\">filters.labels.by_draft</label><select id=\\"1-filter-0\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"\\">All projects</option><option value=\\"true\\">Only draft projects</option><option value=\\"false\\">Only published projects</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-7zxv8a-SelectLabel-EmptySelectLabel e1pyro012\\"> </div><div class=\\"rel\\"><label for=\\"1-filter-1\\" class=\\"screen-reader-text\\"></label><select id=\\"1-filter-1\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"\\">Created by anyone</option><option value=\\"true\\">Created by me</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-11kticw-SelectLabel e1pyro013\\">filters.labels.order_results</div><div class=\\"rel\\"><label for=\\"1-order\\" class=\\"screen-reader-text\\">filters.labels.order_results</label><select id=\\"1-order\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"updated_at ASC\\">Most recently updated</option><option value=\\"sort_title ASC\\">Alphabetical by title</option><option value=\\"created_at DESC\\">Newest projects first</option><option value=\\"created_at ASC\\">Oldest projects first</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div></div></div></div></div><div class=\\"entity-list__header\\"><div class=\\"entity-list__count\\"><p class=\\"list-total\\" aria-hidden=\\"true\\">project_collections.added_count</p><div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\">project_collections.added_count</div></div></div><ul class=\\"entity-list__list entity-list__list--grid\\" aria-describedby=\\"entities-list-instructions-1\\"><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li></ul><div class=\\"entity-list__pagination\\"><nav aria-label=\\"pagination.aria_label\\" class=\\"css-16wbyh7-Nav e1c2l6mw5\\"><ul class=\\"css-j3ryim-Columns e1c2l6mw4\\"><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><a aria-disabled=\\"true\\" href=\\"#\\" aria-label=\\"pagination.previous_page\\" class=\\"css-1309b1i-Link e1c2l6mw1\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--arrowLongLeft16\\" viewBox=\\"0 0 24 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M19.9210555,8.50098377 L2,8.50098377 L2,7.49848233 L19.9183709,7.49848233 L14.6653905,2.76142495 L15.3486096,2 L22.0012062,7.99920999 L15.3651285,14 L14.6808716,13.2395122 L19.9210555,8.50098377 Z\\" transform=\\"rotate(180 12 8)\\"></path></svg><span>pagination.previous_short</span></a></li><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><div class=\\"css-156fd12-Pages e1c2l6mw2\\"><a href=\\"#\\" aria-current=\\"page\\" aria-label=\\"pagination.page_number\\" class=\\"css-1309b1i-Link e1c2l6mw1\\">1</a><a href=\\"#\\" aria-label=\\"pagination.page_number\\" class=\\"css-1309b1i-Link e1c2l6mw1\\">2</a></div></li><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><a aria-disabled=\\"false\\" href=\\"#\\" aria-label=\\"pagination.next_page\\" class=\\"css-1309b1i-Link e1c2l6mw1\\"><span>pagination.next</span><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--arrowLongRight16\\" viewBox=\\"0 0 24 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M19.9210555,8.50098377 L2,8.50098377 L2,7.49848233 L19.9183709,7.49848233 L14.6653905,2.76142495 L15.3486096,2 L22.0012062,7.99920999 L15.3651285,14 L14.6808716,13.2395122 L19.9210555,8.50098377 Z\\"></path></svg></a></li></ul></nav></div></div></div><div class=\\"actions\\"><button class=\\"button-icon-secondary button-icon-secondary--full button-icon-secondary--centered button-icon-secondary--smallcaps\\"><span>actions.close</span><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg button-icon-secondary__icon button-icon-secondary__icon--right button-icon-secondary__icon--short svg-icon--close16\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M2.00050275,2.70620953 L2.70769718,1.9991904 L13.9994973,13.2937905 L13.2923028,14.0008096 L2.00050275,2.70620953 Z M2.70769718,14.0008096 L2.00050275,13.2937905 L13.2923028,1.9991904 L13.9994973,2.70620953 L2.70769718,14.0008096 Z\\"></path></svg></button></div>"`; +exports[`backend/containers/project-collection/ManageProjects matches the snapshot when rendered 1`] = `"<div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\"></div><div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\"></div><header class=\\"css-ibt74g-Header ewz6knw5\\"><h2 class=\\" css-4532mb-TitleWrapper ewz6knw4\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-ijjefu-Icon ewz6knw3 svg-icon--BECollectionManual64\\" width=\\"44\\" height=\\"44\\" viewBox=\\"0 0 64 64\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M33.0042,49.9968 L31.0042,49.9968 L31.0042,37.6356 L33.0042,37.6356 L33.0042,49.9968 Z M38.1758,42.8162 L38.1758,44.8162 L25.8326,44.8162 L25.8326,42.8162 L38.1758,42.8162 Z M9.99999997,46.833 L15.017,46.833 L15.017,48.833 L7.99999996,48.833 L7.99999996,19.723 L56,19.723 L56,48.833 L48.983,48.833 L48.983,46.833 L54,46.833 L54,21.723 L9.99999997,21.723 L9.99999997,46.833 Z M13.0112,15.6977 L13.0112,13.6977 L50.9888,13.6977 L50.9888,15.6977 L13.0112,15.6977 Z M17.0224,9.67219997 L17.0224,7.67219996 L46.9776,7.67219996 L46.9776,9.67219997 L17.0224,9.67219997 Z M31.878,56.3278 C24.976932,56.3278 19.3831,50.7258412 19.3831,43.8162 C19.3831,36.9065589 24.976932,31.3046 31.878,31.3046 C38.779068,31.3046 44.3729,36.9065589 44.3729,43.8162 C44.3729,50.7258412 38.779068,56.3278 31.878,56.3278 Z M31.878,54.3278 C37.673848,54.3278 42.3729,49.6219211 42.3729,43.8162 C42.3729,38.0104789 37.673848,33.3046 31.878,33.3046 C26.082152,33.3046 21.3831,38.0104789 21.3831,43.8162 C21.3831,49.6219211 26.082152,54.3278 31.878,54.3278 Z\\"></path></svg><span class=\\"css-183oh9-Title ewz6knw2\\">A Project Collection</span></h2><span class=\\"css-1xaw27z-Instructions ewz6knw1\\">project_collections.manage_projects_instructions</span></header><div id=\\"entities-list-1\\" class=\\"entity-list\\"><div class=\\"entity-list__contents-wrapper\\"><div class=\\"entity-list__search entity-list-search\\"><form role=\\"search\\"><div class=\\"entity-list-search__keyword-row\\"><button class=\\"entity-list-search__search-button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--search16\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M7.00000003,3 C4.79086102,3 3.00000001,4.79086101 3.00000001,7.00000002 C3.00000001,9.20913903 4.79086102,11 7.00000003,11 C9.20912238,11 10.999973,9.20916605 11.0000001,7.00005001 C10.9995314,4.79106172 9.20889466,3.00044165 7.00000003,3 Z M10.8715096,10.1643028 L13.9971535,13.2899467 L13.2900467,13.9970535 L10.1644164,10.8714232 C9.30247365,11.5768009 8.20065857,12.0000001 7.00000003,12.0000001 C4.23857626,12.0000001 2,9.76142379 2,7.00000002 C2,4.23857625 4.23857626,1.99999999 7.0001,2 C9.76123985,2.00055206 11.999448,4.23876021 12.0000001,6.99990005 C12.0000001,8.2005577 11.5768322,9.30236028 10.8715096,10.1643028 Z\\"></path></svg><span class=\\"screen-reader-text\\">search.title</span></button><div class=\\"entity-list-search__keyword-input-wrapper\\"><label for=\\"1\\" class=\\"screen-reader-text\\">search.instructions</label><input class=\\"entity-list-search__keyword-input\\" id=\\"1\\" type=\\"text\\" placeholder=\\"Search...\\" value=\\"\\"></div><button type=\\"reset\\" class=\\"entity-list-search__text-button\\">actions.reset</button><button class=\\"entity-list-search__text-button entity-list-search__text-button--foregrounded\\" type=\\"button\\" aria-controls=\\"1-content\\" id=\\"1-label\\">glossary.option_title_case_other</button></div></form><div id=\\"1-content\\" role=\\"region\\" aria-labelledby=\\"1-label\\" class=\\"collapse__content collapse__content--hidden\\"><div><div class=\\"entity-list-search__options entity-list-search__options--horizontal\\"><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-11kticw-SelectLabel e1pyro013\\">filters.labels.filter_results</div><div class=\\"rel\\"><label for=\\"1-filter-0\\" class=\\"screen-reader-text\\">filters.labels.by_draft</label><select id=\\"1-filter-0\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"\\">All projects</option><option value=\\"true\\">Only draft projects</option><option value=\\"false\\">Only published projects</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-7zxv8a-SelectLabel-EmptySelectLabel e1pyro012\\"> </div><div class=\\"rel\\"><label for=\\"1-filter-1\\" class=\\"screen-reader-text\\"></label><select id=\\"1-filter-1\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"\\">Created by anyone</option><option value=\\"true\\">Created by me</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div><div class=\\"entity-list-search__option entity-list-search__option--horizontal\\"><div><div class=\\"css-11kticw-SelectLabel e1pyro013\\">filters.labels.order_results</div><div class=\\"rel\\"><label for=\\"1-order\\" class=\\"screen-reader-text\\">filters.labels.order_results</label><select id=\\"1-order\\" class=\\"css-187xsyb-Select e1pyro011\\" tabindex=\\"-1\\"><option value=\\"updated_at ASC\\">Most recently updated</option><option value=\\"sort_title ASC\\">Alphabetical by title</option><option value=\\"created_at DESC\\">Newest projects first</option><option value=\\"created_at ASC\\">Oldest projects first</option></select><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg css-x7n23k-Icon e1pyro010 svg-icon--disclosureDown24\\" viewBox=\\"0 0 24 24\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><polygon points=\\"20.374 9 21 9.78 12 17 3 9.78 3.626 9 12 15.718\\"></polygon></svg></div></div></div></div></div></div></div><div class=\\"entity-list__header\\"><div class=\\"entity-list__count\\"><p class=\\"list-total\\" aria-hidden=\\"true\\">project_collections.added_count</p><div role=\\"status\\" aria-live=\\"polite\\" aria-atomic=\\"true\\" class=\\"screen-reader-text\\">project_collections.added_count</div></div></div><ul class=\\"entity-list__list entity-list__list--grid\\" aria-describedby=\\"entities-list-instructions-1\\"><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li><li class=\\"entity-row entity-list__entity\\"><div class=\\"entity-row__inner entity-row__inner--in-grid\\"><div class=\\"entity-row__figure entity-row__figure--size-normal entity-row__figure--shape-square entity-row__figure--in-grid\\"><figure class=\\"cover\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 134 134\\" width=\\"134\\" height=\\"134\\" class=\\"project-thumb-placeholder project-thumb-placeholder--desktop project-thumb-placeholder--primary\\" role=\\"img\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><g class=\\"project-thumb-placeholder__frame\\" stroke-width=\\"1.5\\" transform=\\"translate(2 2)\\"><polyline points=\\"124 6 124 124 6 124\\"></polyline><polyline points=\\"130 12 130 130 12 130\\"></polyline><polygon points=\\"0 118 118 118 118 0 0 0\\"></polygon></g><polygon class=\\"project-thumb-placeholder__tile\\" points=\\"0 102 102 102 102 0 0 0\\" transform=\\"translate(10 10)\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(34 32)\\"><g transform=\\"translate(47.557 2.968)\\"><polyline class=\\"project-thumb-placeholder__illustration\\" stroke-width=\\"1.5\\" points=\\".271 7.606 .271 50.893 3.747 57.089 7.221 50.893 7.221 7.606\\"></polyline><path stroke-width=\\"1.5\\" d=\\"M7.22166456 50.8926203L.271601266 50.8926203M.27135443 3.82883544L.27135443 2.02693671C.27135443.936746835 1.1558481.0522531646 2.24603797.0522531646L5.24673418.0522531646C6.33774684.0522531646 7.22141772.936746835 7.22141772 2.02693671L7.22141772 3.82883544\\"></path><polygon stroke-width=\\"1.5\\" points=\\".272 7.606 7.222 7.606 7.222 3.829 .272 3.829\\"></polygon><path stroke-width=\\"1.5\\" d=\\"M3.74655063,11.5277975 L3.74655063,46.9708987\\"></path><polygon points=\\"2.183 54.3 3.747 57.089 5.311 54.3\\"></polygon></g><g stroke-width=\\"1.5\\" transform=\\"translate(0 26)\\"><polygon points=\\"34.054 .598 .703 .598 .703 26.635 17.859 26.635 17.859 34.057 24.336 26.635 34.054 26.635\\"></polygon><path d=\\"M28.3519304 7.9074557L6.30870253 7.9074557M28.3519304 18.7725759L6.30870253 18.7725759M28.3519304 13.3399747L6.30870253 13.3399747\\"></path></g><g stroke-width=\\"1.5\\"><path d=\\"M27.3882848 11.8128038C27.3882848 14.7707152 24.9898671 17.1691329 22.0319557 17.1691329 19.0740443 17.1691329 16.6756266 14.7707152 16.6756266 11.8128038 16.6756266 8.85489241 19.0740443 6.45647468 22.0319557 6.45647468 24.9898671 6.45647468 27.3882848 8.85489241 27.3882848 11.8128038zM27.3882848 12.0765886L34.9537911 4.51108228C36.3196139 3.14608228 38.6546772 4.11285443 38.6546772 6.04475316M8.54099982 6.88421582L14.7943291.639221519C16.1601519-.726601266 18.4952152.240993671 18.4952152 2.17206962M11.4153165 12.3338734L12.5877848 11.8393797C13.52 11.4469114 14.5706962 11.4469114 15.5029114 11.8393797L16.6753797 12.3338734\\"></path><path d=\\"M11.4153165,11.8128038 C11.4153165,14.7707152 9.01689873,17.1691329 6.05898734,17.1691329 C3.10107595,17.1691329 0.702658228,14.7707152 0.702658228,11.8128038 C0.702658228,8.85489241 3.10107595,6.45647468 6.05898734,6.45647468 C9.01689873,6.45647468 11.4153165,8.85489241 11.4153165,11.8128038 Z\\"></path></g></g></g></svg><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 48 48\\" width=\\"100%\\" height=\\"100%\\" class=\\"project-thumb-placeholder project-thumb-placeholder--mobile project-thumb-placeholder--primary\\" aria-hidden=\\"true\\"><g fill=\\"none\\" fill-rule=\\"evenodd\\"><polygon class=\\"project-thumb-placeholder__tile\\" fill=\\"#CBF7E6\\" points=\\"0 42 42 42 42 0 0 0\\"></polygon><g class=\\"project-thumb-placeholder__illustration\\" transform=\\"translate(9 8)\\"><g transform=\\"translate(20.683 2.244)\\"><polygon points=\\".161 2.954 .161 21.013 1.612 23.598 3.06 21.013 3.06 2.954\\"></polygon><path d=\\"M3.06081501,21.012797 L0.161633021,21.012797\\"></path><path stroke-linejoin=\\"round\\" d=\\"M0.162011257,2.95560225 L3.06119325,2.95560225 L3.06119325,1.53299812 C3.06119325,0.980713374 2.613478,0.532998124 2.06119325,0.532998124 L1.16201126,0.532998124 C0.609726507,0.532998124 0.162011257,0.980713374 0.162011257,1.53299812 L0.162011257,2.95560225 Z\\"></path></g><g transform=\\"translate(.195 11.463)\\"><polygon points=\\"14.898 .419 .985 .419 .985 11.282 8.141 11.282 8.141 14.378 10.844 11.282 14.898 11.282\\"></polygon><path d=\\"M12.5237854 3.46905816L3.32696735 3.46905816M12.5237854 8.00165403L3.32696735 8.00165403M12.5237854 5.73507242L3.32696735 5.73507242\\"></path></g><g transform=\\"translate(.195 .195)\\"><path d=\\"M11.8853223 5.12585966C11.8853223 6.3608015 10.8848871 7.36123677 9.64994522 7.36123677 8.41689456 7.36123677 7.41645929 6.3608015 7.41645929 5.12585966 7.41645929 3.89091782 8.41689456 2.89048255 9.64994522 2.89048255 10.8848871 2.89048255 11.8853223 3.89091782 11.8853223 5.12585966zM11.8853223 5.23573734L15.0417051 2.0793546C15.6109508 1.51010882 16.5849096 1.91293058 16.5849096 2.71857411M3.99814784 3.09718874L6.63067317.464663415C7.20181013-.106473546 8.17576886.2982394 8.17576886 1.10388293\\"></path><path d=\\"M5.22155347 5.12585966C5.22155347 6.3608015 4.2211182 7.36123677 2.98617636 7.36123677 1.7531257 7.36123677.752690432 6.3608015.752690432 5.12585966.752690432 3.89091782 1.7531257 2.89048255 2.98617636 2.89048255 4.2211182 2.89048255 5.22155347 3.89091782 5.22155347 5.12585966zM5.22155347 5.34334559L5.71136961 5.13720675C6.09906191 4.97267392 6.53781614 4.97267392 6.92739962 5.13720675L7.41532458 5.34334559\\"></path></g></g><g class=\\"project-thumb-placeholder__frame\\" stroke=\\"#828282\\"><polyline points=\\"48 6 48 48 6 48\\"></polyline><polyline points=\\"45 3 45 45 3 45\\"></polyline><polygon points=\\"0 42 42 42 42 0 0 0\\"></polygon></g></g></svg><button class=\\"sr-collecting-toggle screen-reader-text\\">project_collections.include_title</button><button class=\\"collecting-toggle collecting-toggle--small-project-cover\\" aria-hidden=\\"true\\" tabindex=\\"-1\\"><div class=\\"collecting-toggle__inner collecting-toggle__inner--add\\" aria-hidden=\\"true\\"><div class=\\"collecting-toggle__icons\\"><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--remove svg-icon--MinusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--confirm svg-icon--CheckUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><polygon points=\\"14.314 23.462 7.317 16.909 8.684 15.449 14.118 20.538 23.225 9.368 24.775 10.632 14.314 23.462\\"></polygon></svg><svg class=\\"manicon-svg collecting-toggle__icon collecting-toggle__icon--add svg-icon--PlusUnique\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"28\\" height=\\"28\\" fill=\\"currentColor\\" viewBox=\\"0 0 32 32\\" aria-hidden=\\"true\\"><rect x=\\"9\\" y=\\"15\\" width=\\"14\\" height=\\"2\\"></rect><rect x=\\"15\\" y=\\"9\\" width=\\"2\\" height=\\"14\\"></rect></svg></div><div><span class=\\"collecting-toggle__text\\"></span></div></div></button></figure></div><div class=\\"entity-row__text entity-row__text--in-grid\\"><h3 class=\\"entity-row__title entity-row__title--in-grid\\"><span class=\\"entity-row__title-inner\\"><span>Rowan Test</span></span><span id=\\"1-describedby\\" class=\\"screen-reader-text\\">actions.view_item</span></h3><h4 class=\\"entity-row__subtitle entity-row__subtitle--in-grid\\"><span></span></h4><div class=\\"entity-row__meta entity-row__meta--in-grid\\"></div></div></div></li></ul><div class=\\"entity-list__pagination\\"><nav aria-label=\\"pagination.aria_label\\" class=\\"css-16wbyh7-Nav e1c2l6mw5\\"><ul class=\\"css-j3ryim-Columns e1c2l6mw4\\"><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><a aria-disabled=\\"true\\" href=\\"#\\" aria-label=\\"pagination.previous_page\\" class=\\"css-1309b1i-Link e1c2l6mw1\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--arrowLongLeft16\\" viewBox=\\"0 0 24 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M19.9210555,8.50098377 L2,8.50098377 L2,7.49848233 L19.9183709,7.49848233 L14.6653905,2.76142495 L15.3486096,2 L22.0012062,7.99920999 L15.3651285,14 L14.6808716,13.2395122 L19.9210555,8.50098377 Z\\" transform=\\"rotate(180 12 8)\\"></path></svg><span>pagination.previous_short</span></a></li><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><div class=\\"css-156fd12-Pages e1c2l6mw2\\"><a href=\\"#\\" aria-current=\\"page\\" aria-label=\\"pagination.page_number\\" class=\\"css-1309b1i-Link e1c2l6mw1\\">1</a><a href=\\"#\\" aria-label=\\"pagination.page_number\\" class=\\"css-1309b1i-Link e1c2l6mw1\\">2</a></div></li><li class=\\"css-ovcui1-Column e1c2l6mw3\\"><a aria-disabled=\\"false\\" href=\\"#\\" aria-label=\\"pagination.next_page\\" class=\\"css-1309b1i-Link e1c2l6mw1\\"><span>pagination.next</span><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg svg-icon--arrowLongRight16\\" viewBox=\\"0 0 24 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M19.9210555,8.50098377 L2,8.50098377 L2,7.49848233 L19.9183709,7.49848233 L14.6653905,2.76142495 L15.3486096,2 L22.0012062,7.99920999 L15.3651285,14 L14.6808716,13.2395122 L19.9210555,8.50098377 Z\\"></path></svg></a></li></ul></nav></div></div></div><div class=\\"actions\\"><button class=\\"button-icon-secondary button-icon-secondary--full button-icon-secondary--centered button-icon-secondary--smallcaps\\"><span>actions.close</span><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg button-icon-secondary__icon button-icon-secondary__icon--right button-icon-secondary__icon--short svg-icon--close16\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M2.00050275,2.70620953 L2.70769718,1.9991904 L13.9994973,13.2937905 L13.2923028,14.0008096 L2.00050275,2.70620953 Z M2.70769718,14.0008096 L2.00050275,13.2937905 L13.2923028,1.9991904 L13.9994973,2.70620953 L2.70769718,14.0008096 Z\\"></path></svg></button></div>"`; diff --git a/client/src/backend/containers/users/Edit.js b/client/src/backend/containers/users/Edit.js index 20c9faff42..3858078969 100644 --- a/client/src/backend/containers/users/Edit.js +++ b/client/src/backend/containers/users/Edit.js @@ -162,6 +162,8 @@ export class UsersEditContainer extends PureComponent { className: "utility-button__icon--notice" } ]} + instructions={t("records.users.not_established_warning")} + instructionsAreWarning /> <section> diff --git a/client/src/backend/containers/users/__tests__/__snapshots__/Edit-test.js.snap b/client/src/backend/containers/users/__tests__/__snapshots__/Edit-test.js.snap index adbda84f01..1436dc3762 100644 --- a/client/src/backend/containers/users/__tests__/__snapshots__/Edit-test.js.snap +++ b/client/src/backend/containers/users/__tests__/__snapshots__/Edit-test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`backend/containers/users/Edit matches the snapshot when rendered 1`] = `"<header class=\\"css-ibt74g-Header ewz6knw5\\"><h2 class=\\" css-4532mb-TitleWrapper ewz6knw4\\"><span class=\\"css-183oh9-Title ewz6knw2\\">Rowan Ida</span></h2><div class=\\"utility-button-group utility-button-group--inline css-hnxvlr-ButtonGroup ewz6knw0\\"><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon svg-icon--key32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M7.89239997,19.4438 C5.25098769,19.4438 3.10969996,17.3025123 3.10969996,14.6611 C3.10969996,12.0196877 5.25098769,9.87839998 7.89239997,9.87839998 C10.5338123,9.87839998 12.6751,12.0196877 12.6751,14.6611 C12.6751,17.3025123 10.5338123,19.4438 7.89239997,19.4438 Z M7.89239997,18.4438 C9.9815275,18.4438 11.6751,16.7502275 11.6751,14.6611 C11.6751,12.5719725 9.9815275,10.8784 7.89239997,10.8784 C5.80327244,10.8784 4.10969996,12.5719725 4.10969996,14.6611 C4.10969996,16.7502275 5.80327244,18.4438 7.89239997,18.4438 Z M12.1751,15.1611 L12.1751,14.1611 L28.8902,14.1611 L28.8902,15.1611 L12.1751,15.1611 Z M25.0494,14.6611 L26.0494,14.6611 L26.0494,22.1216 L25.0494,22.1216 L25.0494,14.6611 Z M20.718,14.6611 L21.718,14.6611 L21.718,22.1216 L20.718,22.1216 L20.718,14.6611 Z\\"></path></svg><span class=\\"utility-button__text\\">records.users.reset_password</span></button><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon svg-icon--mail32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path fill-rule=\\"evenodd\\" d=\\"M5,23 L27,23 L27,9 L5,9 L5,23 Z M6.818,10 L25.182,10 L16,18.135 L6.818,10 Z M26,10.611 L26,22 L6,22 L6,10.611 L16,19.472 L26,10.611 Z\\"></path></svg><span class=\\"utility-button__text\\">records.users.unsubscribe</span></button><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon--notice utility-button__icon svg-icon--delete32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M22.5006476,10.9603607 L23.4993524,11.0112392 L22.8010808,24.6917831 C22.8222428,25.6607758 22.05432,26.4636781 21.074,26.4858 L10.9146542,26.4856713 C9.94567997,26.4636781 9.17775722,25.6607758 9.19944755,24.7281393 L8.50064755,11.0112392 L9.49935241,10.9603607 L10.1986808,24.713617 C10.1895701,25.1307915 10.5201792,25.4764602 10.926,25.4858 L21.0626542,25.4859288 C21.4798208,25.4764602 21.8104299,25.1307915 21.8018476,24.6772608 L22.5006476,10.9603607 Z M5.99999997,11.4266 L5.99999997,10.4266 L26,10.4266 L26,11.4266 L5.99999997,11.4266 Z M12.75,10.9266 L11.75,10.9266 L11.7501673,6.81953297 C11.732004,6.11756111 12.2857775,5.53349733 13.0014,5.51409997 L19.0122785,5.5142871 C19.7142225,5.53349733 20.267996,6.11756111 20.25,6.80659997 L20.25,10.9266 L19.25,10.9266 L19.2501673,6.79366698 C19.2540602,6.64321292 19.1353696,6.51803017 18.9986,6.51409997 L13.0150785,6.51391283 C12.8646304,6.51803017 12.7459398,6.64321292 12.75,6.80659997 L12.75,10.9266 Z M13.5,14.7217 L14.5,14.7217 L14.5,22.25 L13.5,22.25 L13.5,14.7217 Z M17.5,14.7217 L18.5,14.7217 L18.5,22.25 L17.5,22.25 L17.5,14.7217 Z\\"></path></svg><span class=\\"utility-button__text\\">actions.delete</span></button></div></header><section></section>"`; +exports[`backend/containers/users/Edit matches the snapshot when rendered 1`] = `"<header class=\\"css-ibt74g-Header ewz6knw5\\"><h2 class=\\" css-4532mb-TitleWrapper ewz6knw4\\"><span class=\\"css-183oh9-Title ewz6knw2\\">Rowan Ida</span></h2><span class=\\"css-ic2dd4-Instructions ewz6knw1\\">records.users.not_established_warning</span><div class=\\"utility-button-group utility-button-group--inline css-hnxvlr-ButtonGroup ewz6knw0\\"><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon svg-icon--key32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M7.89239997,19.4438 C5.25098769,19.4438 3.10969996,17.3025123 3.10969996,14.6611 C3.10969996,12.0196877 5.25098769,9.87839998 7.89239997,9.87839998 C10.5338123,9.87839998 12.6751,12.0196877 12.6751,14.6611 C12.6751,17.3025123 10.5338123,19.4438 7.89239997,19.4438 Z M7.89239997,18.4438 C9.9815275,18.4438 11.6751,16.7502275 11.6751,14.6611 C11.6751,12.5719725 9.9815275,10.8784 7.89239997,10.8784 C5.80327244,10.8784 4.10969996,12.5719725 4.10969996,14.6611 C4.10969996,16.7502275 5.80327244,18.4438 7.89239997,18.4438 Z M12.1751,15.1611 L12.1751,14.1611 L28.8902,14.1611 L28.8902,15.1611 L12.1751,15.1611 Z M25.0494,14.6611 L26.0494,14.6611 L26.0494,22.1216 L25.0494,22.1216 L25.0494,14.6611 Z M20.718,14.6611 L21.718,14.6611 L21.718,22.1216 L20.718,22.1216 L20.718,14.6611 Z\\"></path></svg><span class=\\"utility-button__text\\">records.users.reset_password</span></button><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon svg-icon--mail32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path fill-rule=\\"evenodd\\" d=\\"M5,23 L27,23 L27,9 L5,9 L5,23 Z M6.818,10 L25.182,10 L16,18.135 L6.818,10 Z M26,10.611 L26,22 L6,22 L6,10.611 L16,19.472 L26,10.611 Z\\"></path></svg><span class=\\"utility-button__text\\">records.users.unsubscribe</span></button><button class=\\"utility-button\\" type=\\"button\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"manicon-svg utility-button__icon--notice utility-button__icon svg-icon--delete32\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 32 32\\" fill=\\"currentColor\\" aria-hidden=\\"true\\"><path d=\\"M22.5006476,10.9603607 L23.4993524,11.0112392 L22.8010808,24.6917831 C22.8222428,25.6607758 22.05432,26.4636781 21.074,26.4858 L10.9146542,26.4856713 C9.94567997,26.4636781 9.17775722,25.6607758 9.19944755,24.7281393 L8.50064755,11.0112392 L9.49935241,10.9603607 L10.1986808,24.713617 C10.1895701,25.1307915 10.5201792,25.4764602 10.926,25.4858 L21.0626542,25.4859288 C21.4798208,25.4764602 21.8104299,25.1307915 21.8018476,24.6772608 L22.5006476,10.9603607 Z M5.99999997,11.4266 L5.99999997,10.4266 L26,10.4266 L26,11.4266 L5.99999997,11.4266 Z M12.75,10.9266 L11.75,10.9266 L11.7501673,6.81953297 C11.732004,6.11756111 12.2857775,5.53349733 13.0014,5.51409997 L19.0122785,5.5142871 C19.7142225,5.53349733 20.267996,6.11756111 20.25,6.80659997 L20.25,10.9266 L19.25,10.9266 L19.2501673,6.79366698 C19.2540602,6.64321292 19.1353696,6.51803017 18.9986,6.51409997 L13.0150785,6.51391283 C12.8646304,6.51803017 12.7459398,6.64321292 12.75,6.80659997 L12.75,10.9266 Z M13.5,14.7217 L14.5,14.7217 L14.5,22.25 L13.5,22.25 L13.5,14.7217 Z M17.5,14.7217 L18.5,14.7217 L18.5,22.25 L17.5,22.25 L17.5,14.7217 Z\\"></path></svg><span class=\\"utility-button__text\\">actions.delete</span></button></div></header><section></section>"`; diff --git a/client/src/config/app/locale/en-US/json/backend/records.json b/client/src/config/app/locale/en-US/json/backend/records.json index c220664b09..15013a2029 100644 --- a/client/src/config/app/locale/en-US/json/backend/records.json +++ b/client/src/config/app/locale/en-US/json/backend/records.json @@ -31,7 +31,8 @@ "last_name": "$t(records.makers.last_name)", "submit_label": "Save User", "unsubscribe": "Unsubscribe", - "reset_password": "Reset Password" + "reset_password": "Reset Password", + "not_established_warning": "This user has not yet verified their email and will not be able to engage with public projects and reading groups." }, "pages": { "header": "Manage Pages", diff --git a/client/src/config/app/locale/en-US/json/frontend/forms.json b/client/src/config/app/locale/en-US/json/frontend/forms.json index 062ea64bc2..206ebcb40b 100644 --- a/client/src/config/app/locale/en-US/json/frontend/forms.json +++ b/client/src/config/app/locale/en-US/json/frontend/forms.json @@ -186,7 +186,8 @@ "update_notification_header": "Profile updated successfully!", "terms_header": "First things first...", "accept_checkbox_label": "I agree to {{installationName}}'s <privacyLink>privacy policy</privacyLink> and <termsLink>terms and conditions.</termsLink>", - "accept_checkbox_label_truncated": "I agree to {{installationName}}'s <termsLink>terms and conditions.</termsLink>" + "accept_checkbox_label_truncated": "I agree to {{installationName}}'s <termsLink>terms and conditions.</termsLink>", + "not_verified_warning": "It looks like you haven't verified your email. You will not be able to engage with projects or reading groups on Manifold until you do so." }, "attribute_map": { "cancel": "Cancel mapping of {{name}} to {{mapping}}", diff --git a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js index 93f9031556..fbef5da06e 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js @@ -1,13 +1,15 @@ import React, { useContext } from "react"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import PropTypes from "prop-types"; import { FormContext } from "helpers/contexts"; import * as Styled from "./styles"; -export default function ProfileGreeting({ mode }) { +export default function ProfileGreeting({ mode, warn }) { const formData = useContext(FormContext); const nickname = formData.getModelValue("attributes[nickname]"); + const { t } = useTranslation(); + return mode === "new" ? ( <Trans i18nKey="forms.signin_overlay.create_success_message" @@ -19,13 +21,20 @@ export default function ProfileGreeting({ mode }) { values={{ name: nickname }} /> ) : ( - <Styled.Heading> - <Trans - i18nKey="forms.signin_overlay.greeting" - components={[<Styled.Nickname />]} - values={{ name: nickname }} - /> - </Styled.Heading> + <> + <Styled.Heading> + <Trans + i18nKey="forms.signin_overlay.greeting" + components={[<Styled.Nickname />]} + values={{ name: nickname }} + /> + </Styled.Heading> + {warn && ( + <Styled.NotVerifiedWarning> + {t("forms.signin_overlay.not_verified_warning")} + </Styled.NotVerifiedWarning> + )} + </> ); } diff --git a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js index 4477e8f0cd..b5e2cd0130 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js @@ -15,3 +15,9 @@ export const Heading = styled.h4` ${headingPrimary} margin-block-end: 25px; `; + +export const NotVerifiedWarning = styled.span` + display block; + margin-block-end: 25px; + color: var(--error-color); +`; diff --git a/client/src/global/components/sign-in-up/EditProfileForm/index.js b/client/src/global/components/sign-in-up/EditProfileForm/index.js index e6836e661b..965a15cc1c 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/index.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/index.js @@ -81,7 +81,7 @@ export default function EditProfileForm({ hideOverlay, mode }) { formatData={formatAttributes} update={updateUser} > - <Greeting mode={mode} /> + <Greeting mode={mode} warn={!currentUser.attributes.established} /> <h2 className="screen-reader-text"> {t("forms.signin_overlay.update_sr_title")} </h2> From 4c8be12435480a64b103c0a56f0bb7f6e70e20f5 Mon Sep 17 00:00:00 2001 From: Lauren Davidson <32903719+1aurend@users.noreply.github.com> Date: Thu, 22 Feb 2024 14:50:13 -0800 Subject: [PATCH 12/12] [F] Add resend verification email button --- client/src/api/index.js | 1 + .../src/api/resources/emailConfirmations.js | 9 ++++ .../app/locale/en-US/json/frontend/forms.json | 5 +- .../EditProfileForm/Greeting/index.js | 16 ++++-- .../EditProfileForm/Greeting/styles.js | 10 +++- .../ResendEmailConfirm/index.js | 49 +++++++++++++++++++ .../sign-in-up/EditProfileForm/index.js | 7 ++- 7 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 client/src/api/resources/emailConfirmations.js create mode 100644 client/src/global/components/sign-in-up/EditProfileForm/ResendEmailConfirm/index.js diff --git a/client/src/api/index.js b/client/src/api/index.js index c2b37c5597..9d51d56f82 100644 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -49,3 +49,4 @@ export journalsAPI from "./resources/journals"; export journalIssuesAPI from "./resources/journalIssues"; export journalVolumesAPI from "./resources/journalVolumes"; export ingestionSourcesAPI from "./resources/ingestionSources"; +export emailConfirmationsAPI from "./resources/emailConfirmations"; diff --git a/client/src/api/resources/emailConfirmations.js b/client/src/api/resources/emailConfirmations.js new file mode 100644 index 0000000000..57514e398f --- /dev/null +++ b/client/src/api/resources/emailConfirmations.js @@ -0,0 +1,9 @@ +export default { + update(id) { + return { + endpoint: `/api/v1/email_confirmations/${id}`, + method: "PUT", + options: {} + }; + } +}; diff --git a/client/src/config/app/locale/en-US/json/frontend/forms.json b/client/src/config/app/locale/en-US/json/frontend/forms.json index 206ebcb40b..3cf49b528f 100644 --- a/client/src/config/app/locale/en-US/json/frontend/forms.json +++ b/client/src/config/app/locale/en-US/json/frontend/forms.json @@ -187,7 +187,10 @@ "terms_header": "First things first...", "accept_checkbox_label": "I agree to {{installationName}}'s <privacyLink>privacy policy</privacyLink> and <termsLink>terms and conditions.</termsLink>", "accept_checkbox_label_truncated": "I agree to {{installationName}}'s <termsLink>terms and conditions.</termsLink>", - "not_verified_warning": "It looks like you haven't verified your email. You will not be able to engage with projects or reading groups on Manifold until you do so." + "not_verified_warning": "It looks like you haven't verified your email. You will not be able to engage with projects or reading groups on Manifold until you do so.", + "resend_verification": "Resend Verification Email", + "email_success_notification": "Verification email sent!", + "email_failure_notiication": "Verification email could not be sent. Please try again." }, "attribute_map": { "cancel": "Cancel mapping of {{name}} to {{mapping}}", diff --git a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js index fbef5da06e..e6d89db9e6 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/index.js @@ -2,9 +2,10 @@ import React, { useContext } from "react"; import { Trans, useTranslation } from "react-i18next"; import PropTypes from "prop-types"; import { FormContext } from "helpers/contexts"; +import ResendEmailConfirm from "../ResendEmailConfirm"; import * as Styled from "./styles"; -export default function ProfileGreeting({ mode, warn }) { +export default function ProfileGreeting({ mode, warn, userId, hideOverlay }) { const formData = useContext(FormContext); const nickname = formData.getModelValue("attributes[nickname]"); @@ -30,9 +31,12 @@ export default function ProfileGreeting({ mode, warn }) { /> </Styled.Heading> {warn && ( - <Styled.NotVerifiedWarning> - {t("forms.signin_overlay.not_verified_warning")} - </Styled.NotVerifiedWarning> + <Styled.NotVerifiedWrapper> + <Styled.NotVerifiedWarning> + {t("forms.signin_overlay.not_verified_warning")} + </Styled.NotVerifiedWarning> + <ResendEmailConfirm id={userId} hideOverlay={hideOverlay} /> + </Styled.NotVerifiedWrapper> )} </> ); @@ -42,5 +46,7 @@ ProfileGreeting.displayName = "Global.SignInUp.EditProfileForm.Greeting"; ProfileGreeting.propTypes = { mode: PropTypes.oneOf(["new", "existing"]), - nickname: PropTypes.string + userId: PropTypes.string, + hideOverlay: PropTypes.func, + warn: PropTypes.bool }; diff --git a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js index b5e2cd0130..4c8832e984 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/Greeting/styles.js @@ -18,6 +18,14 @@ export const Heading = styled.h4` export const NotVerifiedWarning = styled.span` display block; - margin-block-end: 25px; color: var(--error-color); `; + +export const NotVerifiedWrapper = styled.div` + margin-block-end: 60px; + + > * + * { + margin-block-start: 20px; + width: 100%; + } +`; diff --git a/client/src/global/components/sign-in-up/EditProfileForm/ResendEmailConfirm/index.js b/client/src/global/components/sign-in-up/EditProfileForm/ResendEmailConfirm/index.js new file mode 100644 index 0000000000..de62669b72 --- /dev/null +++ b/client/src/global/components/sign-in-up/EditProfileForm/ResendEmailConfirm/index.js @@ -0,0 +1,49 @@ +import React, { useCallback } from "react"; +import PropTypes from "prop-types"; +import { useApiCallback, useNotification } from "hooks"; +import { emailConfirmationsAPI } from "api"; +import { useTranslation } from "react-i18next"; + +export default function ResendEmailConfirmation({ id, hideOverlay }) { + const { t } = useTranslation(); + const triggerConfirm = useApiCallback(emailConfirmationsAPI?.update); + + const notifyEmailSent = useNotification(() => ({ + level: 0, + id: `CURRENT_USER_VERIFICATION_EMAIL_SENT`, + heading: t("forms.signin_overlay.email_success_notification"), + expiration: 3000 + })); + + const onClick = useCallback( + async e => { + e.preventDefault(); + + try { + await triggerConfirm(id); + notifyEmailSent(); + if (hideOverlay) hideOverlay(); + } catch (err) { + if (hideOverlay) hideOverlay(); + } + }, + [id, triggerConfirm, notifyEmailSent, hideOverlay] + ); + + return ( + <button + className="button-secondary button-secondary--outlined button-secondary--color-white" + onClick={onClick} + > + {t("forms.signin_overlay.resend_verification")} + </button> + ); +} + +ResendEmailConfirmation.displayName = + "Global.SignInUp.EditProfileForm.ResendEmailConfirm"; + +ResendEmailConfirmation.propTypes = { + id: PropTypes.string, + hideOverlay: PropTypes.func +}; diff --git a/client/src/global/components/sign-in-up/EditProfileForm/index.js b/client/src/global/components/sign-in-up/EditProfileForm/index.js index 965a15cc1c..9955bf9f18 100644 --- a/client/src/global/components/sign-in-up/EditProfileForm/index.js +++ b/client/src/global/components/sign-in-up/EditProfileForm/index.js @@ -81,7 +81,12 @@ export default function EditProfileForm({ hideOverlay, mode }) { formatData={formatAttributes} update={updateUser} > - <Greeting mode={mode} warn={!currentUser.attributes.established} /> + <Greeting + mode={mode} + warn={!currentUser.attributes.established} + userId={currentUser.id} + hideOverlay={hideOverlay} + /> <h2 className="screen-reader-text"> {t("forms.signin_overlay.update_sr_title")} </h2>