This commit is contained in:
Riccardo Graziosi
2024-11-08 16:40:53 +01:00
committed by GitHub
parent 5ad04adb10
commit 31999a2af6
57 changed files with 3246 additions and 53 deletions

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Build Docker production image
run: docker compose -f docker-compose.yml -f docker-compose-prod.yml build --build-arg ENVIRONMENT=production

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ yarn-debug.log*
/app/assets/builds/*
!/app/assets/builds/.keep
# Ignore Swagger spec file
/swagger/*

View File

@@ -37,9 +37,12 @@ RUN yarn install --check-files
# Copy all files
COPY . ${APP_ROOT}/
# Build Swagger API documentation
RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
# Compile assets if production
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
RUN if [ "$ENVIRONMENT" = "production" ]; then RAILS_ENV=development ./bin/rails assets:precompile; fi
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
###
### Dev stage ###
@@ -91,6 +94,9 @@ COPY --from=builder ${APP_ROOT}/Rakefile ${APP_ROOT}/
COPY --from=builder ${APP_ROOT}/lib/tasks/ ${APP_ROOT}/lib/tasks/
COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
# Copy Swagger API documentation
COPY --from=builder ${APP_ROOT}/swagger/ ${APP_ROOT}/swagger/
ENTRYPOINT ["./docker-entrypoint-prod.sh"]
EXPOSE 3000

23
Gemfile
View File

@@ -50,10 +50,20 @@ gem 'friendly_id', '5.5.1'
# Billing
gem 'stripe', '11.2.0'
# Serve swagger docs
gem 'rswag-api', '2.15.0'
# We need those gems here, so we can Swaggerize in production
gem 'rswag-specs', '2.15.0'
gem 'rspec-rails', '4.0.2'
gem 'capybara', '3.40.0'
# CORS policy
gem 'rack-cors', '2.0.2'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails', '4.0.2'
gem 'factory_bot_rails', '5.0.2'
end
@@ -64,11 +74,10 @@ group :development do
end
group :test do
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '3.36.0'
gem 'selenium-webdriver', '4.1.0'
# Easy installation and use of web drivers to run system tests with browsers
gem 'webdrivers', '5.3.1'
gem 'selenium-webdriver', '4.17.0'
# Retry flaky Capybara tests
gem 'rspec-retry', '0.6.2'
end
# If not bundled, webpack compilation in production fails

View File

@@ -60,28 +60,28 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
base64 (0.2.0)
bcrypt (3.1.18)
bindex (0.8.1)
bootsnap (1.12.0)
msgpack (~> 1.2)
builder (3.3.0)
byebug (11.1.3)
capybara (3.36.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (4.1.0)
concurrent-ruby (1.3.3)
connection_pool (2.2.5)
crass (1.0.6)
@@ -119,6 +119,8 @@ GEM
activesupport (>= 5.0.0)
jsbundling-rails (1.1.1)
railties (>= 6.0.0)
json-schema (5.0.1)
addressable (~> 2.8)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -165,7 +167,7 @@ GEM
racc (~> 1.4)
orm_adapter (0.5.0)
pg (1.3.5)
public_suffix (4.0.7)
public_suffix (6.0.1)
puma (5.6.9)
nio4r (~> 2.0)
pundit (2.2.0)
@@ -174,6 +176,8 @@ GEM
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rails (6.1.7.8)
@@ -214,12 +218,11 @@ GEM
execjs
railties (>= 3.2)
tilt
regexp_parser (2.5.0)
regexp_parser (2.9.2)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.3.6)
strscan
rexml (3.3.8)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -236,12 +239,23 @@ GEM
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-retry (0.6.2)
rspec-core (> 3.3)
rspec-support (3.12.0)
rswag-api (2.15.0)
activesupport (>= 5.2, < 8.0)
railties (>= 5.2, < 8.0)
rswag-specs (2.15.0)
activesupport (>= 5.2, < 8.0)
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.0)
rspec-core (>= 2.14)
rubyzip (2.3.2)
selenium-webdriver (4.1.0)
childprocess (>= 0.5, < 5.0)
selenium-webdriver (4.17.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
spring (2.1.1)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
@@ -254,7 +268,6 @@ GEM
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stripe (11.2.0)
strscan (3.1.0)
thor (1.3.1)
tilt (2.0.10)
timeout (0.4.1)
@@ -270,10 +283,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webdrivers (5.3.1)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -287,7 +297,7 @@ PLATFORMS
DEPENDENCIES
bootsnap (= 1.12.0)
byebug
capybara (= 3.36.0)
capybara (= 3.40.0)
cssbundling-rails (= 1.1.2)
devise (= 4.7.3)
factory_bot_rails (= 5.0.2)
@@ -302,18 +312,21 @@ DEPENDENCIES
puma (= 5.6.9)
pundit (= 2.2.0)
rack-attack (= 6.7.0)
rack-cors (= 2.0.2)
rails (= 6.1.7.8)
rake (= 12.3.3)
react-rails (= 2.6.2)
rspec-rails (= 4.0.2)
selenium-webdriver (= 4.1.0)
rspec-retry (= 0.6.2)
rswag-api (= 2.15.0)
rswag-specs (= 2.15.0)
selenium-webdriver (= 4.17.0)
spring (= 2.1.1)
spring-watcher-listen (= 2.0.1)
stripe (= 11.2.0)
turbolinks (= 5.2.1)
tzinfo-data
web-console (>= 3.3.0)
webdrivers (= 5.3.1)
RUBY VERSION
ruby 3.0.6p216

View File

@@ -25,6 +25,8 @@
margin: 0 auto;
}
.apiKeyGenerateButton { width: 100%; }
.deviseLinks {
@extend .new_user;

View File

@@ -0,0 +1,95 @@
module Api
class BaseController < ApplicationController
include ApplicationHelper
include Pundit::Authorization
rescue_from StandardError, with: :unexpected_error # Must be at the top, catches exceptions not caught by other rescue_from
rescue_from ActiveRecord::InvalidForeignKey, with: :parameter_wrong
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :parameter_missing
rescue_from Pundit::NotAuthorizedError, with: :not_authorized
rescue_from Api::V1::Helpers::ImpersonationError, with: :impersonation_error
skip_before_action :verify_authenticity_token
skip_before_action :check_tenant_is_private
skip_before_action :load_tenant_data
before_action :authenticate_with_api_key
prepend_before_action :set_current_tenant
attr_reader :current_user, :current_api_key
def pundit_user
current_api_key
end
protected
def set_current_tenant
Current.tenant = get_tenant_from_request(request)
# If current tenant is nil, return generic error message
request_http_token_authentication if Current.tenant.nil?
I18n.locale = I18n.default_locale
end
def not_authorized
render status: :unauthorized, json: {
errors: ['You are not authorized to perform this action.']
}
end
def parameter_missing
render status: :bad_request, json: {
errors: ['Some parameters are missing from the request. Please check the documentation.']
}
end
def parameter_wrong
render status: :bad_request, json: {
errors: ['Some parameters are wrong in the request. Please check the documentation.']
}
end
def not_found(exception)
render status: :not_found, json: {
errors: [exception.message]
}
end
def impersonation_error(exception)
render status: :unauthorized, json: {
errors: ["Impersonation error: #{exception.message}"]
}
end
def unexpected_error(exception)
if Rails.env.development?
error = '[DEV-ONLY MESSAGE] ' + exception.message
else
error = 'An unexpected error occurred.'
end
render status: :internal_server_error, json: {
errors: [error]
}
end
def authenticate_with_api_key
authenticate_or_request_with_http_token do |token, options|
@current_api_key = ApiKey.find_by_token(token)
@current_user = current_api_key&.user
end
end
# Override rails default 401 response to return JSON content-type
# with request for Bearer token
# https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html
def request_http_token_authentication(realm = "Application", message = nil)
json_response = { errors: [message || "Access denied."] }
headers["WWW-Authenticate"] = %(Bearer realm="#{realm.tr('"', "")}")
render json: json_response, status: :unauthorized
end
end
end

View File

@@ -0,0 +1,49 @@
module Api
module V1
class BoardsController < BaseController
include Api::V1::Serializers
# List all boards
def index
boards = Board.all
authorize([:api, Board])
render json: boards.map { |board| board.slice(*BOARD_JSON_ATTRIBUTES) }
end
# Get the board by id or slug
def show
board = Board.find_by(id: params[:id]) || Board.find_by(slug: params[:id])
unless board
raise ActiveRecord::RecordNotFound, "Board with id #{params[:id]} not found"
end
authorize([:api, board])
render json: board.slice(*BOARD_JSON_ATTRIBUTES)
end
# Create a new board
def create
board = Board.new(board_params)
authorize([:api, board])
if board.save
render json: { id: board.id }, status: :created
else
render json: { errors: board.errors.full_messages }, status: :unprocessable_entity
end
end
private
def board_params
params.require(:name)
params.permit(:name, :slug, :description)
end
end
end
end

View File

@@ -0,0 +1,131 @@
module Api
module V1
class CommentsController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List comments
def index
comments = Comment
.includes(:user)
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
comments = comments.where(post_id: params[:post_id]) if params[:post_id].present?
authorize([:api, Comment])
render json: comments.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Show a comment
def show
comment = Comment
.includes(:user)
.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
render json: comment.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create a new comment
def create
comment = Comment.new(comment_params)
authorize([:api, comment])
comment.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if comment.save
SendNotificationForCommentWorkflow.new(comment: comment).run
render json: { id: comment.id }, status: :created
else
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
end
end
# Update a comment
def update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
if comment.update(comment_update_params)
render json: { id: comment.id }, status: :ok
else
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete a comment
def destroy
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.destroy!
render json: { id: comment.id }, status: :ok
end
# Mark comment as post update
def mark_as_post_update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.update!(is_post_update: true)
render json: { id: comment.id }, status: :ok
end
# Unmark comment as post update
def unmark_as_post_update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.update!(is_post_update: false)
render json: { id: comment.id }, status: :ok
end
private
def comment_params
params.permit(:body, :is_post_update, :post_id, :parent_id)
end
def comment_update_params
params.permit(:body)
end
end
end
end

View File

@@ -0,0 +1,19 @@
module Api
module V1
module Helpers
class ImpersonationError < StandardError; end
# Impersonate a user if requested
# Note: only administrators can impersonate other users
# @param impersonated_user_id [Integer] the user id to impersonate
# @param current_user_id [Integer] the current user id (the one making the request with the API key)
def impersonate_user_if_requested(impersonated_user_id, current_user_id)
return current_user_id unless impersonated_user_id.present?
raise ImpersonationError, "You are not allowed to impersonate other users." unless User.find_by(id: current_user_id).admin?
raise ImpersonationError, "Could not find the user to impersonate." unless User.find_by(id: impersonated_user_id).present?
impersonated_user_id
end
end
end
end

View File

@@ -0,0 +1,79 @@
module Api
module V1
class LikesController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List likes
def index
likes = Like
.includes(:user)
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
likes = likes.where(post_id: params[:post_id]) if params[:post_id].present?
authorize([:api, Like])
render json: likes.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Show a like
def show
like = Like
.includes(:user)
.find_by(id: params[:id])
unless like
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
end
authorize([:api, like])
render json: like.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create like
def create
like = Like.new(like_params)
authorize([:api, like])
like.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if like.save
render json: { id: like.id }, status: :created
else
render json: { errors: like.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete like
def destroy
like = Like.find_by(id: params[:id])
unless like
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
end
authorize([:api, like])
like.destroy!
render json: { id: like.id }, status: :ok
end
private
def like_params
params.permit(:post_id)
end
end
end
end

View File

@@ -0,0 +1,16 @@
module Api
module V1
class PostStatusesController < BaseController
include Api::V1::Serializers
# List all post statuses
def index
post_statuses = PostStatus.all
authorize([:api, PostStatus])
render json: post_statuses.map { |post_status| post_status.slice(*POST_STATUS_JSON_ATTRIBUTES) }
end
end
end
end

View File

@@ -0,0 +1,192 @@
module Api
module V1
class PostsController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List posts
def index
posts = Post
.includes(:board, :post_status, :user)
.order(created_at: :desc)
.limit(params[:limit] || 20)
.offset(params[:offset] || 0)
posts = posts.where(board_id: params[:board_id]) if params[:board_id].present?
posts = posts.where(user_id: params[:user_id]) if params[:user_id].present?
authorize([:api, Post])
render json: posts.as_json(only: POST_JSON_ATTRIBUTES, include: {
board: { only: BOARD_JSON_ATTRIBUTES },
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Get a post by id
def show
post = Post
.includes(:board, :post_status, :user)
.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
render json: post.as_json(only: POST_JSON_ATTRIBUTES, include: {
board: { only: BOARD_JSON_ATTRIBUTES },
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create a new post
def create
post = Post.new(post_params)
authorize([:api, post])
post.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if post.save
render json: { id: post.id }, status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
# Update a post
def update
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
if post.update(post_update_params)
render json: { id: post.id }, status: :ok
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete a post
def destroy
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
post.destroy!
render json: { id: post.id }, status: :ok
end
# Update post board
def update_board
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
post.update!(post_update_board_params)
render json: { id: post.id }, status: :ok
end
# Update post status
def update_status
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
post.update!(post_update_status_params)
if post.post_status_id_previously_changed?
ExecutePostStatusChangeLogicWorkflow.new(
user_id: user_id,
post: post,
post_status_id: post.post_status_id
).run
end
render json: { id: post.id }, status: :ok
end
# Approve post
def approve
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
unless post.approval_status == 'pending'
raise StandardError, "Post with id #{params[:id]} is not pending approval"
end
authorize([:api, post])
post.update!(approval_status: 'approved')
render json: { id: post.id }, status: :ok
end
# Reject post
def reject
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
unless post.approval_status == 'pending'
raise StandardError, "Post with id #{params[:id]} is not pending approval"
end
authorize([:api, post])
post.update!(approval_status: 'rejected')
render json: { id: post.id }, status: :ok
end
private
def post_params
params.require(:title)
params.permit(:title, :description, :board_id)
end
def post_update_params
params.permit(:title, :description)
end
def post_update_board_params
params.require(:board_id)
params.permit(:board_id)
end
def post_update_status_params
params.permit(:post_status_id)
end
end
end
end

View File

@@ -0,0 +1,62 @@
module Api
module V1
module Serializers
BOARD_JSON_ATTRIBUTES = [
:id,
:name,
:slug,
:description,
:created_at,
:updated_at
].freeze
COMMENT_JSON_ATTRIBUTES = [
:id,
:body,
:is_post_update,
:post_id,
:user,
:created_at,
:updated_at
].freeze
POST_STATUS_JSON_ATTRIBUTES = [
:id,
:name,
:color,
:show_in_roadmap,
:created_at,
:updated_at
].freeze
POST_JSON_ATTRIBUTES = [
:id,
:title,
:description,
:board,
:post_status,
:user,
:approval_status,
:slug,
:created_at,
:updated_at
].freeze
USER_JSON_ATTRIBUTES = [
:id,
:email,
:full_name,
:created_at,
:updated_at
].freeze
LIKE_JSON_ATTRIBUTES = [
:id,
:user,
:post_id,
:created_at,
:updated_at
].freeze
end
end
end

View File

@@ -0,0 +1,91 @@
module Api
module V1
class UsersController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List users
def index
users = User
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
authorize([:api, User])
render json: users.as_json(only: USER_JSON_ATTRIBUTES)
end
# Get user by id
def show
user = User.find_by(id: params[:id])
unless user
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
end
authorize([:api, user])
render json: user.slice(*USER_JSON_ATTRIBUTES)
end
# Get user by email
def show_by_email
user = User.find_by(email: params[:email])
unless user
raise ActiveRecord::RecordNotFound, "User with email #{params[:email]} not found"
end
authorize([:api, user])
render json: user.slice(*USER_JSON_ATTRIBUTES)
end
# Create user
def create
# Check whether user already exists and return its id
user = User.find_by(email: params[:email])
if user
render json: { id: user.id }, status: :ok
return
end
# ... otherwise, create a new user
user = User.new(
email: params[:email],
full_name: params[:full_name] || params[:email],
password: Devise.friendly_token,
has_set_password: false,
status: 'active'
)
user.skip_confirmation
authorize([:api, user])
if user.save
render json: { id: user.id }, status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
# Block user
def block
user = User.find_by(id: params[:id])
unless user
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
end
authorize([:api, user])
user.update!(status: 'blocked')
render json: { id: user.id }, status: :ok
end
end
end
end

View File

@@ -0,0 +1,19 @@
class ApiKeysController < ApplicationController
before_action :authenticate_user!
def create
current_user.api_key&.destroy # Destroy existing API key
@api_key = ApiKey.new(user: current_user)
authorize @api_key
if @api_key.save
render json: { api_key: @api_key.token }, status: :created
else
render json: {
errors: @api_key.errors.full_messages
}, status: :unprocessable_entity
end
end
end

View File

@@ -108,15 +108,11 @@ class PostsController < ApplicationController
if @post.save
if @post.post_status_id_previously_changed?
PostStatusChange.create(
ExecutePostStatusChangeLogicWorkflow.new(
user_id: current_user.id,
post_id: @post.id,
post: @post,
post_status_id: @post.post_status_id
)
@post.followers.each do |follower|
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
end
).run
end
render json: @post

View File

@@ -6,7 +6,7 @@ class SessionsController < Devise::SessionsController
def new
# Update return_to path if not coming from Devise user pages
if request.referer.present? && !request.referer.include?('/users/')
if request.referer.present? && !request.referer.include?('/users')
session[:return_to] = request.referer
end

View File

@@ -20,9 +20,15 @@ class UsersController < ApplicationController
# Handle special case: trying to set user role to 'owner'
raise Pundit::NotAuthorizedError if @user.owner?
if @user.save
render json: @user
else
ActiveRecord::Base.transaction do
DestroyApiKeyIfNeededWorkflow.new(user: @user).run
if @user.save
render json: @user
else
raise ActiveRecord::Rollback
end
rescue ActiveRecord::Rollback
render json: {
error: @user.errors.full_messages
}, status: :unprocessable_entity

View File

@@ -0,0 +1,5 @@
module ApiKeysHelper
def token_mask(prefix, length = 30)
"#{prefix}#{""*length}"
end
end

View File

@@ -0,0 +1,97 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { DangerText, SmallMutedText, SuccessText } from '../common/CustomTexts';
import Button from '../common/Button';
import CopyToClipboardButton from '../common/CopyToClipboardButton';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink';
import { LearnMoreIcon } from '../common/Icons';
interface Props {
currentApiKey?: string;
generateApiKeyEndpoint: string;
authenticityToken: string;
}
const GenerateApiKeyDialog = ({
currentApiKey,
generateApiKeyEndpoint,
authenticityToken,
}: Props) => {
const [hasBeenGenerated, setHasBeenGenerated] = React.useState(false);
const [apiKey, setApiKey] = React.useState('');
const [error, setError] = React.useState('');
return (
<>
<h3>{I18n.t('common.forms.api_key.title')}</h3>
{
(currentApiKey && !hasBeenGenerated) &&
<>
<input type="disabled" readOnly value={currentApiKey} className="form-control" />
<SmallMutedText>{I18n.t('common.forms.api_key.current_api_key_help')}</SmallMutedText>
</>
}
{
hasBeenGenerated ?
<>
<input type="disabled" readOnly value={apiKey} className="form-control" />
<CopyToClipboardButton
label={I18n.t('common.buttons.copy_to_clipboard')}
textToCopy={apiKey}
copiedLabel={I18n.t('common.copied')}
/>
<SmallMutedText>{I18n.t('common.forms.api_key.generated_api_key_help')}</SmallMutedText>
<br />
<SuccessText>{I18n.t('common.forms.api_key.generated_api_key_successfully')}</SuccessText>
</>
:
<>
<br />
<Button
onClick={async () => {
// If there is already an API key, ask for confirmation before generating a new one
if (currentApiKey) {
const confirmation = confirm(I18n.t('common.forms.api_key.confirm_generate_new_api_key'));
if (!confirmation) return;
}
// Generate a new API key
const res = await fetch(generateApiKeyEndpoint, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
});
if (res.status === HttpStatus.Created) {
const json = await res.json();
setApiKey(json.api_key);
setHasBeenGenerated(true);
} else {
setError(I18n.t('errors.unknown'));
}
}}
className="btnPrimary apiKeyGenerateButton"
>
{I18n.t('common.forms.api_key.generate_api_key')}
</Button>
</>
}
<br /><br />
<ActionLink
icon={<LearnMoreIcon />}
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
>
{I18n.t('common.forms.api_key.api_key_learn_more')}
</ActionLink>
{ error && <DangerText>{error}</DangerText> }
</>
);
};
export default GenerateApiKeyDialog;

56
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,56 @@
class ApiKey < ApplicationRecord
include TenantOwnable
HMAC_SECRET_KEY = Rails.application.secrets.secret_key_base
TOKEN_NAMESPACE = 'tkn'
belongs_to :user
before_validation :set_common_token_prefix, on: :create
before_validation :generate_random_token_prefix, on: :create
before_validation :generate_token, on: :create
before_validation :generate_token_digest, on: :create
# The non-hashed token
attr_accessor :token
def self.find_by_token!(token)
find_by!(token_digest: digest(token))
end
def self.find_by_token(token)
find_by(token_digest: digest(token))
end
def self.digest(token)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), HMAC_SECRET_KEY, token)
end
def token_prefix
[common_token_prefix, random_token_prefix].join("")
end
private
def set_common_token_prefix
if user.role == 'owner' || user.role == 'admin'
user_role = 'admin'
elsif user.role == 'moderator'
user_role = 'mod'
end
self.common_token_prefix = "#{TOKEN_NAMESPACE}_#{user_role}_"
end
def generate_random_token_prefix
self.random_token_prefix = SecureRandom.base58(6)
end
def generate_token
self.token = [common_token_prefix, random_token_prefix, SecureRandom.base58(24)].join("")
end
def generate_token_digest
self.token_digest = self.class.digest(token)
end
end

View File

@@ -10,6 +10,7 @@ class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy
has_many :comments, dependent: :destroy
has_one :api_key, dependent: :destroy
enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted]

View File

@@ -0,0 +1,10 @@
module Api
class BasePolicy
attr_reader :api_key, :record
def initialize(api_key, record)
@api_key = api_key
@record = record
end
end
end

View File

@@ -0,0 +1,15 @@
module Api
class BoardPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.admin?
end
end
end

View File

@@ -0,0 +1,31 @@
module Api
class CommentPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def update?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
def mark_as_post_update?
api_key.user.moderator?
end
def unmark_as_post_update?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,19 @@
module Api
class LikePolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,39 @@
module Api
class PostPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def update?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
def update_board?
api_key.user.moderator?
end
def update_status?
api_key.user.moderator?
end
def approve?
api_key.user.moderator?
end
def reject?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,7 @@
module Api
class PostStatusPolicy < BasePolicy
def index?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,27 @@
module Api
class UserPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def show_by_email?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
# Moderators can block users
# Admins can block everyone except the owner
# Owner can block everyone
# Users can't block themselves
def block?
(api_key.user.id != record.id) && ((api_key.user.moderator? && !record.moderator?) || (api_key.user.admin? && !record.owner?) || api_key.user.owner?)
end
end
end

View File

@@ -0,0 +1,5 @@
class ApiKeyPolicy < ApplicationPolicy
def create?
user.moderator? && user == record.user
end
end

View File

@@ -75,6 +75,22 @@
<% end %>
<% end %>
<% if current_user.moderator? %>
<br />
<div class="edit_user">
<%=
react_component(
'UserProfile/GenerateApiKeyDialog',
{
currentApiKey: current_user.api_key.present? ? token_mask(current_user.api_key.token_prefix) : nil,
generateApiKeyEndpoint: api_keys_path,
authenticityToken: form_authenticity_token
},
)
%>
</div>
<% end %>
<br />
<div class="edit_user">

View File

@@ -0,0 +1,21 @@
# This class is responsible for destroying the API key of a user if they get demoted or blocked
class DestroyApiKeyIfNeededWorkflow
def initialize(user:)
@user = user
end
def run
if @user.role_changed?
# If user gets demoted, remove their API key
if (@user.role_was == 'admin' && @user.role == 'moderator') || (@user.role_was == 'moderator' && @user.role == 'user')
@user.api_key&.destroy
end
elsif @user.status_changed?
# If user gets blocked, remove their API key
if @user.blocked? || @user.deleted?
@user.api_key&.destroy
end
end
end
end

View File

@@ -0,0 +1,23 @@
class ExecutePostStatusChangeLogicWorkflow
attr_accessor :user_id, :post, :post_status_id
def initialize(user_id:, post:, post_status_id:)
@user_id = user_id
@post = post
@post_status_id = post_status_id
end
def run
PostStatusChange.create(
user_id: @user_id,
post_id: @post.id,
post_status_id: @post_status_id
)
unless @post_status_id.nil?
@post.followers.each do |follower|
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
end
end
end
end

View File

@@ -0,0 +1,10 @@
Rails.application.config.middleware.insert_before 0, Rack::Cors do
# Allow requests from any origin to the API documentation
# Needed for the Swagger UI (Redocusaurus in astuto-docs) to work
allow do
origins '*'
resource '/api-docs/*',
headers: :any,
methods: [:get, :post, :options, :put, :delete]
end
end

View File

@@ -9,11 +9,26 @@ class Rack::Attack
# counted by rack-attack and this throttle may be activated too
# quickly. If so, enable the condition to exclude them from tracking.
# Throttle all requests by IP (60rpm)
# Throttle all requests by IP (60rpm) except API endpoints requests
#
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
throttle('req/ip', limit: 300, period: 5.minutes) do |req|
req.get_header("action_dispatch.remote_ip") # unless req.path.start_with?('/assets')
req.get_header("action_dispatch.remote_ip") unless req.path.start_with?('/api/v1/')
end
# Throttle requests to API endpoints by IP address
throttle('api/ip', limit: 100, period: 5.minutes) do |req|
if req.path.start_with?('/api/v1/')
req.get_header("action_dispatch.remote_ip")
end
end
# Throttle requests to API endpoints by api key
throttle('api/key', limit: 100, period: 5.minutes) do |req|
if req.path.start_with?('/api/v1/')
authorization = req.get_header("HTTP_AUTHORIZATION")
authorization&.split(' ')&.last if authorization.present?
end
end
### Prevent Brute-Force Login Attacks ###

View File

@@ -12,5 +12,6 @@ RESERVED_SUBDOMAINS = [
'analytics',
'cname',
'whatever',
'billing'
'billing',
'api',
]

View File

@@ -0,0 +1,14 @@
Rswag::Api.configure do |c|
# Specify a root folder where Swagger JSON files are located
# This is used by the Swagger middleware to serve requests for API descriptions
# NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure
# that it's configured to generate files in the same folder
c.openapi_root = Rails.root.to_s + '/swagger'
# Inject a lambda function to alter the returned Swagger prior to serialization
# The function will have access to the rack env for the current request
# For example, you could leverage this to dynamically assign the "host" property
#
# c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] }
end

View File

@@ -43,6 +43,14 @@ en:
change_password: 'Change password'
password_help: '%{count} characters minimum'
email_registration_not_allowed: 'Email registration not allowed'
api_key:
title: 'API key'
current_api_key_help: 'You have already generated an API key and, for security reasons, it cannot be shown again. If you lost it, you can generate a new one below.'
generate_api_key: 'Generate API key'
generated_api_key_help: 'Save this API key in a safe place, as you will not be able to see it again.'
generated_api_key_successfully: 'API key generated successfully!'
confirm_generate_new_api_key: 'Are you sure you want to generate a new API key? The old one will be invalidated.'
api_key_learn_more: 'Learn how to use the API'
comments_number:
one: '1 comment'
other: '%{count} comments'

View File

@@ -1,9 +1,17 @@
Rails.application.routes.draw do
if !Rails.application.multi_tenancy?
mount Rswag::Api::Engine => '/api-docs'
end
if Rails.application.multi_tenancy?
constraints subdomain: 'showcase' do
root to: 'static_pages#showcase', as: :showcase
end
constraints subdomain: 'api' do
mount Rswag::Api::Engine => '/api-docs'
end
constraints subdomain: 'login' do
get '/signup', to: 'tenants#new'
get '/is_available', to: 'tenants#is_available'
@@ -70,6 +78,8 @@ Rails.application.routes.draw do
resources :invitations, only: [:create]
post '/invitations/test', to: 'invitations#test', as: :invitation_test
resources :api_keys, only: [:create]
namespace :site_settings do
get 'general'
@@ -85,6 +95,33 @@ Rails.application.routes.draw do
get 'feedback'
get 'users'
end
# API routes
namespace :api do
namespace :v1 do
resources :boards, only: [:index, :show, :create]
resources :post_statuses, only: [:index]
resources :posts, only: [:index, :show, :create, :update, :destroy] do
member do
put :update_board, :update_status, :approve, :reject
end
end
resources :comments, only: [:index, :show, :create, :update, :destroy] do
member do
put :mark_as_post_update, :unmark_as_post_update
end
end
resources :votes, only: [:index, :show, :create, :destroy], controller: 'likes'
resources :users, only: [:index, :show, :create] do
collection do
get :get_by_email, controller: 'users', action: 'show_by_email'
end
member do
put :block
end
end
end
end
end
# Healthcheck endpoint

View File

@@ -0,0 +1,17 @@
class CreateApiKeys < ActiveRecord::Migration[6.1]
def change
create_table :api_keys do |t|
t.references :tenant, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :common_token_prefix, null: false
t.string :random_token_prefix, null: false
t.string :token_digest, null: false
t.timestamps
t.index :token_digest, unique: true
t.index [:user_id, :tenant_id], unique: true
end
end
end

View File

@@ -10,11 +10,25 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_09_17_140122) do
ActiveRecord::Schema.define(version: 2024_10_04_170520) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
create_table "api_keys", force: :cascade do |t|
t.bigint "tenant_id", null: false
t.bigint "user_id", null: false
t.string "common_token_prefix", null: false
t.string "random_token_prefix", null: false
t.string "token_digest", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["tenant_id"], name: "index_api_keys_on_tenant_id"
t.index ["token_digest"], name: "index_api_keys_on_token_digest", unique: true
t.index ["user_id", "tenant_id"], name: "index_api_keys_on_user_id_and_tenant_id", unique: true
t.index ["user_id"], name: "index_api_keys_on_user_id"
end
create_table "boards", force: :cascade do |t|
t.string "name", null: false
t.text "description"
@@ -231,6 +245,8 @@ ActiveRecord::Schema.define(version: 2024_09_17_140122) do
t.index ["tenant_id"], name: "index_users_on_tenant_id"
end
add_foreign_key "api_keys", "tenants"
add_foreign_key "api_keys", "users"
add_foreign_key "boards", "tenants"
add_foreign_key "comments", "comments", column: "parent_id"
add_foreign_key "comments", "posts"

View File

@@ -7,5 +7,10 @@ sh docker-entrypoint.sh
# Needed to run .bin/dev
yarn install --check-files
# Generate Swagger documentation (don't know why it's not passed from container to host)
echo "Generating Swagger documentation..."
bundle exec rake rswag:specs:swaggerize
echo "Swagger documentation generated."
# Launch Rails server + yarn build:css + yarn build
./bin/dev

View File

@@ -26,4 +26,4 @@ if [ "$db_version" = "Current version: 0" ]; then
else
bundle exec rake db:migrate
fi
echo "Database prepared."
echo "Database prepared."

149
spec/api/v1/boards_spec.rb Normal file
View File

@@ -0,0 +1,149 @@
require 'swagger_helper'
RSpec.describe 'api/v1/boards', type: :request do
include_context 'API Authentication'
before(:each) do
@board = FactoryBot.create(:board)
end
path '/api/v1/boards' do
get('List boards') do
tags 'Boards'
description 'List all boards.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/Board' }
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
post('Create board') do
tags 'Boards'
description 'Create a new board.<br><br><b>Note</b>: requires admin role.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :board, in: :body, schema: {
type: :object,
properties: {
name: { type: :string, description: 'Name of the board' },
slug: { type: :string, nullable: true, description: 'URL-friendly identifier for the board (optional; if not provided, one will be created automatically from provided board name)' },
description: { type: :string, nullable: true, description: 'Description of the board (optional)' },
},
required: ['name']
}
response(201, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:board) { { name: 'New board' } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@board_count_before = Board.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
data = JSON.parse(response.body)
expect(Board.count).to eq(@board_count_before + 1)
expect(Board.find(data['id'])).to be_present
end
end
response(400, 'bad request') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:board) { { description: 'Only description, not name' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:board) { { name: 'New board' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/boards/{id}' do
get('Get board') do
tags 'Boards'
description 'Get the specified board.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :string, required: true, description: 'ID or slug of the board'
# Test with slug
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @board.slug }
schema '$ref' => '#/components/schemas/Board'
run_test! do |response|
data = JSON.parse(response.body)
expect(data['id']).to eq(@board.id)
expect(data['name']).to eq(@board.name)
end
end
# Test with id
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @board.id }
schema '$ref' => '#/components/schemas/Board'
run_test! do |response|
data = JSON.parse(response.body)
expect(data['id']).to eq(@board.id)
expect(data['name']).to eq(@board.name)
end
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 'invalid-id' }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @board.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

View File

@@ -0,0 +1,396 @@
require 'swagger_helper'
RSpec.describe 'api/v1/comments', type: :request do
include_context 'API Authentication'
before(:each) do
@post_1 = FactoryBot.create(:post)
@post_2 = FactoryBot.create(:post)
@comment_1 = FactoryBot.create(:comment, post: @post_1)
@comment_2 = FactoryBot.create(:comment, post: @post_1)
end
path '/api/v1/comments' do
get('List comments') do
tags 'Comments'
description 'List comments with optional filters. In particular, you may want to filter by post_id to get comments for a specific post. Comments are returned from newest to oldest.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :post_id, in: :query, type: :integer, required: false, description: 'Return only comments for the specified post.'
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of comments to return. Defaults to 100.'
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of comments to return. Defaults to 0.'
# No filters
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/Comment' }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@comments_number = Comment.count
end
run_test! do |response|
data = JSON.parse(response.body)
expect(data.length).to eq(@comments_number)
# Check that the comments are ordered by created_at desc
expect(data[0]['id']).to eq(@comment_2.id)
expect(data[1]['id']).to eq(@comment_1.id)
end
end
# Filter by post_id
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:post_id) { @post_1.id }
schema type: :array, items: { '$ref' => '#/components/schemas/Comment' }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@comments_number = Comment.where(post_id: @post_1.id).count
end
run_test! do |response|
data = JSON.parse(response.body)
expect(data.length).to eq(@comments_number)
end
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
post('Create a comment') do
tags 'Comments'
description 'Create a new comment.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :comment, in: :body, schema: {
type: :object,
properties: {
body: { type: :string, description: 'Content of the comment' },
post_id: { type: :integer, description: 'ID of the post the comment belongs to' },
parent_id: { type: :integer, nullable: true, description: 'ID of the parent comment if this is a comment reply' },
is_post_update: { type: :boolean, nullable: true, description: 'Whether the comment is a post update or not' },
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
},
required: %w[body post_id]
}
response(201, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:comment) { { body: 'This is a comment', post_id: @post_1.id } }
schema type: :object, properties: { id: { type: :integer } }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@comment_count_before = Comment.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_comment = Comment.find(JSON.parse(response.body)['id'])
expect(Comment.count).to eq(@comment_count_before + 1)
expect(created_comment.body).to eq(comment[:body])
expect(created_comment.post_id).to eq(comment[:post_id])
end
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:comment) { { body: 'This is a comment', post_id: @post_1.id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(422, 'Unprocessable entity') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:comment) { { body: '', post_id: @post_1.id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
# Impersonation works for admin users
response(201, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:comment) { { body: 'This is a comment', post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
schema type: :object, properties: { id: { type: :integer } }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@comment_count_before = Comment.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_comment = Comment.find(JSON.parse(response.body)['id'])
expect(Comment.count).to eq(@comment_count_before + 1)
expect(created_comment.body).to eq(comment[:body])
expect(created_comment.post_id).to eq(comment[:post_id])
expect(created_comment.user_id).to eq(comment[:impersonated_user_id])
end
end
# Impersonation does not work for non-admin users
response(401, 'Unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:comment) { { body: 'This is a comment', post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/comments/{id}' do
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
get('Get a comment') do
tags 'Comments'
description 'Get a comment by id.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
schema '$ref' => '#/components/schemas/Comment'
run_test!
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @comment_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
put('Update a comment') do
tags 'Comments'
description 'Update a comment by id.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :comment, in: :body, schema: {
type: :object,
properties: {
body: { type: :string, description: 'Content of the comment' }
},
required: %w[body]
}
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
let(:comment) { { body: 'Updated comment' } }
schema type: :object, properties: { id: { type: :integer } }
run_test! do |response|
@comment_1.reload
expect(@comment_1.body).to eq(comment[:body])
end
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
let(:comment) { { body: 'Updated comment' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @comment_1.id }
let(:comment) { { body: 'Updated comment' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(422, 'Unprocessable entity') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
let(:comment) { { body: '' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
delete('Delete a comment') do
tags 'Comments'
description 'Delete a comment by id.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
schema type: :object, properties: { id: { type: :integer } }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
expect(Comment.find_by(id: @comment_1.id)).to be_nil
end
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @comment_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/comments/{id}/mark_as_post_update' do
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
put('Mark comment as post update') do
tags 'Comments'
description 'Mark a comment as a post update.<br><br><b>Note</b>: email notification to post followers will NOT be sent when using this endpoint. To send email notifications, use the "Create a comment" endpoint with the "is_post_update" parameter set to true.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
schema type: :object, properties: { id: { type: :integer } }
before do
@comment_1.update!(is_post_update: false)
end
run_test! do |response|
@comment_1.reload
expect(@comment_1.is_post_update).to eq(true)
end
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @comment_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/comments/{id}/unmark_as_post_update' do
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
put('Unmark comment as post update') do
tags 'Comments'
description 'Unmark a comment as a post update.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @comment_1.id }
schema type: :object, properties: { id: { type: :integer } }
before do
@comment_1.update!(is_post_update: true)
end
run_test! do |response|
@comment_1.reload
expect(@comment_1.is_post_update).to eq(false)
end
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @comment_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

239
spec/api/v1/likes_spec.rb Normal file
View File

@@ -0,0 +1,239 @@
require 'swagger_helper'
RSpec.describe 'api/v1/votes', type: :request do
include_context 'API Authentication'
before(:each) do
@post_1 = FactoryBot.create(:post)
@post_2 = FactoryBot.create(:post)
@like_1 = FactoryBot.create(:like, post: @post_1)
@like_2 = FactoryBot.create(:like, post: @post_2)
end
path '/api/v1/votes' do
get('List votes') do
tags 'Votes'
description 'List votes with optional filters. In particular, you may want to filter by post_id to get votes for a specific post. Votes are returned from newest to oldest.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :post_id, in: :query, type: :integer, required: false, description: 'Return only votes for the specified post.'
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Limit the number of votes returned. Defaults to 100.'
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of votes to return. Defaults to 0.'
# No filters
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/Vote' }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@likes_number = Like.count
end
run_test! do |response|
data = JSON.parse(response.body)
expect(data.length).to eq(@likes_number)
# Check that the likes are ordered by created_at desc
expect(data[0]['id']).to eq(@like_2.id)
expect(data[1]['id']).to eq(@like_1.id)
end
end
# Filter by post_id
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:post_id) { @post_1.id }
schema type: :array, items: { '$ref' => '#/components/schemas/Vote' }
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@likes_number = Like.where(post_id: @post_1.id).count
end
run_test! do |response|
data = JSON.parse(response.body)
expect(data.length).to eq(@likes_number)
end
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
post('Create a vote') do
tags 'Votes'
description 'Create a new vote.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :vote, in: :body, schema: {
type: :object,
properties: {
post_id: { type: :integer, description: 'ID of the post the vote belongs to' }
},
required: ['post_id']
}
response(201, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:vote) { { post_id: @post_1.id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@like_count_before = Like.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_like = Like.find(JSON.parse(response.body)['id'])
expect(Like.count).to eq(@like_count_before + 1)
expect(created_like.post_id).to eq(vote[:post_id])
end
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:vote) { { post_id: @post_1.id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(422, 'Unprocessable entity') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:vote) { { post_id: nil } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
# Impersonation works for admin users
response(201, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:vote) { { post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@like_count_before = Like.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_like = Like.find(JSON.parse(response.body)['id'])
expect(Like.count).to eq(@like_count_before + 1)
expect(created_like.user_id).to eq(vote[:impersonated_user_id])
end
end
# Impersonation does not work for moderator users
response(401, 'Unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:vote) { { post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Error'
end
end
end
path '/api/v1/votes/{id}' do
get('Show a vote') do
tags 'Votes'
description 'Show a vote by ID.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the vote to show.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @like_1.id }
schema '$ref' => '#/components/schemas/Vote'
run_test!
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @like_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
delete('Delete a vote') do
tags 'Votes'
description 'Delete a vote by ID.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the vote to delete.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @like_1.id }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@like_count_before = Like.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
expect(Like.count).to eq(@like_count_before - 1)
expect { Like.find(id) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
response(404, 'Not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
let(:id) { @like_1.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

View File

@@ -0,0 +1,42 @@
require 'swagger_helper'
RSpec.describe 'api/v1/post_statuses', type: :request do
include_context 'API Authentication'
before(:each) do
@post_status_1 = FactoryBot.create(:post_status)
@post_status_2 = FactoryBot.create(:post_status)
end
path '/api/v1/post_statuses' do
get('List post statuses') do
tags 'Post Statuses'
description 'List all post statuses.'
security [{ api_key: [] }]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/PostStatus' }
run_test! do |response|
data = JSON.parse(response.body)
expect(data.size).to eq(2)
expect(data[0]['id']).to eq(@post_status_1.id)
expect(data[1]['id']).to eq(@post_status_2.id)
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

532
spec/api/v1/posts_spec.rb Normal file
View File

@@ -0,0 +1,532 @@
require 'swagger_helper'
RSpec.describe 'api/v1/posts', type: :request do
include_context 'API Authentication'
before(:each) do
@post = FactoryBot.create(:post)
@pending_post = FactoryBot.create(:post, approval_status: 'pending')
end
path '/api/v1/posts' do
get('List posts') do
tags 'Posts'
description 'List posts with optional filters. Posts are returned from newest to oldest.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of posts to return. Defaults to 20.'
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of posts to return. Defaults to 0.'
parameter name: :board_id, in: :query, type: :integer, required: false, description: 'If specified, only posts from this board will be returned.'
parameter name: :user_id, in: :query, type: :integer, required: false, description: 'If specified, only posts from this user will be returned.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/Post' }
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
post('Create post') do
tags 'Posts'
description 'Create a new post.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :post_parameter, in: :body, schema: {
type: :object,
properties: {
title: { type: :string, description: 'Title of the post' },
description: { type: :string, description: 'Content of the post' },
board_id: { type: :integer, description: 'ID of the board where the post will be created' },
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
},
required: %w[title board_id]
}
response(201, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@post_count_before = Post.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_post = Post.find(JSON.parse(response.body)['id'])
expect(Post.count).to eq(@post_count_before + 1)
expect(created_post.title).to eq(post_parameter[:title])
expect(created_post.board_id).to eq(post_parameter[:board_id])
expect(created_post.user_id).to eq(@moderator.id)
end
end
response(400, 'bad request') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:post_parameter) { { title: nil } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
# Impersonation works for admin users
response(201, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@post_count_before = Post.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_post = Post.find(JSON.parse(response.body)['id'])
expect(Post.count).to eq(@post_count_before + 1)
expect(created_post.title).to eq(post_parameter[:title])
expect(created_post.board_id).to eq(post_parameter[:board_id])
expect(created_post.user_id).to eq(post_parameter[:impersonated_user_id])
end
end
# Impersonation doesn't work for non-admin users
response(401, 'unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/posts/{id}' do
get('Get a post') do
tags 'Posts'
description 'Get a post by id.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
schema '$ref' => '#/components/schemas/Post'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @post.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
put('Update a post') do
tags 'Posts'
description 'Update a post.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
parameter name: :post, in: :body, schema: {
type: :object,
properties: {
title: { type: :string, description: 'New title of the post' },
description: { type: :string, description: 'New content of the post' }
},
}
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
let(:post) { { title: 'New title', description: 'New description' } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@post.title).not_to eq('New title')
expect(@post.description).not_to eq('New description')
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@post.id)
expect(@post.title).to eq('New title')
expect(@post.description).to eq('New description')
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @post.id }
let(:post) { { title: 'New title', description: 'New description' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
let(:post) { { title: 'New title', description: 'New description' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(422, 'unprocessable entity') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
let(:post) { { title: nil, description: nil } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
delete('Delete a post') do
tags 'Posts'
description 'Delete a post by id.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(Post.find_by(id: @post.id)).to be_present
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
expect(Post.find_by(id: @post.id)).to be_nil
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @post.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/posts/{id}/update_board' do
put('Update post board') do
tags 'Posts'
description 'Move post to another board.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
parameter name: :post, in: :body, schema: {
type: :object,
properties: {
board_id: { type: :integer, description: 'ID of the new board' }
},
required: %w[board_id]
}
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
let(:post) { { board_id: FactoryBot.create(:board).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@post.board_id).not_to eq(post[:board_id])
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@post.id)
expect(@post.board_id).to eq(post[:board_id])
end
end
response(400, 'bad request') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
let(:post) { { board_id: nil } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @post.id }
let(:post) { { board_id: FactoryBot.create(:board).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
let(:post) { { board_id: FactoryBot.create(:board).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/posts/{id}/update_status' do
put('Update post status') do
tags 'Posts'
description 'Update post status.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
parameter name: :post, in: :body, schema: {
type: :object,
properties: {
post_status_id: { type: :integer, description: 'ID of the new post status. Send null if you want to remove current status.' },
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
},
required: %w[post_status_id]
}
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @post.id }
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@post.post_status_id).not_to eq(post[:post_status_id])
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@post.id)
@post_status_change = PostStatusChange.where(post_id: @post.id).order(created_at: :desc).first
expect(@post.post_status_id).to eq(post[:post_status_id])
expect(@post_status_change.user_id).to eq(@moderator.id)
end
end
# Impersonation
response(200, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:id) { @post.id }
let(:post) { { post_status_id: FactoryBot.create(:post_status).id, impersonated_user_id: FactoryBot.create(:user).id } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@post.post_status_id).not_to eq(post[:post_status_id])
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@post.id)
@post_status_change = PostStatusChange.where(post_id: @post.id).order(created_at: :desc).first
expect(@post.post_status_id).to eq(post[:post_status_id])
expect(@post_status_change.user_id).to eq(post[:impersonated_user_id])
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @post.id }
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/posts/{id}/approve' do
put('Approve post') do
tags 'Posts'
description 'Approve a post that is pending approval.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @pending_post.id }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@pending_post.approval_status).to eq('pending')
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@pending_post.id)
expect(@post.approval_status).to eq('approved')
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @pending_post.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/posts/{id}/reject' do
put('Reject post') do
tags 'Posts'
description 'Reject a post that is pending approval.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @pending_post.id }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
expect(@pending_post.approval_status).to eq('pending')
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
@post = Post.find(@pending_post.id)
expect(@post.approval_status).to eq('rejected')
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @pending_post.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { -1 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

263
spec/api/v1/users_spec.rb Normal file
View File

@@ -0,0 +1,263 @@
require 'swagger_helper'
RSpec.describe 'api/v1/users', type: :request do
include_context 'API Authentication'
before(:each) do
@user = FactoryBot.create(:user)
@admin = FactoryBot.create(:admin)
@moderator = FactoryBot.create(:moderator)
end
path '/api/v1/users' do
get('List users') do
tags 'Users'
description 'List users with optional filters. Users are returned from newest to oldest.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of users to return. Defaults to 50.'
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of users to return. Defaults to 0.'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
schema type: :array, items: { '$ref' => '#/components/schemas/User' }
run_test!
end
response(401, 'Unauthorized') do
let(:Authorization) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
post('Create/get user') do
tags 'Users'
description 'Create a new user or, if it already exists, get it.<br><br>This endpoint is useful for the impersonation technique.<br>In the case of creation, a password will be randomly generated so the user will be able to log in using an OAuth provider or by email after resetting the password.<br>The full_name parameter is optional, but it is recommended to provide it if you are creating a new user.'
security [{ api_key: [] }]
consumes 'application/json'
produces 'application/json'
parameter name: :user, in: :body, schema: {
type: :object,
properties: {
email: { type: :string, description: 'Email of the user' },
full_name: { type: :string, description: 'Full name of the user' }
},
required: %w[email]
}
# Create user if it does not exist
response(201, 'created') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:user) { { email: 'new-user@example.com', full_name: 'New User' } }
schema '$ref' => '#/components/schemas/Id'
before do
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
@user_count_before = User.count
end
run_test! do |response|
Current.tenant = @current_tenant # Restore the current tenant
created_user = User.find(JSON.parse(response.body)['id'])
expect(User.count).to eq(@user_count_before + 1)
expect(created_user.email).to eq(user[:email])
expect(created_user.full_name).to eq(user[:full_name])
end
end
# Return existing user ID if it already exists
response(200, 'ok') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:user) { { email: @user.email } }
schema '$ref' => '#/components/schemas/Id'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:user) { { email: 'new-user@example.com', full_name: 'New User' } }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/users/{id}' do
get('Get user') do
tags 'Users'
description 'Get a user by id.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :string, required: true, description: 'User ID'
# With user ID
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @user.id }
schema '$ref' => '#/components/schemas/User'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @user.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/users/get_by_email' do
get('Get user by email') do
tags 'Users'
description 'Get a user by email. You can specify email both as a query parameter or in the request body.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :email, in: :query, type: :string, required: true, description: 'User email'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:email) { @user.email }
schema '$ref' => '#/components/schemas/User'
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:email) { @user.email }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:email) { '' }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/users/{id}/block' do
put('Block user') do
tags 'Users'
description 'Block a user.'
security [{ api_key: [] }]
produces 'application/json'
parameter name: :id, in: :path, type: :string, required: true, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @user.id }
schema '$ref' => '#/components/schemas/Id'
before do
@user_status_before = @user.status
expect(@user.status).to eq('active')
end
run_test! do |response|
@user.reload
expect(@user.status).to eq('blocked')
end
end
# Admin can block moderator
response(200, 'successful') do
let(:Authorization) { "Bearer #{@admin_api_token}" }
let(:id) { @moderator.id }
schema '$ref' => '#/components/schemas/Id'
before do
@moderator_status_before = @moderator.status
expect(@moderator.status).to eq('active')
end
run_test! do |response|
@moderator.reload
expect(@moderator.status).to eq('blocked')
end
end
response(401, 'unauthorized') do
let(:Authorization) { nil }
let(:id) { @user.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
# Moderator cannot block admin
response(401, 'unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @admin.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
# Nobody cannot block themselves
response(401, 'unauthorized') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { @moderator.id }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer #{@moderator_api_token}" }
let(:id) { 0 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

View File

@@ -0,0 +1,5 @@
FactoryBot.define do
factory :api_key do
user
end
end

View File

@@ -0,0 +1,13 @@
require 'rails_helper'
RSpec.describe ApiKey, type: :model do
it 'automatically digest token upon creation' do
api_key = FactoryBot.build(:api_key)
expect(api_key.token_digest).to eq(nil)
api_key.save
expect(api_key.token_digest).not_to eq(nil)
end
end

View File

@@ -25,7 +25,7 @@ Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
begin
ActiveRecord::Migration.maintain_test_schema!
ActiveRecord::Migration.maintain_test_schema! unless ENV.fetch("RSWAG_SWAGGERIZE", nil)
rescue ActiveRecord::PendingMigrationError => e
puts e.to_s.strip
exit 1

View File

@@ -9,18 +9,13 @@ Capybara.register_driver :chrome_headless do |app|
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--window-size=1400,1400')
capabilities = [
options,
Selenium::WebDriver::Remote::Capabilities.chrome
]
Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: capabilities)
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end
Capybara.javascript_driver = :chrome_headless
# Max wait time for a match to be found by capybara selectors
Capybara.default_max_wait_time = 10
Capybara.default_max_wait_time = 15
# Remove whitespaces characters (\n, etc...) from "page" variable
Capybara.default_normalize_ws = true
@@ -34,4 +29,9 @@ RSpec.configure do |config|
config.before(:each, type: :system, js: true) do
driven_by :chrome_headless
end
# Retry failed tests up to 3 times
config.around(:each, type: :system) do |example|
example.run_with_retry retry: 3
end
end

View File

@@ -0,0 +1,13 @@
# Create an admin and moderator users with respective an API keys
# The @admin_api_token and @moderator_api_token will be available in the tests that include this shared context
RSpec.shared_context 'API Authentication', shared_context: :metadata do
before(:each) do
@admin = FactoryBot.create(:admin)
admin_api_key = FactoryBot.create(:api_key, user: @admin)
@admin_api_token = admin_api_key.token
@moderator = FactoryBot.create(:moderator)
moderator_api_key = FactoryBot.create(:api_key, user: @moderator)
@moderator_api_token = moderator_api_key.token
end
end

View File

@@ -0,0 +1,124 @@
module Swagger
module Schemas
# Generic schema for an error response
def self.Error
{
type: :object,
properties: {
errors: { type: :array, items: { type: :string } }
},
required: ['errors']
}
end
# Generic schema for returning an ID
def self.Id
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID' }
},
required: ['id']
}
end
# Board schema
def self.Board
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the board' },
name: { type: :string, description: 'Name of the board' },
slug: { type: :string, description: 'Slug of the board' },
description: { type: [:string, :null], description: 'Description of the board' },
created_at: { type: :string, description: 'Date and time when the board was created' },
updated_at: { type: :string, description: 'Date and time when the board was last updated' }
},
required: %w[id name slug description created_at updated_at]
}
end
# Comment schema
def self.Comment
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the comment' },
body: { type: :string, description: 'Content of the comment' },
post_id: { type: :integer, description: 'ID of the post the comment belongs to' },
is_post_update: { type: :boolean, description: 'Whether the comment is a post update or not' },
user: { '$ref' => '#/components/schemas/User' },
created_at: { type: :string, description: 'Date and time when the comment was created' },
updated_at: { type: :string, description: 'Date and time when the comment was last updated' }
},
required: %w[id body is_post_update post_id user created_at updated_at]
}
end
# PostStatus schema
def self.PostStatus
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the post status' },
name: { type: :string, description: 'Name of the post status' },
color: { type: :string, description: 'Color of the post status' },
show_in_roadmap: { type: :boolean, description: 'Whether the post status should be shown in the roadmap or not' },
created_at: { type: :string, description: 'Date and time when the post status was created' },
updated_at: { type: :string, description: 'Date and time when the post status was last updated' }
},
required: %w[id name color show_in_roadmap created_at updated_at]
}
end
# User schema
def self.User
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the user' },
email: { type: :string, description: 'Email of the user' },
full_name: { type: :string, description: 'Full name of the user' },
created_at: { type: :string, description: 'Date and time when the user was created' },
updated_at: { type: :string, description: 'Date and time when the user was last updated' }
},
required: %w[id email full_name created_at updated_at]
}
end
# Post schema
def self.Post
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the post' },
title: { type: :string, description: 'Title of the post' },
description: { type: :string, description: 'Content of the post' },
board: { '$ref' => '#/components/schemas/Board' },
post_status: { '$ref' => '#/components/schemas/PostStatus' },
user: { '$ref' => '#/components/schemas/User' },
approval_status: { type: :string, description: 'Approval status of the post (approved, pending or rejected)' },
slug: { type: :string, description: 'Slug of the post' },
created_at: { type: :string, description: 'Date and time when the post was created' },
updated_at: { type: :string, description: 'Date and time when the post was last updated' }
},
required: %w[id title description board post_status user approval_status slug created_at updated_at]
}
end
# Vote schema
def self.Vote
{
type: :object,
properties: {
id: { type: :integer, description: 'Unique ID for the vote' },
post_id: { type: :integer, description: 'ID of the post the vote belongs to' },
user: { '$ref' => '#/components/schemas/User' },
created_at: { type: :string, description: 'Date and time when the vote was created' },
updated_at: { type: :string, description: 'Date and time when the vote was last updated' }
},
required: %w[id post_id user created_at updated_at]
}
end
end
end

134
spec/swagger_helper.rb Normal file
View File

@@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'rails_helper'
require_relative 'support/swagger_schemas'
RSpec.configure do |config|
# Specify a root folder where Swagger JSON files are generated
# NOTE: If you're using the rswag-api to serve API descriptions, you'll need
# to ensure that it's configured to serve Swagger from the same folder
config.openapi_root = Rails.root.join('swagger').to_s
# Define one or more Swagger documents and provide global metadata for each one
# When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will
# be generated at the provided relative path under openapi_root
# By default, the operations defined in spec files are added to the first
# document below. You can override this behavior by adding a openapi_spec tag to the
# the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json'
config.openapi_specs = {
'v1/swagger.yaml' => {
openapi: '3.0.1',
info: {
title: 'Astuto API',
version: 'v1',
description: 'The Astuto API allows you to interact programmatically with the feedback space. You can manage Boards, Posts, Comments, Users, Votes, and more.'
},
paths: {},
servers: [
{
url: 'https://your-company.astuto.io'
}
],
tags: [
{
name: 'Get started',
description: <<~DESC
### How to obtain an API key
You can obtain an API key only if you are an administrator or moderator of the feedback space. To get an API key, follow these steps:
1. Log in to the feedback space.
2. Click on your name in the top right corner and then click on Profile settings.
3. In the API key section, click on the Generate API key button.
4. Copy the API key and store it in a safe place. It cannot be shown again. If you lose it, you will have to generate a new one.
### How to use the API key
To use the API key, you need to pass it in the `Authorization` header of your requests. The header must be in the following format:
`Bearer {your-api-key}`
The API endpoint path is `/api/v1/`.
Note: all API endpoints require authentication, so you must pass the API key in all your requests.
### Moderator vs administrator API key
Moderators and administrators can do almost the same operations through the API. Some notable differences are:
- Only administrators can impersonate other users (see the following section).
- Only administrators can create Boards.
- Moderators cannot block administrators, whereas administrators can block moderators.
### The impersonation technique
Administrators can impersonate other users through the API. This is useful if you want to submit a post or cast a vote on behalf of another user.
Some endpoints accept an `impersonated_user_id` parameter. If you pass this parameter, the API will act as if the request was made by the user with the specified ID. For example, if you want to create a post on behalf of a user with ID 123, you can pass `impersonated_user_id=123` in the request body.
Since you need to know the ID of the user you want to impersonate, this technique is usually used in combination with the Create/Get user endpoint. This endpoint creates a new user and returns its ID if it does not exist or it just returns the ID of the existing user. You can then use the returned ID to impersonate the user in other requests.
### Rate limits
The API has rate limits to prevent abuse. The following limits are in place simultaneously:
- 100 requests every 5 minutes per API key.
- 100 requests every 5 minutes per IP address.
DESC
},
{
name: 'Boards',
description: 'A Board is an entity that groups related Posts together.'
},
{
name: 'Comments',
description: 'A Comment can be written to reply to a Post or to another Comment. Moreover, administrators and moderators can mark a Comment as a "Post update": this is usually used to notify Users that some progress has been made in a Post.'
},
{
name: 'Post Statuses',
description: 'A Post Status is a label that can be assigned to a Post to indicate its current status (e.g. "In progress", "Completed", etc.).'
},
{
name: 'Posts',
description: 'A Post is a piece of content that can be created by Users. It usually represents ideas, suggestions, feedback or reports from your Users. Posts must be associated with a Board and can have Comments.'
},
{
name: 'Users',
description: 'A User is a person who interacts with the feedback space. Users can create and vote Posts, write Comments, and more.'
},
{
name: 'Votes',
description: 'A Vote is a way for Users to express their agreement/support for a Post.'
}
],
components: {
# Schemas are defined in spec/support/swagger_schemas.rb
schemas: {
Error: Swagger::Schemas.Error,
Id: Swagger::Schemas.Id,
Board: Swagger::Schemas.Board,
Comment: Swagger::Schemas.Comment,
PostStatus: Swagger::Schemas.PostStatus,
Post: Swagger::Schemas.Post,
User: Swagger::Schemas.User,
Vote: Swagger::Schemas.Vote
},
securitySchemes: {
api_key: {
type: :apiKey,
name: 'Authorization',
in: :header,
description: 'Pass your API key in the `Authorization` header using format: `Bearer {your-api-key}`'
}
},
}
}
}
# Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'.
# The openapi_specs configuration option has the filename including format in
# the key, this may want to be changed to avoid putting yaml in json files.
# Defaults to json. Accepts ':json' and ':yaml'.
config.openapi_format = :yaml
end