diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index cb7f4fbcf..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,337 +0,0 @@ -version: 2.1 -orbs: - codecov: codecov/codecov@5 -jobs: - test-ruby31: - docker: - - image: cimg/ruby:3.1-node - - image: cimg/mysql:8.0 - command: [ --default-authentication-plugin=mysql_native_password ] - environment: - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 'root' - MYSQL_DATABASE: 'qpixel_test' - - image: cimg/redis:7.0 - - working_directory: ~/qpixel - - steps: - - run: - name: Install packages - command: | - sudo apt-get --allow-releaseinfo-change -qq update - sudo apt-get -y install git libmariadb-dev libmagickwand-dev - - checkout - - restore_cache: - keys: - - qpixel-ruby31-{{ checksum "Gemfile.lock" }} - - qpixel-ruby31- - - run: - name: Install Bundler & gems - command: | - gem install bundler - bundle install --path=~/gems - - run: - name: Clean unnecessary gems - command: | - bundle clean --force - - save_cache: - key: qpixel-ruby31-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Copy key - command: | - if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi - - run: - name: Prepare config & database - environment: - RAILS_ENV: test - command: | - cp config/database.sample.yml config/database.yml - cp config/storage.sample.yml config/storage.yml - bundle exec rails db:create - bundle exec rails db:schema:load - bundle exec rails db:migrate - bundle exec rails test:prepare - - run: - name: Current revision - command: | - git rev-parse $(git rev-parse --abbrev-ref HEAD) - - run: - name: Test - environment: - RAILS_ENV: test - command: | - bundle exec rails test - - store_test_results: - path: "~/qpixel/test/reports" - system-test-ruby31: - docker: - - image: cimg/ruby:3.1-browsers - - image: cimg/mysql:8.0 - command: [ --default-authentication-plugin=mysql_native_password ] - environment: - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 'root' - MYSQL_DATABASE: 'qpixel_test' - - image: cimg/redis:7.0 - - working_directory: ~/qpixel - - steps: - - run: - name: Install packages - command: | - sudo apt-get --allow-releaseinfo-change -qq update - sudo apt-get -y install git libmariadb-dev libmagickwand-dev - - checkout - - restore_cache: - keys: - - qpixel-ruby31-{{ checksum "Gemfile.lock" }} - - qpixel-ruby31- - - run: - name: Install Bundler & gems - command: | - gem install bundler - bundle install --path=~/gems - - run: - name: Clean unnecessary gems - command: | - bundle clean --force - - save_cache: - key: qpixel-ruby31-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Copy key - command: | - if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi - - run: - name: Prepare config & database - environment: - RAILS_ENV: test - command: | - cp config/database.sample.yml config/database.yml - cp config/storage.sample.yml config/storage.yml - bundle exec rails db:create - bundle exec rails db:schema:load - bundle exec rails db:migrate - bundle exec rails test:prepare - - run: - name: Current revision - command: | - git rev-parse $(git rev-parse --abbrev-ref HEAD) - - run: - name: Test - environment: - RAILS_ENV: test - command: | - bundle exec rails test:system - - store_test_results: - path: "~/qpixel/test/reports" - - store_artifacts: - path: "~/qpixel/tmp/screenshots" - when: on_fail - - test-ruby32: - docker: - - image: cimg/ruby:3.2-node - - image: cimg/mysql:8.0 - command: [ --default-authentication-plugin=mysql_native_password ] - environment: - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 'root' - MYSQL_DATABASE: 'qpixel_test' - - image: cimg/redis:7.0 - - working_directory: ~/qpixel - - steps: - - run: - name: Install packages - command: | - sudo apt-get --allow-releaseinfo-change -qq update - sudo apt-get -y install git libmariadb-dev libmagickwand-dev - - checkout - - restore_cache: - keys: - - qpixel-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - run: - name: Install Bundler & gems - command: | - gem install bundler - bundle install --path=~/gems - - run: - name: Clean unnecessary gems - command: | - bundle clean --force - - save_cache: - key: qpixel-ruby32-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Copy key - command: | - if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi - - run: - name: Prepare config & database - environment: - RAILS_ENV: test - command: | - cp config/database.sample.yml config/database.yml - cp config/storage.sample.yml config/storage.yml - bundle exec rails db:create - bundle exec rails db:schema:load - bundle exec rails db:migrate - bundle exec rails test:prepare - - run: - name: Current revision - command: | - git rev-parse $(git rev-parse --abbrev-ref HEAD) - - run: - name: Test - environment: - RAILS_ENV: test - command: | - bundle exec rails test - - store_test_results: - path: "~/qpixel/test/reports" - - codecov/upload - system-test-ruby32: - docker: - - image: cimg/ruby:3.2-browsers - - image: cimg/mysql:8.0 - command: [ --default-authentication-plugin=mysql_native_password ] - environment: - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 'root' - MYSQL_DATABASE: 'qpixel_test' - - image: cimg/redis:7.0 - - working_directory: ~/qpixel - - steps: - - run: - name: Install packages - command: | - sudo apt-get --allow-releaseinfo-change -qq update - sudo apt-get -y install git libmariadb-dev libmagickwand-dev - - checkout - - restore_cache: - keys: - - qpixel-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - run: - name: Install Bundler & gems - command: | - gem install bundler - bundle install --path=~/gems - - run: - name: Clean unnecessary gems - command: | - bundle clean --force - - save_cache: - key: qpixel-ruby32-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Copy key - command: | - if [ -z "$MASTER_KEY" ]; then rm config/credentials.yml.enc; else echo "$MASTER_KEY" > config/master.key; fi - - run: - name: Prepare config & database - environment: - RAILS_ENV: test - command: | - cp config/database.sample.yml config/database.yml - cp config/storage.sample.yml config/storage.yml - bundle exec rails db:create - bundle exec rails db:schema:load - bundle exec rails db:migrate - bundle exec rails test:prepare - - run: - name: Current revision - command: | - git rev-parse $(git rev-parse --abbrev-ref HEAD) - - run: - name: Test - environment: - RAILS_ENV: test - command: | - bundle exec rails test:system - - store_test_results: - path: "~/qpixel/test/reports" - - store_artifacts: - path: "~/qpixel/tmp/screenshots" - when: on_fail - - rubocop: - docker: - - image: cimg/ruby:3.2-node - - working_directory: ~/qpixel - - steps: - - run: - name: Install packages - command: | - sudo apt-get --allow-releaseinfo-change -qq update - sudo apt-get -y install git libmariadb-dev libmagickwand-dev - - checkout - - restore_cache: - keys: - - qpixel-ruby32-{{ checksum "Gemfile.lock" }} - - qpixel-ruby32- - - run: - name: Install Bundler & gems - command: | - gem install bundler - bundle install --path=~/gems - - run: - name: Clean unnecessary gems - command: | - bundle clean --force - - save_cache: - key: qpixel-ruby32-{{ checksum "Gemfile.lock" }} - paths: - - ~/gems - - run: - name: Rubocop - command: | - bundle exec rubocop - - deploy: - docker: - - image: cimg/ruby:3.2-node - - working_directory: ~/qpixel - - steps: - - run: - name: Import SSH key - command: | - echo "$DEV_SSH_KEY" | base64 --decode > ~/deploy.key - chmod 0700 ~/deploy.key - - run: - name: Run deploy - command: | - ssh -o 'StrictHostKeyChecking no' "$SSH_USER"@"$SSH_IP" -p "$SSH_PORT" -i ~/deploy.key "sudo su -l qpixel /var/apps/deploy-dev" - -workflows: - test_lint: - jobs: - - test-ruby31 - - system-test-ruby31 - - test-ruby32 - - system-test-ruby32 - - rubocop - - deploy: - requires: - - test-ruby32 - - system-test-ruby32 - - rubocop - filters: - branches: - only: develop diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 000000000..86570f1e2 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,147 @@ +name: ci-cd + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +env: + RAILS_ENV: test + +jobs: + rubocop: + name: Rubocop checking + runs-on: ubuntu-latest + + strategy: + matrix: + ruby_version: [3.1, 3.2] + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Setup dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -yqq libmagickwand-dev + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - run: bundle exec rubocop + + typescript: + name: TypeScript type checking + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Install Node + uses: actions/setup-node@v1 + with: + node-version: 22 + - run: npm install + - run: tsc + + tests: + name: Ruby on Rails tests + runs-on: ubuntu-latest + + strategy: + matrix: + ruby_version: [3.1, 3.2] + test_type: [test, test:system] + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 'root' + MYSQL_DATABASE: 'qpixel_test' + ports: + - 3306:3306 + redis: + image: redis:8.0 + ports: + - 6379:6379 + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Setup dependencies + run: | + sudo apt-get -qq update + sudo apt-get -yqq install libmariadb-dev libmagickwand-dev + - name: Setup Firefox + if: ${{ matrix.test_type == 'test:system' }} + uses: browser-actions/setup-firefox@v1.5.4 + - name: Check Firefox setup + if: ${{ matrix.test_type == 'test:system' }} + run: firefox --version + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - name: Prepare for testing + run: | + cp config/database.sample.yml config/database.yml + cp config/storage.sample.yml config/storage.yml + bundle exec rails db:create + bundle exec rails db:schema:load + bundle exec rails db:migrate + bundle exec rails test:prepare + - run: bundle exec rails ${{ matrix.test_type }} + - name: Upload screenshots + if: ${{ matrix.test_type == 'test:system' }} + uses: actions/upload-artifact@v4 + with: + name: screenshots-${{ matrix.ruby_version }} + path: tmp/screenshots + if-no-files-found: ignore + - uses: codecov/codecov-action@v5 + if: ${{ matrix.ruby_version == 3.2 && matrix.test_type == 'test' }} + with: + directory: coverage + fail_ci_if_error: true + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + + deploy: + name: Dev server deployment + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} + needs: + - rubocop + - typescript + - tests + + env: + SSH_IP: ${{ secrets.DEV_SSH_IP }} + SSH_KEY: ${{ secrets.DEV_SSH_KEY }} + SSH_PORT: ${{ secrets.DEV_SSH_PORT }} + SSH_USER: ${{ secrets.DEV_SSH_USER }} + + steps: + - name: Check SSH version + run: ssh -V + - name: Import key + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/deploy.key + chmod 0700 ~/.ssh/deploy.key + - name: Deploy + run: | + ssh -o 'StrictHostKeyChecking no' \ + -o 'KbdInteractiveAuthentication no' \ + -o 'PasswordAuthentication no' \ + -p "$SSH_PORT" \ + -i ~/.ssh/deploy.key \ + "$SSH_USER"@"$SSH_IP" \ + "sudo su -l qpixel /var/apps/deploy-dev" diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml deleted file mode 100644 index 4b19bf686..000000000 --- a/.github/workflows/typescript.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: TypeScript check - -on: - push: - branches: - - develop - pull_request: - branches: - - develop - -jobs: - typescript: - name: TypeScript type checking - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - name: Install Node - uses: actions/setup-node@v1 - with: - node-version: 22 - - name: npm install - run: npm install - - name: tsc - run: tsc diff --git a/.rubocop.yml b/.rubocop.yml index 9a617d96c..7c85865b5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,6 +11,7 @@ AllCops: - 'scripts/**/*' - 'bin/**/*' - 'lib/namespaced_env_cache.rb' + - 'vendor/bundle/**/*' NewCops: enable SuggestExtensions: false diff --git a/Gemfile b/Gemfile index 175a6ae51..47f6f9fc2 100644 --- a/Gemfile +++ b/Gemfile @@ -53,6 +53,7 @@ gem 'whenever', '~> 1.0', require: false gem 'awesome_print', '~> 1.9' gem 'rubocop', '~> 1' gem 'rubocop-rails', '~> 2.15' +gem 'rubocop-rake', '~> 0.7.1' # MiniProfiler support, including stack traces & memory dumps, plus flamegraphs. gem 'flamegraph', '~> 0.9' diff --git a/Gemfile.lock b/Gemfile.lock index dcbe488c0..c13cc91ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -373,6 +373,9 @@ GEM rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) ruby-progressbar (1.13.0) ruby-saml (1.18.0) nokogiri (>= 1.13.10) @@ -509,6 +512,7 @@ DEPENDENCIES rqrcode (~> 2.1) rubocop (~> 1) rubocop-rails (~> 2.15) + rubocop-rake (~> 0.7.1) ruby-progressbar (~> 1.11) sass-rails (~> 6.0) selenium-webdriver (~> 4.7) diff --git a/README.md b/README.md index ab41bbe44..774efdd62 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- CircleCI Build Status + Pipeline status Coverage Status DOI

diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 2e6350345..26fef7f0a 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -150,9 +150,7 @@ def category_params end def verify_view_access - unless (current_user&.trust_level || 0) >= (@category.min_view_trust_level || -1) - not_found! - end + not_found! unless @category.public? || current_user&.can_see_category?(@category) end def set_list_posts @@ -163,7 +161,7 @@ def set_list_posts sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc } @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) .includes(:post_type, :tags).list_includes - filter_qualifiers = helpers.params_to_qualifiers + filter_qualifiers = helpers.params_to_qualifiers(params) @active_filter = helpers.active_filter if filter_qualifiers.blank? && @active_filter[:name].blank? @@ -194,7 +192,7 @@ def set_list_posts end end - @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts, current_user) @filtered = filter_qualifiers.any? @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 202a3636b..eb47d38c4 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,6 +1,6 @@ class SearchController < ApplicationController def search - @posts, @qualifiers = helpers.search_posts + @posts, @qualifiers = helpers.search_posts(current_user, params) @signed_out_me = @qualifiers.any? { |q| q[:param] == :user && q[:user_id].nil? } diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 0b5c8a704..7d8a47b0d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,33 +1,26 @@ module SearchHelper - def check_posts_permissions - (current_user&.at_least_moderator? ? Post : Post.undeleted) - .qa_only.list_includes - end - ## # Search & sort a default posts list based on parameters in the current request. # - # Generates initial post list using {Post#qa_only}, including deleted posts for mods and admins. Takes search string - # from params[:search], applies any qualifiers, and searches post bodies for the remaining term(s). + # Search uses MySQL FTS in boolean mode which is what provides advanced search syntax (excluding qualifiers) + # see {MySQL manual 14.9.2}[https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html]. # - # Search uses MySQL fulltext search in boolean mode which is what provides advanced search syntax (excluding - # qualifiers) - see {MySQL manual 14.9.2}[https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html]. - # - # @return [ActiveRecord::Relation] - def search_posts - posts = check_posts_permissions - - qualifiers = params_to_qualifiers + # @param user [User] user for search context + # @param params [ActionController::Parameters] search parameters + # @return [[ActiveRecord::Relation, Array Object}>]] + def search_posts(user, params) + posts = Post.accessible_to(user) + qualifiers = params_to_qualifiers(params) search_string = params[:search] # Filter based on search string qualifiers if search_string.present? search_data = parse_search(search_string) - qualifiers += parse_qualifier_strings search_data[:qualifiers] + qualifiers += parse_qualifier_strings(search_data[:qualifiers]) search_string = search_data[:search] end - posts = qualifiers_to_sql(qualifiers, posts) + posts = qualifiers_to_sql(qualifiers, posts, user) posts = posts.paginate(page: params[:page], per_page: 25) posts = if search_string.present? @@ -79,8 +72,9 @@ def active_filter ## # Retrieves parameters from +params+, validates their values, and adds them to a qualifiers hash. + # @param params [ActionController::Parameters] params to convert to qualifiers # @return [Array Object}>] - def params_to_qualifiers + def params_to_qualifiers(params) valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, status: /any|open|closed/, @@ -222,10 +216,9 @@ def parse_qualifier_strings(qualifiers) # @param qualifiers [Array Object}>] A qualifiers hash, as returned by other methods in this module. # @param query [ActiveRecord::Relation] An ActiveRecord query to which to add conditions based on the qualifiers. # @return [ActiveRecord::Relation] - def qualifiers_to_sql(qualifiers, query) - trust_level = current_user&.trust_level || 0 - allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) - query = query.where(category_id: allowed_categories) + def qualifiers_to_sql(qualifiers, query, user) + categories = Category.accessible_to(user) + query = query.where(category_id: categories) qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength case qualifier[:param] diff --git a/app/models/category.rb b/app/models/category.rb index 0a9a989c2..d88c3d46f 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -15,6 +15,13 @@ class Category < ApplicationRecord validates :name, uniqueness: { scope: [:community_id], case_sensitive: false } + # Can anyone view the category (even if not logged in)? + # @return [Boolean] check result + def public? + trust_level = min_view_trust_level || -1 + trust_level <= 0 + end + def new_posts_for?(user) key = "#{community_id}/#{user.id}/#{id}/last_visit" Rails.cache.fetch key, expires_in: 5.minutes do @@ -31,6 +38,18 @@ def update_activity(last_activity) RequestContext.redis.set("#{community_id}/#{id}/last_activity", last_activity) end + # Gets categories appropriately scoped for a given user + # @param user [User] user to check + # @return [ActiveRecord::Relation] + def self.accessible_to(user) + if user&.at_least_moderator? + return Category.all + end + + trust_level = user&.trust_level || 0 + Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) + end + def self.by_lowercase_name(name) categories = Rails.cache.fetch 'categories/by_lowercase_name' do Category.all.to_h { |c| [c.name.downcase, c] } diff --git a/app/models/post.rb b/app/models/post.rb index bb8581e59..3f72411ea 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -65,6 +65,13 @@ class Post < ApplicationRecord after_save :update_category_activity, if: -> { post_type.has_category && !destroyed? } after_save :recalc_score + # Gets posts appropriately scoped for a given user + # @param user [User] user to check + # @return [ActiveRecord::Relation] + def self.accessible_to(user) + (user&.at_least_moderator? ? Post : Post.undeleted).qa_only.list_includes + end + # @param term [String] the search term # @return [ActiveRecord::Relation] def self.search(term) diff --git a/app/models/user.rb b/app/models/user.rb index 9ae452717..6a2ed2c61 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -40,7 +40,7 @@ class User < ApplicationRecord validate :not_blocklisted? validate :email_not_bad_pattern - delegate :trust_level, :reputation, :reputation=, :privilege?, :privilege, to: :community_user + delegate :reputation, :reputation=, :privilege?, :privilege, to: :community_user def self.list_includes includes(:posts, :avatar_attachment) @@ -50,6 +50,12 @@ def self.search(term) where('username LIKE ?', "%#{sanitize_sql_like(term)}%") end + # Safely gets the user's trust level even if they don't have a community user + # @return [Integer] user's trust level + def trust_level + community_user&.trust_level || 0 + end + # Is the user a new user? # @return [Boolean] check result def new? @@ -146,6 +152,14 @@ def can_reply_to?(thread) can_comment_on?(thread.post) && !thread.read_only? end + # Can the user see a given category at all? + # @param category [Category] category to check + # @return [Boolean] check result + def can_see_category?(category) + category_trust_level = category.min_view_trust_level || -1 + trust_level >= category_trust_level + end + # Is the user allowed to see deleted posts? # @return [Boolean] check result def can_see_deleted_posts? diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 66b194f10..82bdbfdc5 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -9,63 +9,78 @@ class CategoriesControllerTest < ActionController::TestCase assert_not_nil assigns(:categories) end - test 'should get show' do - get :show, params: { id: categories(:main).id } - assert_response(:success) - assert_not_nil assigns(:category) - assert_not_nil assigns(:posts) + test 'should correctly show public categories' do + public_categories = categories.select(&:public?) + + assert_not public_categories.empty? + + public_categories.each do |category| + try_show_category(category) + + assert_response(:success) + assert_not_nil assigns(:category) + assert_not_nil assigns(:posts) + end end - test 'fake community should not get show' do + test 'fake community should never be shown' do RequestContext.community = communities(:fake) request.env['HTTP_HOST'] = 'fake.qpixel.com' - get :show, params: { id: categories(:main).id } + try_show_category(categories(:main)) + assert_response(:not_found) end - test 'should require authentication to get new' do - get :new - assert_redirected_to_sign_in - end + test 'categories should only be shown to those who can see them' do + users.each do |user| + sign_in user - test 'should require admin to get new' do - sign_in users(:standard_user) - get :new - assert_response(:not_found) + categories.each do |category| + try_show_category(category) + + if category.public? || user.can_see_category?(category) + assert_response(:success) + else + assert_response(:not_found) + end + + assert_not_nil assigns(:category) + end + end end - test 'should allow admins to get new' do - sign_in users(:admin) - get :new - assert_response(:success) - assert_not_nil assigns(:category) + test ':new should require the user to be an admin' do + users.each do |user| + sign_in user + + get :new + + if user.admin? + assert_response(:success) + assert_not_nil assigns(:category) + elsif @controller.helpers.user_signed_in? + assert_response(:not_found) + else + assert_redirected_to_sign_in + end + end end test 'should require authentication to create category' do - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } - + try_create_category assert_redirected_to_sign_in end test 'should require admin to create category' do sign_in users(:standard_user) - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } + try_create_category assert_response(:not_found) end test 'should allow admins to create category' do sign_in users(:admin) - post :create, params: { category: { name: 'test', short_wiki: 'test', display_post_types: [Question.post_type_id], - post_type_ids: [Question.post_type_id, Answer.post_type_id], - tag_set_id: tag_sets(:main).id, color_code: 'blue', - license_id: licenses(:cc_by_sa).id } } + try_create_category assert_response(:found) assert_not_nil assigns(:category) @@ -74,10 +89,27 @@ class CategoriesControllerTest < ActionController::TestCase assert_redirected_to category_path(assigns(:category)) end - test 'should prevent users under min_view_trust_level viewing category that requires higher' do - get :show, params: { id: categories(:admin_only).id } + private - assert_response(:not_found) - assert_not_nil assigns(:category) + def try_create_category(**opts) + name = opts[:name] || 'test' + short_wiki = opts[:short_wiki] || 'test' + license = opts[:license] || licenses(:cc_by_sa) + color_code = opts[:color_code] || 'blue' + display_post_types = opts[:display_post_types] || [Question.post_type_id] + post_types = opts[:post_types] || [Question, Answer] + tag_set = opts[:tag_set] || tag_sets(:main) + + post :create, params: { category: { name: name, + short_wiki: short_wiki, + display_post_types: display_post_types, + post_type_ids: post_types.map(&:post_type_id), + tag_set_id: tag_set.id, + color_code: color_code, + license_id: license.id } } + end + + def try_show_category(category) + get :show, params: { id: category.id } end end diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index 7d31b0e49..255f50742 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -3,25 +3,10 @@ class PostsControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers - test 'can create help post' do - sign_in users(:moderator) - - post :create, params: { post_type: post_types(:help_doc).id, - post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic', - body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } - - assert_response(:found) - assert_not_nil assigns(:post).id - assert_redirected_to help_path(assigns(:post).doc_slug) - end - test 'can create category post' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache, license_id: licenses(:cc_by_sa).id } } + try_create_post assert_response(:found) assert_not_nil assigns(:post).id @@ -32,10 +17,7 @@ class PostsControllerTest < ActionController::TestCase sign_in users(:closer) before_notifs = posts(:question_one).user.notifications.count - post :create, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id, - post: { post_type_id: post_types(:answer).id, title: sample.title, - body_markdown: sample.body_markdown, parent_id: posts(:question_one).id, - license_id: licenses(:cc_by_sa).id } } + try_create_post(post_type: post_types(:answer), parent: posts(:question_one)) after_notifs = posts(:question_one).user.notifications.count assert_response(:found) @@ -45,14 +27,22 @@ class PostsControllerTest < ActionController::TestCase end test 'create requires authentication' do - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - + try_create_post assert_redirected_to_sign_in end + test 'can create help post' do + sign_in users(:moderator) + + post :create, params: { post_type: post_types(:help_doc).id, + post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic', + body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } } + + assert_response(:found) + assert_not_nil assigns(:post).id + assert_redirected_to help_path(assigns(:post).doc_slug) + end + test 'standard users cannot create help posts' do sign_in users(:standard_user) @@ -76,9 +66,7 @@ class PostsControllerTest < ActionController::TestCase test 'category post type rejects without category' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, tags_cache: sample.tags_cache } } + try_create_post(category: nil) assert_response(:found) assert_redirected_to root_path @@ -89,10 +77,7 @@ class PostsControllerTest < ActionController::TestCase test 'category post type checks required trust level' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:question).id, category: categories(:high_trust).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:high_trust).id, - tags_cache: sample.tags_cache } } + try_create_post(category: categories(:high_trust)) assert_response(:forbidden) assert_nil assigns(:post).id @@ -102,9 +87,7 @@ class PostsControllerTest < ActionController::TestCase test 'parented post type rejects without parent' do sign_in users(:standard_user) - post :create, params: { post_type: post_types(:answer).id, - post: { post_type_id: post_types(:answer).id, title: sample.title, - body_markdown: sample.body_markdown } } + try_create_post(post_type: post_types(:answer)) assert_response(:found) assert_redirected_to root_path @@ -117,34 +100,44 @@ class PostsControllerTest < ActionController::TestCase before = CommunityUser.where(user: user, community: communities(:sample)).count sign_in user - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } + try_create_post after = CommunityUser.where(user: user, community: communities(:sample)).count assert_equal before + 1, after, 'No CommunityUser record was created' end - test 'should prevent deleted account creating post' do + test 'should prevent deleted accounts from creating posts' do sign_in users(:deleted_account) - - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - + try_create_post assert_redirected_to_sign_in end - test 'should prevent deleted profile creating post' do + test 'should prevent deleted profiles from creating posts' do sign_in users(:deleted_profile) - - post :create, params: { post_type: post_types(:question).id, category: categories(:main).id, - post: { post_type_id: post_types(:question).id, title: sample.title, - body_markdown: sample.body_markdown, category_id: categories(:main).id, - tags_cache: sample.tags_cache } } - + try_create_post assert_redirected_to_sign_in end + + private + + # Attempts to create a post + # @param post_type [PostType] + # @param category [Category, nil] + # @param parent [Post, nil] + # @param license [String] + def try_create_post(post_type: post_types(:question), + category: categories(:main), + parent: nil, + license: licenses(:cc_by_sa)) + post :create, params: { post_type: post_type.id, + parent: parent&.id, + category: category&.id, + post: { post_type_id: post_type.id, + title: sample.title, + body_markdown: sample.body_markdown, + category_id: category&.id, + parent_id: parent&.id, + tags_cache: sample.tags_cache, + license_id: license.id } } + end end diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index 4f103923a..f44432b70 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -29,13 +29,13 @@ high_trust: admin_only: community: sample - name: High Trust - short_wiki: High Trust + name: Admin Only + short_wiki: Admin Only display_post_types: - <%= Question.post_type_id %> tag_set: main - min_trust_level: 6 - min_view_trust_level: 6 + min_trust_level: 5 + min_view_trust_level: 5 license: cc_by_sa articles_only: diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index dcdc2f655..ea81fce1e 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -61,6 +61,13 @@ sample_global_admin: is_moderator: false reputation: 1 +sample_staff: + user: staff + community: sample + is_admin: false + is_moderator: false + reputation: 1 + sample_deleted_account: user: deleted_account community: sample diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 0d3cf3585..e4cf4cace 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -327,6 +327,32 @@ deleted_answer: upvote_count: 0 downvote_count: 0 +good_answer: + post_type: answer + body: A3GA - This is the seventh answer to question number 1 (Q1). It has a good score. Posted by standard_user. + body_markdown: A7 - This is the seventh answer to question number 1 (Q1). It has a good score. Posted by standard_user. + score: 0.6 + parent: question_one + user: standard_user + community: sample + category: main + license: cc_by_sa + upvote_count: 1 + downvote_count: 0 + +divisive_answer: + post_type: answer + body: A3DA - This is the eighth answer to question number 1 (Q1). It has divisive votes. Posted by standard_user. + body_markdown: A8 - This is the eighth answer to question number 1 (Q1). It has divisive votes. Posted by standard_user. + score: 0.6 + parent: question_one + user: standard_user + community: sample + category: main + license: cc_by_sa + upvote_count: 4 + downvote_count: 2 + policy_doc: post_type: policy_doc body: PD - This is a policy document called "Terms of Service", or "tos" for short. diff --git a/test/helpers/search_helper_test.rb b/test/helpers/search_helper_test.rb index a82e92f6f..11f30c8f1 100644 --- a/test/helpers/search_helper_test.rb +++ b/test/helpers/search_helper_test.rb @@ -35,4 +35,168 @@ class SearchHelperTest < ActionView::TestCase assert_equal expect, date_value_sql(input) end end + + test 'qualifiers_to_sql should correctly narrow by :category qualifier' do + main = categories(:main) + admin_only = categories(:admin_only) + + std_user = users(:standard_user) + adm_user = users(:admin) + + posts_query_std = Post.accessible_to(std_user) + posts_query_adm = Post.accessible_to(adm_user) + + std_post = [{ param: :category, operator: '=', category_id: main.id }] + adm_post = [{ param: :category, operator: '=', category_id: admin_only.id }] + + std_posts_query_standard = qualifiers_to_sql(std_post, posts_query_std, std_user) + adm_posts_query_standard = qualifiers_to_sql(adm_post, posts_query_std, std_user) + adm_posts_query_admin = qualifiers_to_sql(adm_post, posts_query_adm, adm_user) + + assert_not_equal posts_query_std.size, std_posts_query_standard.size + assert_not_equal std_posts_query_standard.size, 0 + + assert_not_equal posts_query_adm.size, adm_posts_query_admin.size + assert_not_equal adm_posts_query_admin.size, 0 + + assert_equal adm_posts_query_standard.size, 0 + end + + test 'qualifiers_to_sql should correctly narrow by :user qualifier' do + std_user = users(:standard_user) + edt_user = users(:editor) + + posts_query = Post.accessible_to(std_user) + edt_post = [{ param: :user, operator: '=', user_id: edt_user.id }] + edt_query = qualifiers_to_sql(edt_post, posts_query, std_user) + + only_editor_posts = edt_query.to_a.all? { |p| p.user.id == edt_user.id } + + assert_not_equal posts_query.size, edt_query.size + assert_not_equal edt_query.size, 0 + assert only_editor_posts + end + + test 'qualifiers_to_sql should correctly narrow by :score qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + bad_post = [{ param: :score, operator: '<', value: 0.5 }] + good_post = [{ param: :score, operator: '>', value: 0.5 }] + neut_post = [{ param: :score, operator: '=', value: 0.5 }] + + bad_posts_query = qualifiers_to_sql(bad_post, posts_query, std_user) + good_posts_query = qualifiers_to_sql(good_post, posts_query, std_user) + neut_posts_query = qualifiers_to_sql(neut_post, posts_query, std_user) + + only_bad_posts = bad_posts_query.to_a.all? { |p| p.score < 0.5 } + only_good_posts = good_posts_query.to_a.all? { |p| p.score > 0.5 } + only_neut_posts = neut_posts_query.to_a.all? { |p| p.score.to_d == 0.5.to_d } + + assert_not_equal posts_query.size, bad_posts_query.size + assert_not_equal bad_posts_query.size, 0 + assert only_bad_posts + + assert_not_equal posts_query.size, good_posts_query.size + assert_not_equal good_posts_query.size, 0 + assert only_good_posts + + assert_not_equal posts_query.size, neut_posts_query.size + assert_not_equal neut_posts_query.size, 0 + assert only_neut_posts + end + + test 'qualifiers_to_sql should correctly narrow by :status qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + open_post = [{ param: :status, value: 'open' }] + closed_post = [{ param: :status, value: 'closed' }] + + open_query = qualifiers_to_sql(open_post, posts_query, std_user) + closed_query = qualifiers_to_sql(closed_post, posts_query, std_user) + + only_open_posts = open_query.to_a.none?(&:closed) + only_closed_posts = closed_query.to_a.all?(&:closed) + + assert_not_equal posts_query.size, open_query.size + assert_not_equal open_query.size, 0 + assert only_open_posts + + assert_not_equal posts_query.size, closed_query.size + assert_not_equal closed_query.size, 0 + assert only_closed_posts + end + + test 'qualifiers_to_sql should correctly narrow by :upvotes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + upvoted_post = [{ param: :upvotes, operator: '>', value: 0 }] + neutral_post = [{ param: :upvotes, operator: '=', value: 0 }] + + upvoted_query = qualifiers_to_sql(upvoted_post, posts_query, std_user) + neutral_query = qualifiers_to_sql(neutral_post, posts_query, std_user) + + only_upvoted_posts = upvoted_query.to_a.all? { |p| p[:upvote_count].positive? } + only_neutral_posts = neutral_query.to_a.all? { |p| p[:upvote_count].zero? } + + assert_not_equal posts_query.size, upvoted_query.size + assert_not_equal upvoted_query.size, 0 + assert only_upvoted_posts + + assert_not_equal posts_query.size, neutral_query.size + assert_not_equal neutral_query.size, 0 + assert only_neutral_posts + end + + test 'qualifiers_to_sql should correctly narrow by :downvotes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + downvoted_post = [{ param: :downvotes, operator: '>', value: 0 }] + neutral_post = [{ param: :downvotes, operator: '=', value: 0 }] + + downvoted_query = qualifiers_to_sql(downvoted_post, posts_query, std_user) + neutral_query = qualifiers_to_sql(neutral_post, posts_query, std_user) + + only_downvoted_posts = downvoted_query.to_a.all? { |p| p[:downvote_count].positive? } + only_neutral_posts = neutral_query.to_a.all? { |p| p[:downvote_count].zero? } + + assert_not_equal posts_query.size, downvoted_query.size + assert_not_equal downvoted_query.size, 0 + assert only_downvoted_posts + + assert_not_equal posts_query.size, neutral_query.size + assert_not_equal neutral_query.size, 0 + assert only_neutral_posts + end + + test 'qualifiers_to_sql should correctly narrow by :net_votes qualifier' do + std_user = users(:standard_user) + + posts_query = Post.accessible_to(std_user) + divisive_post = [{ param: :net_votes, operator: '=', value: 2 }] + + divisive_query = qualifiers_to_sql(divisive_post, posts_query, std_user) + + only_divisive_posts = divisive_query.to_a.all? do |p| + (p[:upvote_count] - p[:downvote_count]) == 2 + end + + assert_not_equal posts_query.size, divisive_query.size + assert_not_equal divisive_query.size, 0 + assert only_divisive_posts + end + + test 'search_posts should not show posts in categories that a user cannot view' do + std_user = users(:standard_user) + + params = ActionController::Parameters.new({ search: 'high trust' }) + posts, _qualifiers = search_posts(std_user, params) + + admin_category = categories(:admin_only) + + assert_not(posts.any? { |p| p.category.id == admin_category.id }) + end end diff --git a/test/models/post_test.rb b/test/models/post_test.rb index 6a98e8275..befb2b3a9 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -47,6 +47,24 @@ class PostTest < ActiveSupport::TestCase end end + test 'accessible_to should correctly check user access' do + adm_user = users(:admin) + mod_user = users(:moderator) + std_user = users(:standard_user) + + adm_posts = Post.accessible_to(adm_user) + mod_posts = Post.accessible_to(mod_user) + std_posts = Post.accessible_to(std_user) + + can_admin_get_deleted_posts = adm_posts.any?(&:deleted) + can_mod_get_deleted_posts = mod_posts.any?(&:deleted) + can_user_get_deleted_posts = std_posts.any?(&:deleted) + + assert can_admin_get_deleted_posts + assert can_mod_get_deleted_posts + assert_not can_user_get_deleted_posts + end + test 'should allow specified post types in a category' do category = categories(:main) post_type = post_types(:question)