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 @@
-
+
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)